feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAdaptiveResolution, renderPixelRatio } from '../src/scene/adaptiveResolution.ts';
function feed(controller: ReturnType<typeof createAdaptiveResolution>, from: number, duration: number, interval: number) {
let now = from;
for (; now < from + duration; now += interval) controller.observe(now, true);
return now;
}
test('sustained slow rendering lowers resolution once and does not change simulation', () => {
const quality = createAdaptiveResolution(1.5);
const now = feed(quality, 0, 16000, 40);
assert.equal(quality.cap(), 1.25);
feed(quality, now, 8000, 40);
assert.equal(quality.cap(), 1.25);
});
test('upgrade needs longer healthy evidence; maximum stays device-profile bound', () => {
const quality = createAdaptiveResolution(1.5);
let now = feed(quality, 0, 16000, 40);
now = feed(quality, now, 18000, 16);
assert.equal(quality.cap(), 1.25);
now = feed(quality, now, 18000, 16);
assert.equal(quality.cap(), 1.5);
feed(quality, now, 60000, 16);
assert.equal(quality.cap(), 1.5);
});
test('idle and background transitions cannot supply slow-frame evidence', () => {
const quality = createAdaptiveResolution(1.5);
feed(quality, 0, 14000, 40);
quality.observe(14000, false);
quality.reset(120000);
feed(quality, 120000, 14000, 40);
assert.equal(quality.cap(), 1.5);
});
test('new benchmark restarts at maximum quality, while a transition preserves selected quality', () => {
const quality = createAdaptiveResolution(1.5);
const now = feed(quality, 0, 16000, 40);
quality.reset(now);
assert.equal(quality.cap(), 1.25);
quality.restart(now);
assert.equal(quality.cap(), 1.5);
});
test('render dimensions respect native DPR, quality cap and mobile pixel budget', () => {
assert.equal(renderPixelRatio(1, 1.5, 412, 915, true), 1);
assert.equal(renderPixelRatio(3, 1.5, 412, 915, true), 1.5);
const ratio = renderPixelRatio(3, 1.5, 2000, 1000, true);
assert.ok(2000 * 1000 * ratio * ratio <= 1500000 + 0.01);
assert.equal(renderPixelRatio(3, 2, 2000, 1000, false), 2);
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Mesh, MeshStandardMaterial } from 'three';
import { createBuildings } from '../src/scene/buildings.ts';
import { loadBuildingAssets, createBakedBuildings } from '../src/scene/buildingAssets.ts';
test('saved buildings load without GeoJSON and preserve geometry and lighting exactly', async (t) => {
const requested: string[] = [];
t.mock.method(globalThis, 'fetch', async (input: string) => {
requested.push(input);
const path = new URL(input, 'http://localhost').pathname;
return new Response(readFileSync(new URL(`../public${path}`, import.meta.url)));
});
const metadata = JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const bytes = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const terrain = { metadata, heights: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4) };
const data = JSON.parse(readFileSync(new URL('../public/buildings/gwacheon.geojson', import.meta.url), 'utf8'));
const saved = await loadBuildingAssets();
assert.equal(requested.length, 2);
assert.ok(requested.every(path => !path.includes('.geojson')));
const baked = createBakedBuildings(saved, metadata);
const original = createBuildings(data, terrain);
try {
assert.deepEqual(baked.counts, original.counts);
assert.deepEqual(baked.target, original.target);
assert.equal(baked.group.children.length, original.group.children.length);
for (const [i, child] of baked.group.children.entries()) {
const actual = child as Mesh, expected = original.group.children[i] as Mesh;
assert.equal(actual.name, expected.name);
for (const name of ['position', 'normal', 'buildingLightHeight']) {
assert.deepEqual(actual.geometry.getAttribute(name).array, expected.geometry.getAttribute(name).array);
}
assert.deepEqual(actual.geometry.index!.array, expected.geometry.index!.array);
expected.geometry.computeBoundingBox(); expected.geometry.computeBoundingSphere();
assert.deepEqual(actual.geometry.boundingBox, expected.geometry.boundingBox);
assert.deepEqual(actual.geometry.boundingSphere, expected.geometry.boundingSphere);
assert.equal((actual.material as MeshStandardMaterial).color.getHex(), (expected.material as MeshStandardMaterial).color.getHex());
}
assert.deepEqual(baked.spill.texture.image.data, original.spill.texture.image.data);
assert.deepEqual(baked.spill.bounds, original.spill.bounds);
for (const altitude of [-8, 0, 6, -8]) {
baked.update(altitude); original.update(altitude);
assert.equal(baked.spill.intensity.value, original.spill.intensity.value);
}
assert.throws(() => createBakedBuildings(saved, { ...metadata, observerSurfaceElevation: metadata.observerSurfaceElevation + 1 }), /지형/);
assert.throws(() => createBakedBuildings({ ...saved, buffer: saved.buffer.slice(4) }, metadata), /건물/);
} finally { baked.dispose(); original.dispose(); }
});
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { gunzipSync } from 'node:zlib';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { buildingCompressionMiddleware } from '../scripts/building-compression.ts';
test('building transfer uses lossless gzip with automatic client decoding and identity fallback', async () => {
const raw = readFileSync(new URL('../public/buildings/gwacheon.baked.bin', import.meta.url));
const gzip = readFileSync(new URL('../public/buildings/gwacheon.baked.bin.gz', import.meta.url));
assert.deepEqual(gunzipSync(gzip), raw);
assert.ok(gzip.length < raw.length * 0.3);
const middleware = buildingCompressionMiddleware(new URL('../public/', import.meta.url));
const server = createServer((req, res) => middleware(req, res, () => {
res.setHeader('Content-Length', raw.length);
res.end(req.method === 'HEAD' ? undefined : raw);
}));
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const url = `http://127.0.0.1:${(server.address() as {port: number}).port}/buildings/gwacheon.baked.bin?v=test`;
try {
const response = await fetch(url, { headers: { 'Accept-Encoding': 'gzip' } });
assert.equal(response.headers.get('content-encoding'), 'gzip');
assert.equal(Number(response.headers.get('content-length')), gzip.length);
assert.match(response.headers.get('vary')!, /Accept-Encoding/i);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), raw);
const head = await fetch(url, { method: 'HEAD', headers: { 'Accept-Encoding': 'gzip' } });
assert.equal(head.headers.get('content-encoding'), 'gzip');
assert.equal((await head.arrayBuffer()).byteLength, 0);
for (const encoding of ['identity', 'gzip;q=0', '*;q=1, gzip;q=0']) {
const plain = await fetch(url, { headers: { 'Accept-Encoding': encoding } });
assert.equal(plain.headers.get('content-encoding'), null);
assert.deepEqual(Buffer.from(await plain.arrayBuffer()), raw);
}
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); }
});
+43
View File
@@ -0,0 +1,43 @@
import importlib.util
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / '.tools/terrain'))
spec = importlib.util.spec_from_file_location('building_data', ROOT / 'scripts/prepare-vworld-buildings.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
def feature(identity, x=126.99, properties=None):
return {'type': 'Feature', 'id': identity, 'properties': properties or {}, 'geometry': {
'type': 'Polygon', 'coordinates': [[[x, 37.43], [x+.0001, 37.43],
[x+.0001, 37.4301], [x, 37.4301], [x, 37.43]]]}}
class VWorldBuildingsTest(unittest.TestCase):
def test_selection_uses_only_official_features_and_prefers_measured_height(self):
empty = feature('vworld/a', properties={'source:geometry': 'VWorld'})
measured = feature('vworld/b', properties={'height': '12', 'source:geometry': 'VWorld'})
separate = feature('vworld/c', 126.992, properties={'source:geometry': 'VWorld'})
separate['geometry']['coordinates'].append([[126.99202,37.43002], [126.99202,37.43004],
[126.99204,37.43004], [126.99204,37.43002], [126.99202,37.43002]])
result, report = module.select_buildings([empty, measured, separate])
self.assertEqual([f['id'] for f in result], ['vworld/b', 'vworld/c'])
self.assertEqual(result[0]['properties']['height'], '12')
self.assertEqual(len(result[1]['geometry']['coordinates']), 2)
self.assertEqual(report['officialOverlapsSkipped'], ['vworld/a'])
self.assertTrue(all(f['properties']['source:geometry'] == 'VWorld' for f in result))
def test_official_zero_and_overflow_are_missing_and_multipart_is_preserved(self):
raw = feature('archive:1', properties={'A1':'123', 'A16':'0', 'A26':'*********', 'A9':'공동주택'})
raw['geometry'] = {'type':'MultiPolygon', 'coordinates':[raw['geometry']['coordinates'], feature('other',126.992)['geometry']['coordinates']]}
result = module.official_features([raw])
self.assertEqual(len(result), 2)
self.assertNotEqual(result[0]['id'], result[1]['id'])
self.assertTrue(all('height' not in f['properties'] and 'building:levels' not in f['properties'] for f in result))
if __name__ == '__main__':
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Mesh, MeshStandardMaterial, Raycaster, Vector3 } from 'three';
import { buildingHeight, createBuildings } from '../src/scene/buildings.ts';
import type { BuildingData } from '../src/scene/buildings.ts';
const data: BuildingData = JSON.parse(readFileSync(new URL('../public/buildings/gwacheon.geojson', import.meta.url), 'utf8'));
const metadata = JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const buffer = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const terrain = { metadata, heights: new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) };
test('exterior lighting fades through sunrise without emissive window buffers', () => {
const city = createBuildings(data, terrain);
const materials = city.group.children.map(mesh => (mesh as Mesh).material as MeshStandardMaterial);
assert.equal(typeof city.update, 'function');
city.update(-8);
assert.ok(materials.every(m => m.emissive.getHex() === 0), 'buildings reflect exterior light instead of emitting');
let geometryBytes = 0, vertices = 0;
for (const mesh of city.group.children as Mesh[]) {
assert.equal(mesh.geometry.getAttribute('buildingWindow'), undefined);
assert.ok(!mesh.geometry.getAttribute('uv'), 'exterior lighting does not use UV coordinates');
assert.ok(mesh.geometry.index, 'shared vertices use an index buffer');
vertices += mesh.geometry.index.count;
geometryBytes += mesh.geometry.index.array.byteLength;
for (const attribute of Object.values(mesh.geometry.attributes)) geometryBytes += attribute.array.byteLength;
}
assert.ok(geometryBytes < vertices * 36 * 0.6, 'including indices, geometry uses less than 60% of the previous buffer');
assert.ok(city.spill.intensity.value > 0);
const nightSpill = city.spill.intensity.value;
city.update(0);
assert.ok(city.spill.intensity.value > 0 && city.spill.intensity.value < nightSpill);
city.update(6);
assert.ok(materials.every(m => m.emissive.getHex() === 0));
assert.equal(city.spill.intensity.value, 0);
city.update(-8);
assert.equal(city.spill.intensity.value, nightSpill);
city.dispose();
});
test('height provenance distinguishes tags, floor estimates, and placeholders', () => {
assert.deepEqual(buildingHeight({height:'50 m','building:levels':'10'}), {meters:50,source:'tagged'});
assert.equal(buildingHeight({height:'100 ft'}).meters,30.48);
assert.deepEqual(buildingHeight({height:'unknown','building:levels':'20'}),{meters:60,source:'levels'});
assert.deepEqual(buildingHeight({height:'6000',building:'apartments'}),{meters:15,source:'fallback'});
});
test('downloaded footprints are deduplicated, closed, and generate finite merged city geometry', () => {
assert.equal(new Set(data.features.map(f=>f.id)).size, data.features.length);
for(const f of data.features) for(const ring of f.geometry.coordinates) assert.deepEqual(ring[0],ring.at(-1));
const city=createBuildings(data,terrain);
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
assert.ok(city.counts.tagged>0 && city.counts.levels>0 && city.counts.fallback>0);
assert.ok(city.group.children.length<=3);
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
city.dispose();
});
test('courtyard holes remain open while roof rays hit the surrounding footprint', () => {
const ring=(r:number):[number,number][]=>[[126.991-r,37.429-r],[126.991+r,37.429-r],[126.991+r,37.429+r],[126.991-r,37.429+r],[126.991-r,37.429-r]];
const city=createBuildings({type:'FeatureCollection',features:[{id:'synthetic',properties:{height:'30'},geometry:{type:'Polygon',coordinates:[ring(.0002),ring(.00008)]}}]},terrain);
city.group.updateMatrixWorld(true);
const center=city.target.clone(); center.y=1000;
const ray=new Raycaster(center,new Vector3(0,-1,0));
assert.equal(ray.intersectObject(city.group,true).length,0);
ray.ray.origin.x+=12;
assert.ok(ray.intersectObject(city.group,true).length>0);
city.dispose();
});
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Vector3 } from 'three';
import { canPray, createEmoteState } from '../src/controls/emotes.ts';
import { faceTravel, updateThirdPersonCamera } from '../src/controls/thirdPersonCamera.ts';
import { readFile } from 'node:fs/promises';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createSheep } from '../src/scene/sheep.ts';
test('remote sheep share GPU assets but have independent joints and safe disposal', async () => {
const bytes = await readFile(new URL('../public/characters/red-sheep.glb', import.meta.url));
const model = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
const sheep = createSheep(model.scene);
assert.equal(typeof sheep.clone, 'function');
const remote = sheep.clone();
const localArm = sheep.group.getObjectByName('ArmRight')!;
const initial = localArm.quaternion.clone();
remote.pose('wave', 1, false, 1);
assert.ok(localArm.quaternion.equals(initial));
assert.ok(remote.group.getObjectByName('ArmRight')!.quaternion.angleTo(initial) > 1);
const localMeshes: Mesh[] = [], remoteMeshes: Mesh[] = [];
sheep.group.traverse(o => { if (o instanceof Mesh) localMeshes.push(o); });
remote.group.traverse(o => { if (o instanceof Mesh) remoteMeshes.push(o); });
let disposals = 0;
localMeshes.forEach((mesh, i) => {
assert.equal(remoteMeshes[i].geometry, mesh.geometry);
assert.equal(remoteMeshes[i].material, mesh.material);
mesh.geometry.addEventListener('dispose', () => disposals++);
});
remote.dispose();
assert.equal(disposals, 0);
sheep.dispose();
assert.ok(disposals > 0);
});
test('sheep dims with night and clouds without losing its colors after repeated updates', async () => {
const bytes = await readFile(new URL('../public/characters/red-sheep.glb', import.meta.url));
const model = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
const sheep = createSheep(model.scene);
const materials = new Set<MeshBasicMaterial>();
sheep.group.traverse(object => {
if (object instanceof Mesh) {
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
assert.ok(material instanceof MeshBasicMaterial);
materials.add(material);
}
}
});
const originals = [...materials].map(material => material.color.clone());
const brightest = [...materials].sort((a, b) => b.color.r - a.color.r)[0];
const day = brightest.color.r;
sheep.updateLighting(30, .65);
assert.ok(brightest.color.r < day);
sheep.updateLighting(-18, .65);
const night = brightest.color.r;
assert.ok(night > day * .05 && night < day * .2, 'night stays visible but substantially darker');
sheep.updateLighting(-18, .65);
assert.equal(brightest.color.r, night, 'updates must not accumulate darkening');
sheep.updateLighting(0, .65);
assert.ok(brightest.color.r > night && brightest.color.r < day);
sheep.updateLighting(30, 1);
[...materials].forEach((material, index) => assert.ok(material.color.equals(originals[index])));
sheep.dispose();
});
test('prayer is available only within five minutes of the selected sunrise', () => {
const sunrise = new Date('2026-10-30T21:50:00Z');
const at = (offset: number) => canPray(new Date(+sunrise + offset), sunrise);
assert.equal(at(-300001), false);
assert.equal(at(-300000), true);
assert.equal(at(0), true);
assert.equal(at(300000), true);
assert.equal(at(300001), false);
assert.equal(canPray(sunrise, null), false);
});
test('wave expires; movement and unavailable sunrise cancel prayer', () => {
const state = createEmoteState();
assert.equal(state.start('pray', false), false);
state.start('wave', false);
assert.equal(state.update(1, false, false), 'wave');
assert.equal(state.update(2, false, false), 'idle');
state.start('pray', true);
assert.equal(state.update(.1, false, true), 'pray');
assert.equal(state.update(.1, true, true), 'idle');
state.start('pray', true);
assert.equal(state.update(.1, false, false), 'idle');
});
test('third person camera stays behind observer and above terrain without moving observer', () => {
const anchor = new PerspectiveCamera();
anchor.position.set(10, 1.7, 20);
anchor.lookAt(11, 1.7, 20);
const original = anchor.position.clone();
const follow = anchor.clone();
updateThirdPersonCamera(follow, anchor, () => 0);
assert.ok(follow.position.x < anchor.position.x);
assert.deepEqual(anchor.position, original);
assert.ok(follow.getWorldDirection(new Vector3()).x > .5);
updateThirdPersonCamera(follow, anchor, () => 4);
assert.ok(follow.position.y >= 4.35);
});
test('exported sheep has working joints and renders the final idle pose after walking', async () => {
const bytes = await readFile(new URL('../public/characters/red-sheep.glb', import.meta.url));
const model = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
const sheep = createSheep(model.scene);
const leg = sheep.group.getObjectByName('LegLeft')!;
const rest = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, true, 1), true);
assert.ok(leg.quaternion.angleTo(rest) > .01);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
assert.ok(leg.quaternion.angleTo(rest) < 1e-6);
assert.equal(sheep.pose('idle', 0, false, 1), false);
const arm = sheep.group.getObjectByName('ArmRight')!;
const armRest = arm.quaternion.clone();
sheep.pose('wave', 1, false, 1);
assert.ok(arm.quaternion.angleTo(armRest) > 1);
sheep.pose('pray', 1, false, 1);
assert.ok(arm.quaternion.angleTo(armRest) > .5);
sheep.dispose();
});
test('sideways and rear destinations turn the character forward and ease the camera behind it', () => {
for (const step of [new Vector3(1, 0, 0), new Vector3(0, 0, 1), new Vector3(-1, 0, -.01)]) {
const character = new Group();
character.rotation.y = Math.PI;
const camera = new PerspectiveCamera();
camera.rotation.set(-.2, 0, 0, 'YXZ');
const original = camera.quaternion.clone();
const initialFacing = character.quaternion.clone();
const speed = faceTravel(character, camera, step, 1 / 60);
assert.ok(character.quaternion.angleTo(initialFacing) <= 4 / 60 + 1e-6, 'body must turn gradually');
if (step.z === 1) assert.equal(speed, 0, 'rear destinations turn before translating');
assert.ok(camera.quaternion.angleTo(original) < .22, 'camera must ease, not snap');
for (let i = 0; i < 180; i++) faceTravel(character, camera, step, 1 / 60);
assert.ok(character.getWorldDirection(new Vector3()).dot(step.clone().normalize()) > .999);
const view = camera.getWorldDirection(new Vector3());
view.y = 0;
assert.ok(view.normalize().dot(step.clone().normalize()) > .999);
assert.ok(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
const settled = character.quaternion.clone();
faceTravel(character, camera, new Vector3(), 1 / 60);
assert.ok(character.quaternion.angleTo(settled) < 1e-6, 'idle keeps the last facing');
const manual = camera.quaternion.clone();
faceTravel(character, camera, step.clone().negate(), 1 / 60, false);
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
}
});
+57
View File
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Vector3 } from 'three';
import { isCoastalLand, seaHeight, createOceanGeometry } from '../src/scene/coast.ts';
import type { CoastData } from '../src/scene/coast.ts';
import { terrainHeightAt, validateTerrain } from '../src/scene/terrainGeometry.ts';
import { COAST } from '../src/constants/coast.ts';
import { LOCATIONS } from '../src/constants/locations.ts';
import { walkingHeight } from '../src/controls/walking.ts';
import { structurePosition } from '../src/scene/structures.ts';
const base = new URL('../public/terrain/hajodae/', import.meta.url);
const coast: CoastData = JSON.parse(readFileSync(new URL('coast.json', base), 'utf8'));
const meta = JSON.parse(readFileSync(new URL('metadata.json', base), 'utf8'));
const b = readFileSync(new URL('heights.f32', base));
const heights = new Float32Array(b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength));
test('Hajodae extraction has complete bounded coverage and a dry pavilion origin', () => {
validateTerrain(meta, heights, COAST);
assert.equal(meta.radiusMeters,1500);
assert.equal(heights.byteLength,362404);
assert.ok(isCoastalLand(coast,0,0));
assert.ok(terrainHeightAt(meta, heights,0,0)>0);
assert.ok(heights.every(Number.isFinite));
assert.equal(meta.corrections.osmHeightCorrection,false);
const shore = structurePosition(38.0231,128.72599,coast.origin);
assert.ok(isCoastalLand(coast,shore.x,shore.z));
assert.ok(walkingHeight(meta,heights,shore.x,shore.z) >= seaHeight(shore.x,shore.z,meta.observerSurfaceElevation)-0.001,
'the near-shore land sample must not be pulled down by offshore vertices');
assert.equal(isCoastalLand(coast,400,0),false,'east of the beach is sea');
const cape=structurePosition(38.021987,128.7355911,coast.origin);
assert.ok(isCoastalLand(coast,cape.x,cape.z),'lighthouse headland is land');
assert.equal(isCoastalLand(coast,1600,0),false,'walking is limited to surveyed coverage');
});
test('Hajodae view uses the pavilion and faces the pine reference east-northeast', () => {
assert.deepEqual(meta.origin, {latitude:38.020813,longitude:128.7352564});
assert.deepEqual(LOCATIONS.hajodae.coast.lookTarget, COAST.pine);
const pine = structurePosition(COAST.pine.latitude, COAST.pine.longitude, meta.origin);
const azimuth = Math.atan2(pine.x,-pine.z)*180/Math.PI;
assert.ok(azimuth > 75 && azimuth < 80);
});
test('ocean follows terrain curvature and faces upward', () => {
const h=meta.observerSurfaceElevation;
assert.equal(seaHeight(0,0,h),-h);
assert.ok(seaHeight(1000,0,h)<-h);
const g=createOceanGeometry(h);
const p=g.getAttribute('position'), n=g.getAttribute('normal');
assert.ok(n.getY(Math.floor(n.count/2))>0);
for (const i of [0,Math.floor(p.count/2),p.count-1]) {
assert.ok(Math.abs(p.getY(i)-seaHeight(p.getX(i),p.getZ(i),h))<0.001);
}
const v=new Vector3().fromBufferAttribute(p,0);
assert.ok(Number.isFinite(v.length()));
g.dispose();
});
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { directionFromAngles } from '../src/astronomy/direction.ts';
import { fromKstInput, kstClock, kstFields } from '../src/ui/time.ts';
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
import { observer } from '../src/config/observer.ts';
test('N/E/S/W and zenith map to the specified fixed world axes', () => {
for (const [az, alt, expected] of [
[0, 0, [0, 0, -1]], [90, 0, [1, 0, 0]],
[180, 0, [0, 0, 1]], [270, 0, [-1, 0, 0]], [0, 90, [0, 1, 0]],
] as const) {
const actual = directionFromAngles(az, alt);
actual.forEach((value, i) => assert.ok(Math.abs(value - expected[i]) < 1e-12));
}
assert.ok(directionFromAngles(90, 20)[1] > directionFromAngles(90, 10)[1]);
});
test('KST inputs preserve midnight, last minute and year rollover regardless of host timezone', () => {
assert.equal(fromKstInput('2026-01-01', 0).toISOString(), '2025-12-31T15:00:00.000Z');
assert.equal(fromKstInput('2026-01-01', 1439).toISOString(), '2026-01-01T14:59:00.000Z');
assert.deepEqual(kstFields(new Date('2026-10-28T15:00:00Z')), { date: '2026-10-29', minutes: 0 });
});
test('sunrise belongs to the selected KST date even at midnight and 23:59', () => {
for (const minutes of [0, 720, 1439]) {
const result = calculateSun(fromKstInput('2026-10-29', minutes), observer);
assert.equal(kstFields(result.sunrise!).date, '2026-10-29');
assert.equal(kstFields(result.sunset!).date, '2026-10-29');
}
});
test('observer height advances sunrise and delays sunset relative to the 0m horizon', () => {
const result = calculateSun(fromKstInput('2026-10-29', 720), observer);
assert.ok(result.sunrise!.getTime() < result.standardSunrise!.getTime());
assert.ok(result.sunset!.getTime() > result.standardSunset!.getTime());
});
test('date inputs and labels consistently use Asia/Seoul historical offsets', () => {
for (const date of ['1901-01-01', '1988-06-01', '2026-10-29']) {
const instant = fromKstInput(date, 720);
assert.equal(kstClock(instant), '12:00:00');
assert.deepEqual(kstFields(instant), { date, minutes: 720 });
}
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { GISAMUN } from '../src/constants/gisamun.ts';
import { isCoastalLand } from '../src/scene/coast.ts';
import { createTerrainGeometry, validateTerrain } from '../src/scene/terrainGeometry.ts';
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
import { directionFromAngles } from '../src/astronomy/direction.ts';
const base = new URL('../public/terrain/gisamun/', import.meta.url);
test('Gisamun observer is on land and the eastern view is sea, with both DSM tiles used', () => {
const meta=JSON.parse(readFileSync(new URL('metadata.json',base),'utf8'));
const coast=JSON.parse(readFileSync(new URL('coast.json',base),'utf8'));
const bytes=readFileSync(new URL('heights.f32',base));
const heights=new Float32Array(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength));
validateTerrain(meta,heights,GISAMUN);
assert.ok(isCoastalLand(coast,0,0));
assert.equal(isCoastalLand(coast,200,0),false);
assert.equal(meta.sources.filter((s:{file:string})=>s.file.endsWith('.tif')).length,2);
assert.ok(heights.every(Number.isFinite));
assert.ok(heights.some(h=>h<0), 'retain below-zero Copernicus samples instead of clamping to OSM land/sea heights');
assert.equal(meta.corrections.osmHeightCorrection,false);
});
test('Gisamun terrain clears the October dawn sun that is blocked at Hajodae', () => {
const meta=JSON.parse(readFileSync(new URL('metadata.json',base),'utf8'));
const bytes=readFileSync(new URL('heights.f32',base));
const heights=new Float32Array(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength));
const geometry=createTerrainGeometry(meta,heights,true), material=new MeshBasicMaterial();
const mesh=new Mesh(geometry,material);
const observer={...GISAMUN,elevation:meta.observerSurfaceElevation,eyeHeight:1.7};
for (const time of ['06:52','06:55','07:00']) {
const sun=calculateSun(new Date(`2026-10-29T${time}:00+09:00`),observer);
assert.ok(sun.altitude>0);
const ray=new Raycaster(new Vector3(0,1.7,0),new Vector3(...directionFromAngles(sun.azimuth,sun.altitude)));
assert.equal(ray.intersectObject(mesh).length,0,`${time} should clear the modelled terrain`);
}
geometry.dispose(); material.dispose();
});
+27
View File
@@ -0,0 +1,27 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {Raycaster,Vector3} from 'three';
import {createHarbour,insideHarbour,isGisamun} from '../src/scene/harbour.ts';
import {GISAMUN_HARBOUR as PORT} from '../src/constants/harbour.ts';
import {GISAMUN} from '../src/constants/gisamun.ts';
test('Gisamun harbour keeps its basin and entrance open with solid raised quays',()=>{
const port=createHarbour(.1375735);
port.group.updateMatrixWorld(true);
for(const [x,z] of [[-80,-250],[70,-155],[140,-150]]) {
assert.equal(insideHarbour(PORT.land,x,z),false,'water must not become pavement');
assert.equal(new Raycaster(new Vector3(x,100,z),new Vector3(0,-1,0)).intersectObject(port.group,true).length,0);
}
assert.equal(insideHarbour(PORT.basin,-80,-250),true);
assert.equal(insideHarbour(PORT.basin,140,-150),false);
for(const [x,z] of [[-150,-280],[20,-155],[100,-165],[-25,-350]]) {
const y=port.heightAt(x,z);
assert.ok(y!==undefined && y>2.8);
const hit=new Raycaster(new Vector3(x,100,z),new Vector3(0,-1,0)).intersectObject(port.group,true)[0];
assert.ok(hit,'quay roof must face upward');
assert.ok(Math.abs(hit.point.y-y)<.01,'walking height matches the roof');
}
assert.equal(isGisamun(GISAMUN),true);
assert.equal(isGisamun({latitude:38.0231,longitude:128.7254}),false);
port.dispose();
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Group } from 'three';
import { createServerClock } from '../src/multiplayer/clock.ts';
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
import type { Sheep } from '../src/scene/sheep.ts';
import type { PlayerState } from '../src/multiplayer/protocol.ts';
test('server clock uses round trip midpoint and monotonic elapsed time', () => {
let mono = 100;
const clock = createServerClock(() => mono);
mono = 140;
clock.synchronize(1000000, 100);
assert.equal(clock.now(), 1000020);
mono = 1140;
assert.equal(clock.now(), 1001020);
});
test('remote snapshot reconciles membership, equipment and interpolated motion without local duplicate', () => {
const scene = new Group();
const equipment: boolean[][] = [];
let disposed = 0;
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
const remote = createRemotePlayers(template, scene, () => 3);
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player, { ...player, id: 'self' }], 'self');
assert.equal(scene.children.length, 1);
assert.deepEqual(equipment.at(-1), [true, true, false]);
remote.synchronize([{ ...player, x: 1, updatedAt: 1100 }], 'self');
remote.update(.075, 1100);
assert.ok(scene.children[0].position.x > 0 && scene.children[0].position.x < 1);
remote.update(.2, 1300);
assert.equal(scene.children[0].position.x, 1);
assert.equal(scene.children[0].position.y, 3);
remote.synchronize([], 'self');
assert.equal(scene.children.length, 0);
assert.equal(disposed, 1);
});
+274
View File
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { once } from 'node:events';
import { randomUUID } from 'node:crypto';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join as joinPath } from 'node:path';
import { gzipSync } from 'node:zlib';
import { io, type Socket } from 'socket.io-client';
import { createApp, type AppOptions } from '../server/app.ts';
import { MemoryVisitStore, type VisitRecord } from '../server/visits.ts';
import { MULTIPLAYER } from '../src/constants/multiplayer.ts';
import type { JoinRequest, JoinResult, PlayerState, RoomState } from '../src/multiplayer/protocol.ts';
const request = (): JoinRequest => ({ locationId: MULTIPLAYER.locationId, roomId: MULTIPLAYER.roomId, visitorId: randomUUID(), nickname: 'Visitor' });
const pause = (ms = 30) => new Promise(resolve => setTimeout(resolve, ms));
async function until(check: () => boolean) { for (let i = 0; i < 100; i++) { if (check()) return; await pause(10); } assert.fail('condition timed out'); }
async function fixture(store = new MemoryVisitStore(), options: Partial<AppOptions> = {}) {
const app = createApp({ visitStore: store, weather: false, onError: () => {}, ...options });
app.httpServer.listen(0, '127.0.0.1');
await once(app.httpServer, 'listening');
const url = `http://127.0.0.1:${(app.httpServer.address() as { port: number }).port}`;
const clients: Socket[] = [];
return { app, store, url, async connect() {
const socket = io(url, { transports: ['websocket'], forceNew: true, reconnection: false });
clients.push(socket); await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve); socket.once('connect_error', reject);
}); return socket;
}, async close() { clients.forEach(socket => socket.disconnect()); await app.close(); } };
}
const join = (socket: Socket, value = request()): Promise<JoinResult> => socket.timeout(3000).emitWithAck('join', value);
test('failed admission replies before blocked cleanup and releases its reservation', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
class OfflineStore extends MemoryVisitStore {
override async insert() { throw Error('offline'); }
override async finish() { await gate; }
}
const f = await fixture(new OfflineStore());
try {
const socket = await f.connect();
const result = await socket.timeout(500).emitWithAck('join', request());
assert.deepEqual(result, { ok: false, code: 'STORAGE_UNAVAILABLE' });
assert.equal(f.app.room.reserved, 0);
} finally { release(); await f.close(); }
});
test('lobby lists only the available summit channel and reports occupancy without joining', async () => {
const f = await fixture();
try {
const response = await fetch(`${f.url}/api/lobby`);
assert.equal(response.status, 200);
assert.equal(response.headers.get('cache-control'), 'no-store');
const lobby = await response.json();
assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'gwanaksan').enabled, true);
assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'hajodae').enabled, false);
assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'gisamun').enabled, false);
assert.deepEqual(lobby.channel, { id: MULTIPLAYER.roomId, name: '채널 1', capacity: 100, players: 0, available: true });
assert.equal(f.store.records.size, 0);
await join(await f.connect());
assert.equal((await (await fetch(`${f.url}/api/lobby`)).json()).channel.players, 1);
} finally { await f.close(); }
});
function player(result: JoinResult): PlayerState { assert.equal(result.ok, true); if (!result.ok) throw Error(); return result.state.players.find(p => p.id === result.playerId)!; }
test('100 real simultaneous admissions reserve capacity; 101 rejected and released slot reusable', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
class SlowStore extends MemoryVisitStore { override async insert(record: VisitRecord) { await gate; await super.insert(record); } }
const f = await fixture(new SlowStore());
try {
const clients = await Promise.all(Array.from({ length: 101 }, () => f.connect()));
const admissions = clients.map(c => join(c));
await until(() => f.app.room.reserved === 100);
assert.equal(f.app.room.size, 0);
release();
const results = await Promise.all(admissions);
assert.equal(results.filter(r => r.ok).length, 100);
assert.deepEqual(results.find(r => !r.ok), { ok: false, code: 'ROOM_FULL' });
const index = results.findIndex(r => r.ok);
const duplicate = await join(clients[index]);
assert.equal(player(duplicate).id, player(results[index]).id);
assert.equal(f.store.records.size, 100);
clients[index].disconnect();
await until(() => f.app.room.size === 99);
assert.equal((await join(clients[results.findIndex(r => !r.ok)])).ok, true);
} finally { release(); await f.close(); }
});
test('pending admission is reserved, duplicate pending rejected, disconnect during insert closes once', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
class SlowStore extends MemoryVisitStore { override async insert(record: VisitRecord) { await gate; await super.insert(record); } }
const f = await fixture(new SlowStore());
try {
const socket = await f.connect();
socket.emit('join', request(), () => {});
await until(() => f.app.room.reserved === 1);
assert.deepEqual(await join(socket), { ok: false, code: 'JOIN_PENDING' });
socket.disconnect();
await until(() => f.app.io.sockets.sockets.size === 0);
assert.equal(f.app.room.reserved, 1);
release();
await until(() => f.store.records.size === 1 && [...f.store.records.values()][0].endReason !== null);
assert.equal(f.app.room.reserved, 0);
assert.equal(f.app.room.size, 0);
assert.equal([...f.store.records.values()][0].endReason, 'disconnect');
} finally { release(); await f.close(); }
});
test('storage failure denies admission and frees reservation; invalid joins never persist', async () => {
class BrokenStore extends MemoryVisitStore { override async insert() { throw Error('offline'); } }
const f = await fixture(new BrokenStore());
try {
const socket = await f.connect();
assert.deepEqual(await join(socket, { ...request(), locationId: 'coast' }), { ok: false, code: 'INVALID_JOIN' });
assert.deepEqual(await join(socket, { ...request(), visitorId: 'spoof' }), { ok: false, code: 'INVALID_JOIN' });
assert.deepEqual(await join(socket), { ok: false, code: 'STORAGE_UNAVAILABLE' });
assert.equal(f.app.room.reserved, 0); assert.equal(f.app.room.size, 0);
} finally { await f.close(); }
});
test('validated gear reaches late joiner; invalid movement corrects; reset preserves gear and reconnect clears it', async () => {
const f = await fixture();
try {
const a = await f.connect(); const initial = player(await join(a));
const gear = { umbrella: true, raincoat: true, hood: true };
a.emit('player:update', { ...initial, equipment: gear, id: 'spoof' });
await until(() => f.app.room.snapshot().players[0]?.equipment.hood === true);
const b = await f.connect(); const second = await join(b); assert.ok(second.ok);
assert.deepEqual(second.state.players.find(p => p.id === initial.id)?.equipment, gear);
assert.ok(second.state.players.every(p => p.id !== 'spoof'));
for (const invalid of [{ x: 1e9 }, { yaw: 100 }, { equipment: { umbrella: false, raincoat: false, hood: true } }, { emote: 'hack' }]) {
await pause(110);
const correction = new Promise<PlayerState>(resolve => a.once('player:correction', resolve));
a.emit('player:update', { ...initial, ...invalid });
assert.equal((await correction).x, initial.x);
}
const reset: PlayerState = await a.timeout(3000).emitWithAck('player:reset');
assert.deepEqual(reset.equipment, gear);
a.disconnect(); await until(() => f.app.room.size === 1);
const c = await f.connect(); const reconnected = player(await join(c));
assert.notEqual(reconnected.id, initial.id); assert.equal(reconnected.equipment.hood, false);
const time = await c.timeout(3000).emitWithAck('clock:sync'); assert.ok(Math.abs(time - Date.now()) < 1000);
assert.deepEqual(await (await fetch(f.url + '/health')).json(), { ok: true });
} finally { await f.close(); }
});
test('server tick expires a wave even when its client sends no idle update', async () => {
const f = await fixture();
try {
const a = await f.connect(); const p = player(await join(a));
a.emit('player:update', { ...p, emote: 'wave' });
await until(() => f.app.room.snapshot().players[0].emote === 'wave');
await pause(3200);
const ended = f.app.room.snapshot().players[0];
assert.equal(ended.emote, 'idle'); assert.ok(ended.updatedAt > p.updatedAt);
} finally { await f.close(); }
});
test('stationary room sends no 100ms snapshots, changed snapshots have increasing sequence', async () => {
const f = await fixture();
try {
const a = await f.connect(); const p = player(await join(a)); await pause(150);
const states: RoomState[] = []; a.on('room:state', s => states.push(s));
await pause(250); assert.equal(states.length, 0);
a.emit('player:update', { ...p, equipment: { umbrella: true, raincoat: false, hood: false } });
await until(() => states.length === 1);
a.emit('player:reset', () => {}); await until(() => states.length === 2);
assert.ok(states[1].sequence > states[0].sequence);
} finally { await f.close(); }
});
test('heartbeat and disconnect writes serialize, shutdown closes all visits and store', async () => {
let release!: () => void, heartbeatStarted = false, closed = false;
const gate = new Promise<void>(resolve => { release = resolve; });
const writes: string[] = [];
class SlowHeartbeat extends MemoryVisitStore {
override async heartbeat(ids: string[], at: Date) { heartbeatStarted = true; await gate; writes.push('heartbeat'); await super.heartbeat(ids, at); }
override async finish(id: string, at: Date, reason: 'disconnect' | 'shutdown' | 'interrupted') { writes.push(reason); await super.finish(id, at, reason); }
override async close() { closed = true; }
}
const f = await fixture(new SlowHeartbeat());
try {
const a = await f.connect(); await join(a);
const b = await f.connect(); await join(b);
const beat = f.app.room.heartbeat(); await until(() => heartbeatStarted);
a.disconnect(); await until(() => f.app.room.size === 1);
assert.deepEqual(writes, []);
release(); await beat;
await f.app.close();
assert.deepEqual(writes, ['heartbeat', 'disconnect', 'shutdown']);
assert.equal(closed, true);
assert.ok([...f.store.records.values()].every(record => record.leftAt !== null));
} finally { release(); await f.close(); }
});
test('heartbeat failure fails closed for subsequent admission and health', async () => {
class BrokenHeartbeat extends MemoryVisitStore { override async heartbeat() { throw Error('write failed'); } }
const f = await fixture(new BrokenHeartbeat());
try {
const a = await f.connect(); await join(a); await f.app.room.heartbeat();
const b = await f.connect();
assert.deepEqual(await join(b), { ok: false, code: 'STORAGE_UNAVAILABLE' });
const health = await fetch(f.url + '/health'); assert.equal(health.status, 503);
assert.deepEqual(await health.json(), { ok: false });
} finally { await f.close(); }
});
test('movement spends a bounded shared token budget, rejects unsafe path and malformed updates', async () => {
const f = await fixture();
try {
const a = await f.connect(); let p = player(await join(a));
const start = { ...p };
// Repeated 2m moves at 10Hz must not get a fresh per-packet allowance.
a.emit('player:update', { ...p, x: p.x + 1.5, moving: true });
await until(() => f.app.room.snapshot().players[0].x !== p.x);
p = f.app.room.snapshot().players[0]; await pause(110);
const corrected = new Promise<PlayerState>(resolve => a.once('player:correction', resolve));
a.emit('player:update', { ...p, x: p.x + 1.5, moving: true });
assert.equal((await corrected).x, p.x);
assert.ok(Math.abs(p.x - start.x) <= 2);
for (const update of [null, { ...p, x: null }, { ...p, moving: 1 }, { ...p, equipment: { umbrella: 'true', raincoat: false, hood: false } }]) {
await pause(110);
const correction = new Promise<PlayerState>(resolve => a.once('player:correction', resolve));
a.emit('player:update', update); assert.equal((await correction).id, p.id);
}
} finally { await f.close(); }
});
test('weather snapshot is delivered to late joiner and server updates are shared', async () => {
const f = await fixture();
try {
const state = { status: 'ready' as const, weather: { cloudCover: 80, visibility: 1000, rain: 3, snowfall: 0 } };
f.app.room.setWeather(state);
const a = await f.connect(); const received = new Promise(resolve => a.once('weather:state', resolve));
await join(a); assert.deepEqual(await received, state);
const changed = new Promise(resolve => a.once('weather:state', resolve));
f.app.room.setWeather({ status: 'error', weather: null });
assert.deepEqual(await changed, { status: 'error', weather: null });
} finally { await f.close(); }
});
test('origin checks apply to WebSocket and oversized payload disconnects', async () => {
const f = await fixture();
try {
const rejected = io(f.url, { transports: ['websocket'], forceNew: true, reconnection: false, extraHeaders: { Origin: 'https://untrusted.example' } });
try { await new Promise<void>((resolve, reject) => { rejected.once('connect_error', () => resolve()); rejected.once('connect', () => reject(Error('origin accepted'))); }); }
finally { rejected.disconnect(); }
const a = await f.connect(); await join(a);
const disconnected = new Promise(resolve => a.once('disconnect', resolve));
a.emit('player:update', { data: 'x'.repeat(5000) }); await disconnected;
await until(() => f.app.room.size === 0);
} finally { await f.close(); }
});
test('static dist serving supports gzip and HEAD and rejects traversal and hidden files', async () => {
const directory = await mkdtemp(joinPath(tmpdir(), 'sunrise-server-'));
await writeFile(joinPath(directory, 'index.html'), '<h1>Sunrise</h1>');
await writeFile(joinPath(directory, 'asset.bin'), 'asset payload');
await writeFile(joinPath(directory, 'asset.bin.gz'), gzipSync('asset payload'));
await writeFile(joinPath(directory, '.env'), 'secret');
const f = await fixture(new MemoryVisitStore(), { distDirectory: directory });
try {
assert.match(await (await fetch(f.url)).text(), /Sunrise/);
const gzip = await fetch(f.url + '/asset.bin'); assert.equal(gzip.headers.get('content-encoding'), 'gzip');
assert.equal(await gzip.text(), 'asset payload');
const head = await fetch(f.url + '/asset.bin', { method: 'HEAD' }); assert.equal(await head.text(), '');
for (const path of ['/.env', '/%2e%2e%5cpackage.json', '/%00', '/C%3a/windows']) assert.equal((await fetch(f.url + path)).status, 404);
const identity = await fetch(f.url + '/asset.bin', { headers: { 'Accept-Encoding': 'gzip;q=0' } });
assert.equal(identity.headers.get('content-encoding'), null);
} finally { await f.close(); await rm(directory, { recursive: true, force: true }); }
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Mesh, Points, Vector3 } from 'three';
import { createNightSky } from '../src/scene/nightSky.ts';
import { observer } from '../src/config/observer.ts';
test('decorative stars stay fixed across reloads and disappear in daylight', () => {
const a = createNightSky(new Vector3()), b = createNightSky(new Vector3());
const stars = a.group.children.find(child => child instanceof Points)! as Points;
const other = b.group.children.find(child => child instanceof Points)! as Points;
assert.deepEqual(stars.geometry.attributes.position.array, other.geometry.attributes.position.array);
const date = new Date('2026-10-30T23:00:00+09:00');
a.update(date, observer, -20);
assert.equal(stars.visible, true);
a.update(date, observer, 10);
assert.equal(stars.visible, false);
a.dispose(); b.dispose();
});
test('moon moves from below the horizon into the northeastern night sky', () => {
const eye = new Vector3(100, 500, -100);
const sky = createNightSky(eye);
const moon = sky.group.children.find(child => child instanceof Mesh)! as Mesh;
sky.update(new Date('2026-10-30T18:00:00+09:00'), observer, -10);
assert.ok(moon.position.y < eye.y);
sky.update(new Date('2026-10-30T23:00:00+09:00'), observer, -30);
const direction = moon.position.clone().sub(eye).normalize();
assert.ok(direction.x > 0 && direction.z < 0 && direction.y > 0);
assert.ok(Math.abs(Math.asin(direction.y) * 180 / Math.PI - 21) < 0.2);
sky.dispose();
});
+36
View File
@@ -0,0 +1,36 @@
// Independent NOAA/Meeus solar calculator, used only by verification, never by the app.
// Equations: https://gml.noaa.gov/grad/solcalc/calcdetails.html
// https://gml.noaa.gov/grad/solcalc/main.js
// Geocentric solar coordinates + NOAA piecewise atmospheric refraction.
export function noaaPosition(date: Date, latitude: number, longitude: number) {
const rad = Math.PI / 180;
const sin = (d: number) => Math.sin(d * rad);
const cos = (d: number) => Math.cos(d * rad);
const tan = (d: number) => Math.tan(d * rad);
const t = (date.getTime() / 86400000 + 2440587.5 - 2451545) / 36525;
const l = ((280.46646 + t * (36000.76983 + t * 0.0003032)) % 360 + 360) % 360;
const m = 357.52911 + t * (35999.05029 - 0.0001537 * t);
const e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
const c = sin(m) * (1.914602 - t * (0.004817 + 0.000014 * t))
+ sin(2 * m) * (0.019993 - 0.000101 * t) + sin(3 * m) * 0.000289;
const omega = 125.04 - 1934.136 * t;
const lambda = l + c - 0.00569 - 0.00478 * sin(omega);
const epsilon = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 + 0.00256 * cos(omega);
const decl = Math.asin(sin(epsilon) * sin(lambda)) / rad;
const y = tan(epsilon / 2) ** 2;
const eq = 4 / rad * (y * sin(2 * l) - 2 * e * sin(m) + 4 * e * y * sin(m) * cos(2 * l)
- 0.5 * y ** 2 * sin(4 * l) - 1.25 * e ** 2 * sin(2 * m));
const utcMinutes = date.getUTCHours() * 60 + date.getUTCMinutes() + date.getUTCSeconds() / 60;
const solarMinutes = ((utcMinutes + eq + 4 * longitude) % 1440 + 1440) % 1440;
const ha = solarMinutes / 4 - 180;
const geometric = Math.asin(sin(latitude) * sin(decl) + cos(latitude) * cos(decl) * cos(ha)) / rad;
const azimuth = (Math.atan2(sin(ha), cos(ha) * sin(latitude) - tan(decl) * cos(latitude)) / rad + 180) % 360;
const te = tan(geometric);
let refraction = 0;
if (geometric <= 85) {
if (geometric > 5) refraction = 58.1 / te - 0.07 / te ** 3 + 0.000086 / te ** 5;
else if (geometric > -0.575) refraction = 1735 + geometric * (-518.2 + geometric * (103.4 + geometric * (-12.79 + geometric * 0.711)));
else refraction = -20.774 / te;
}
return { azimuth, altitude: geometric + refraction / 3600 };
}
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Vector3 } from 'three';
import { createPrecipitation } from '../src/scene/precipitation.ts';
test('more precipitation draws more particles; mixed weather animates both and dry weather stops', (t) => {
t.mock.method(Math, 'random', () => 0.5);
const effect = createPrecipitation(() => -100);
const camera = new Vector3(0, 10, 0);
const [rain, snow] = effect.group.children as any[];
try {
assert.equal(effect.update(0, camera, 0, 0, 1, 600), false);
effect.update(0, camera, 0.2, 0, 1, 600);
const light = rain.geometry.drawRange.count;
assert.ok(light > 0);
assert.equal(snow.visible, false);
const before = rain.geometry.attributes.position.getY(0);
effect.update(0.01, camera, 0.2, 0, 1, 600);
assert.ok(rain.geometry.attributes.position.getY(0) < before);
assert.equal(effect.update(0.01, camera, 15, 2, 1, 600), true);
assert.ok(rain.geometry.drawRange.count > light);
assert.ok(snow.visible && snow.geometry.drawRange.count > 0);
const snowBefore = snow.geometry.attributes.position.getY(0);
effect.update(0.01, camera, 15, 2, 1, 600);
assert.ok(snow.geometry.attributes.position.getY(0) < snowBefore);
assert.equal(effect.update(0.01, camera, 0, 0, 1, 600), false);
assert.equal(rain.visible || snow.visible, false);
} finally { effect.dispose(); }
});
test('particles follow camera teleports and stay above the sampled ground', () => {
const effect = createPrecipitation(() => 3);
const camera = new Vector3(0, 10, 0);
try {
effect.update(0, camera, 8, 1, 1, 600);
camera.set(1000, 20, -1000);
effect.update(0.01, camera, 8, 1, 1, 600);
for (const mesh of effect.group.children as any[]) {
const positions = mesh.geometry.attributes.position;
for (let i = 0; i < mesh.geometry.drawRange.count; i++) {
assert.ok(Math.abs(positions.getX(i) - camera.x) < 25);
assert.ok(Math.abs(positions.getZ(i) - camera.z) < 25);
assert.ok(positions.getY(i) >= 3);
}
}
} finally { effect.dispose(); }
});
+66
View File
@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createRainGearState } from '../src/controls/rainGear.ts';
import { createEmoteState } from '../src/controls/emotes.ts';
import { createSheep } from '../src/scene/sheep.ts';
test('rain equipment is independent of emotes, remains in snow, and clears in dry weather', () => {
const gear = createRainGearState();
const emote = createEmoteState();
assert.equal(gear.toggle('umbrella'), false);
gear.updateWeather(1, 0);
assert.equal(gear.toggle('hood'), false, 'hood requires the raincoat');
assert.ok(gear.toggle('umbrella'));
assert.ok(gear.toggle('raincoat'));
assert.ok(gear.toggle('hood'));
assert.ok(gear.hood);
emote.start('wave', false); emote.update(4, true, false);
assert.equal(emote.current, 'idle');
assert.ok(gear.umbrella && gear.raincoat);
emote.start('pray', true);
gear.updateWeather(0, .3);
assert.ok(gear.umbrella && gear.raincoat);
gear.toggle('umbrella');
assert.ok(!gear.umbrella && gear.raincoat);
gear.toggle('raincoat');
assert.equal(gear.hood, false);
gear.updateWeather(0, 0);
assert.ok(!gear.available && !gear.umbrella && !gear.raincoat);
});
async function asset(name: string) {
const bytes = await readFile(new URL(`../public/characters/${name}.glb`, import.meta.url));
return (await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '')).scene;
}
test('Blender gear follows the sheep joints and keeps the umbrella grip through walking, waving and prayer', async () => {
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
const sheep = createSheep(model, { umbrella, raincoat });
const right = model.getObjectByName('ArmRight')!;
const left = model.getObjectByName('ArmLeft')!;
const sleeve = model.getObjectByName('CoatArmRight')!;
const hood = model.getObjectByName('CoatHood')!;
const folded = model.getObjectByName('CoatFoldedHood')!;
assert.equal(hood.parent, model.getObjectByName('Head'));
assert.equal(sleeve.parent, right);
assert.equal(umbrella.visible, false);
sheep.setEquipment(true, true);
assert.ok(!hood.visible && folded.visible);
sheep.setEquipment(true, true, true);
assert.ok(hood.visible && !folded.visible);
sheep.pose('idle', 0, true, 1);
const grip = right.quaternion.clone();
const leftWalking = left.quaternion.clone();
sheep.pose('wave', .2, false, 1);
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
assert.ok(left.quaternion.angleTo(leftWalking) > .5);
sheep.pose('pray', .2, false, 1);
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
assert.ok(umbrella.visible && raincoat.visible && sleeve.visible);
sheep.setEquipment(false, false);
assert.ok(!umbrella.visible && !raincoat.visible && !sleeve.visible);
assert.equal(hood.visible, false);
sheep.dispose();
});
+18
View File
@@ -0,0 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frameSummary } from '../src/scene/frameSummary.ts';
test('frame summary uses nearest-rank p95 and retains long foreground stalls', () => {
const values = [...Array(94).fill(16), ...Array(5).fill(40), 500];
const result = frameSummary(values);
assert.equal(result.p95Ms, 40);
assert.equal(result.meanMs, 22.04);
assert.equal(result.over50Ms, 1);
assert.equal(result.overBudget, 6);
assert.ok(Math.abs(result.fps! - 1000 / 22.04) < 1e-10);
});
test('empty render sample cannot be reported as passing FPS', () => {
assert.equal(frameSummary([]).fps, null);
assert.equal(frameSummary([]).p95Ms, null);
});
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PerspectiveCamera } from 'three';
import { createRenderInvalidation } from '../src/scene/renderInvalidation.ts';
test('unchanged frames skip rendering but each live update requests a fresh frame', () => {
const render = createRenderInvalidation();
const camera = new PerspectiveCamera();
assert.equal(render.shouldRender(camera), true);
assert.equal(render.shouldRender(camera), false);
for (let tick = 0; tick < 3; tick++) {
render.invalidate();
assert.equal(render.shouldRender(camera), true);
assert.equal(render.shouldRender(camera), false);
}
});
test('movement, rotation, camera switches and ocean animation remain visible', () => {
const render = createRenderInvalidation();
const camera = new PerspectiveCamera();
render.shouldRender(camera);
camera.position.x = 1;
assert.equal(render.shouldRender(camera), true);
camera.rotation.y = 0.2;
assert.equal(render.shouldRender(camera), true);
assert.equal(render.shouldRender(camera.clone()), true);
assert.equal(render.shouldRender(camera, true), true);
assert.equal(render.shouldRender(camera, true), true);
});
+26
View File
@@ -0,0 +1,26 @@
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
import { observer } from '../src/config/observer.ts';
import { noaaPosition } from './noaa-reference.ts';
export const solarCases = [
'2026-03-20T06:40:00+09:00', '2026-03-20T12:00:00+09:00',
'2026-06-21T05:10:00+09:00', '2026-06-21T12:00:00+09:00',
'2026-09-23T06:20:00+09:00', '2026-09-23T18:00:00+09:00',
'2026-10-29T06:50:00+09:00', '2026-10-29T12:00:00+09:00',
'2026-12-21T07:45:00+09:00', '2026-12-21T12:00:00+09:00',
'2026-12-31T23:59:00+09:00', '2027-01-01T00:00:00+09:00',
];
// Independent implementations differ in atmospheric refraction and ephemeris details.
export const ANGLE_TOLERANCE = 0.1;
// Below 0°, SunCalc clamps its refraction input to 0° (~0.484° correction),
// whereas NOAA continues its piecewise formula. Below-zero positions are
// compared with a separate 0.5° model tolerance; terrain mode can expose some.
export const BELOW_HORIZON_TOLERANCE = 0.5;
export function compareSolar(iso: string) {
const date = new Date(iso);
const actual = calculateSun(date, observer);
const reference = noaaPosition(date, observer.latitude, observer.longitude);
const azimuthError = Math.abs(((actual.azimuth - reference.azimuth + 540) % 360) - 180);
const altitudeTolerance = actual.altitude < 0 ? BELOW_HORIZON_TOLERANCE : ANGLE_TOLERANCE;
return { iso, actual, reference, azimuthError, altitudeTolerance, altitudeError: Math.abs(actual.altitude - reference.altitude) };
}
+14
View File
@@ -0,0 +1,14 @@
import { ANGLE_TOLERANCE, BELOW_HORIZON_TOLERANCE, compareSolar, solarCases } from './solar-cases.ts';
console.log(`# 태양 위치 검증 결과\n\n관측점 37.4450 N, 126.9640 E (임시). SunCalc 2.0.1과 독립 NOAA/Meeus 수식 구현 비교. 방위각과 0° 이상 고도각 허용 오차: ${ANGLE_TOLERANCE}°. 0° 아래 고도값은 굴절 모델 차이를 고려해 ${BELOW_HORIZON_TOLERANCE}°. 실측 검증이 아닌 계산 대조.\n`);
console.log('| KST | SunCalc 방위각 | NOAA 방위각 | 차이 | SunCalc 겉보기 고도 | NOAA 겉보기 고도 | 차이 |');
console.log('|---|---:|---:|---:|---:|---:|---:|');
let maxAz = 0, maxAlt = 0, maxVisibleAlt = 0, passed = true;
for (const iso of solarCases) {
const c = compareSolar(iso);
maxAz = Math.max(maxAz, c.azimuthError); maxAlt = Math.max(maxAlt, c.altitudeError);
if (c.actual.altitude >= 0) maxVisibleAlt = Math.max(maxVisibleAlt, c.altitudeError);
if (c.azimuthError > ANGLE_TOLERANCE || c.altitudeError > c.altitudeTolerance) passed = false;
console.log(`| ${iso.replace('T', ' ').replace('+09:00', '')} | ${c.actual.azimuth.toFixed(4)} | ${c.reference.azimuth.toFixed(4)} | ${c.azimuthError.toFixed(4)} | ${c.actual.altitude.toFixed(4)} | ${c.reference.altitude.toFixed(4)} | ${c.altitudeError.toFixed(4)} |`);
}
console.log(`\n최대 차이: 방위각 ${maxAz.toFixed(4)}°, 0° 이상 태양 고도 ${maxVisibleAlt.toFixed(4)}°, 지평선 아래 포함 고도 ${maxAlt.toFixed(4)}°.\n\nSunCalc는 기하학적 고도가 음수이면 굴절 수식의 입력을 0°로 고정한다(약 0.484° 보정). NOAA는 음의 고도에도 별도 수식을 적용한다. 따라서 처음 시도한 전 구간 0.1° 기준은 5개 지평선 아래 사례에서 실패했다. 이 차이를 숨기지 않고 0° 아래 구간의 허용 차이를 분리했다. 지형 모드에서는 음의 고도 태양도 일부 보일 수 있으므로 이 모델 차이도 관측 일출의 오차 요인이다.\n\n출처: [NOAA 계산 설명](https://gml.noaa.gov/grad/solcalc/calcdetails.html), [NOAA 계산 소스](https://gml.noaa.gov/grad/solcalc/main.js), [SunCalc 2.x](https://github.com/mourner/suncalc).\n\n재현: npm run verify:solar. 앱 계산에는 NOAA 검증 코드를 사용하지 않는다. 대기 굴절 모델 차이와 실제 기압·기온 변동, 지형 차폐는 이 비교로 검증되지 않는다.`);
if (!passed) process.exitCode = 1;
+11
View File
@@ -0,0 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ANGLE_TOLERANCE, compareSolar, solarCases } from './solar-cases.ts';
for (const iso of solarCases) {
test(`SunCalc 2.x vs NOAA: nonnegative altitude / azimuth 0.1°, negative altitude 0.5°: ${iso}`, () => {
const comparison = compareSolar(iso);
assert.ok(comparison.azimuthError <= ANGLE_TOLERANCE, `azimuth error ${comparison.azimuthError}°`);
assert.ok(comparison.altitudeError <= comparison.altitudeTolerance, `altitude error ${comparison.altitudeError}°`);
});
}
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Box3, Mesh, Raycaster, Vector3 } from 'three';
import { createStructures, structurePosition } from '../src/scene/structures.ts';
import { observer } from '../src/config/observer.ts';
import { readFile } from 'node:fs/promises';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STRUCTURES } from '../src/constants/structures.ts';
import { createTerrainGeometry, terrainGeometryHeightAt } from '../src/scene/terrainGeometry.ts';
async function assetModels() {
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id)];
return new Map(await Promise.all(ids.map(async id => {
const bytes = await readFile(new URL(`../public/models/summit/${id}.glb`, import.meta.url));
const gltf = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
return [id, gltf.scene] as const;
})));
}
test('structure coordinates use meters, east +X and north -Z around the existing observer', () => {
assert.equal(structurePosition(observer.latitude, observer.longitude, observer).length(), 0);
const north = structurePosition(observer.latitude + 0.001, observer.longitude, observer);
const east = structurePosition(observer.latitude, observer.longitude + 0.001, observer);
assert.ok(north.z < -110 && north.z > -112);
assert.ok(east.x > 88 && east.x < 90);
});
test('structures occlude rays through solid geometry, clear overhead rays, and have finite bounds', async () => {
const assets = await assetModels();
const structures = createStructures(observer, (x, z) => x * 0.02 + z * 0.03, assets);
assert.equal(structures.group.children.length, 6);
assert.equal(assets.has('north-deck'), false);
assert.equal(structures.group.getObjectByName('north-deck'), undefined);
for (const [id, model] of assets) assert.equal(model.parent?.name, id, 'Actual GLB is placed once');
structures.group.updateMatrixWorld(true);
const radar = structures.group.getObjectByName('weather-radar')!;
const bounds = new Box3().setFromObject(structures.group);
assert.ok([...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite));
const ray = new Raycaster(radar.position.clone().add(new Vector3(-20, 3, 0)), new Vector3(1, 0, 0), 0, 40);
assert.ok(ray.intersectObject(radar, true).length > 0);
ray.ray.origin.y += 100;
assert.equal(ray.intersectObject(radar, true).length, 0);
structures.group.traverse(object => {
if (object instanceof Mesh) {
assert.equal(object.material.depthTest, true);
assert.equal(object.material.depthWrite, true);
}
});
structures.dispose();
});
test('rotated deck supports join the GLB anchors to sloped ground and resources are disposed', async () => {
const assets = await assetModels();
const groundAt = (x: number, z: number) => x * .2 - z * .12;
const structures = createStructures(observer, groundAt, assets);
structures.group.updateMatrixWorld(true);
for (const spec of STRUCTURES.decks) {
const deck = structures.group.getObjectByName(spec.id)!;
assert.ok(Math.abs(deck.rotation.y - spec.heading * Math.PI / 180) < 1e-8);
const modelRoot = assets.get(spec.id)!.getObjectByName(spec.id)!;
const anchors = JSON.parse(modelRoot.userData.terrainSupportAnchorsJson) as { position: [number, number, number] }[];
const supports = deck.getObjectByName('terrain-supports')!;
assert.equal(supports.children.length, anchors.length);
supports.children.forEach((support, index) => {
const bounds = new Box3().setFromObject(support);
const anchor = deck.localToWorld(new Vector3(...anchors[index].position));
assert.ok(bounds.max.y >= anchor.y && bounds.max.y < anchor.y + .05);
assert.ok(bounds.min.y < groundAt(anchor.x, anchor.z));
});
}
let disposed = 0;
const geometry = (assets.get('weather-radar')!.getObjectByProperty('isMesh', true) as Mesh).geometry;
geometry.addEventListener('dispose', () => disposed++);
structures.dispose();
assert.equal(disposed, 1);
});
test('real north deck has a continuous entrance and exit reaching the rendered ground', { skip: !STRUCTURES.decks.some(s => s.id === 'north-deck') }, async () => {
const metadata = JSON.parse(await readFile(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const bytes = await readFile(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
const terrain = createTerrainGeometry(metadata, heights, true);
const ground = (x: number, z: number) => terrainGeometryHeightAt(terrain, x, z);
const structures = createStructures(metadata.origin, ground, await assetModels());
structures.group.updateMatrixWorld(true);
for (const spec of STRUCTURES.decks.filter(s => s.id === 'north-deck')) {
const deck = structures.group.getObjectByName(spec.id)!;
for (const name of ['entry-access', 'exit-access']) {
const access = deck.getObjectByName(name);
assert.ok(access, `${spec.id}: ${name} must join the open end to terrain`);
const { startZ, endZ, topY } = access.userData;
let previous = topY + deck.position.y;
const samples = Math.ceil(Math.abs(endZ - startZ) / .05);
for (let i = 1; i <= samples; i++) {
const p = deck.localToWorld(new Vector3(0, 0, startZ + (endZ - startZ) * Math.min(i / samples, 1 - 1e-6)));
const hits = new Raycaster(new Vector3(p.x, 10000, p.z), new Vector3(0, -1, 0)).intersectObject(access, true);
assert.ok(hits.length, `${spec.id} ${name} sample ${i}: no hole in the access surface`);
const surface = Math.max(hits[0].point.y, ground(p.x, p.z));
assert.ok(Math.abs(surface - previous) < .25, `${spec.id} ${name} sample ${i}: step ${surface - previous}`);
previous = surface;
if (i === samples) assert.ok(Math.abs(surface - ground(p.x, p.z)) < .05, 'Landing meets terrain');
}
}
}
structures.dispose();
});
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BufferGeometry, Float32BufferAttribute } from 'three';
import { encodeSummitGeometry, decodeSummitGeometry, loadSummitAssets } from '../src/scene/summitAssets.ts';
import { gzipSync } from 'node:zlib';
import { buildSummitGeometry,stitchSummitGeometry } from '../src/scene/summitGeometry.ts';
import { createSummitWalking } from '../src/controls/summitWalking.ts';
import { createTerrainGeometry,terrainGeometryHeightAt } from '../src/scene/terrainGeometry.ts';
import { applySummitTerrain } from '../src/scene/summitTerrain.ts';
import { readFileSync } from 'node:fs';
import { SUMMIT_ASSETS } from '../src/constants/summitAssets.ts';
import { SUMMIT_MATERIALS } from '../src/constants/summitMaterials.ts';
test('baked summit shares vertices and preserves every triangle attribute exactly',()=>{
const geometry=new BufferGeometry();
const order=[0,1,2,2,1,3];
for(const [name,size] of [['position',3],['normal',3],['uv',2],['color',3],['trailDistance',1],['rockDistance',1]] as const) {
geometry.setAttribute(name,new Float32BufferAttribute(order.flatMap(i=>Array.from({length:size},(_,j)=>i*.123+j*.35)),size));
}
const bytes=encodeSummitGeometry(geometry),restored=decodeSummitGeometry(bytes);
assert.equal(restored.getAttribute('position').count,4);
assert.equal(restored.getIndex()!.count,6);
for(const name of Object.keys(geometry.attributes)) {
const a=geometry.getAttribute(name),b=restored.getAttribute(name);
for(let i=0;i<6;i++) for(let j=0;j<a.itemSize;j++)
assert.equal(b.array[restored.getIndex()!.getX(i)*a.itemSize+j],a.array[i*a.itemSize+j]);
}
assert.throws(()=>decodeSummitGeometry(bytes.slice(0,-4)));
geometry.dispose();restored.dispose();
});
test('summit loader accepts both browser-decoded gzip and raw gzip hosting',async()=>{
const geometry=new BufferGeometry();
for(const [name,size] of [['position',3],['normal',3],['uv',2],['color',3],['trailDistance',1],['rockDistance',1]] as const)
geometry.setAttribute(name,new Float32BufferAttribute(new Float32Array(size*3),size));
const bytes=encodeSummitGeometry(geometry),originalFetch=globalThis.fetch;
try {
for(const automatic of [true,false]) {
globalThis.fetch=async input=>String(input).includes('.json')
? Response.json({version:1,bufferHash:'test',byteLength:bytes.byteLength})
: new Response(automatic?bytes:gzipSync(new Uint8Array(bytes)),{headers:automatic?{'Content-Encoding':'gzip'}:{}});
const loaded=await loadSummitAssets();assert.equal(loaded.getIndex()!.count,3);loaded.dispose();
}
} finally {globalThis.fetch=originalFetch;geometry.dispose();}
});
test('adaptive interior triangles follow terrain and never shortcut material transition bands',()=>{
const meta=JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json',import.meta.url),'utf8'));
const raw=readFileSync(new URL('../public/terrain/gwanaksan.f32',import.meta.url));
const terrain=createTerrainGeometry(meta,new Float32Array(raw.buffer,raw.byteOffset,raw.byteLength/4),true);
applySummitTerrain(terrain,meta);
const walking=createSummitWalking(meta.origin),mesh=buildSummitGeometry(walking,terrain,true);
const p=mesh.getAttribute('position');
let large=0;
for(let i=0;i<p.count;i+=3) {
const x=(p.getX(i)+p.getX(i+1)+p.getX(i+2))/3,z=(p.getZ(i)+p.getZ(i+1)+p.getZ(i+2))/3;
const y=(p.getY(i)+p.getY(i+1)+p.getY(i+2))/3;
assert.ok(Math.abs(y-terrainGeometryHeightAt(terrain,x,z)-.06)<.0001,'surface stays on source triangle');
const edge=Math.max(...[0,1,2].map(j=>Math.hypot(p.getX(i+j)-p.getX(i+(j+1)%3),p.getZ(i+j)-p.getZ(i+(j+1)%3))));
if(edge<=1.4)continue;
large++;
assert.ok(edge<=SUMMIT_ASSETS.interiorMaxEdge+.0001);
const r=SUMMIT_MATERIALS.rockBlend,t=SUMMIT_MATERIALS.edgeBlend;
for(let j=0;j<3;j++) {
const rock=walking.rockDistance(p.getX(i+j),p.getZ(i+j)),path=walking.pathDistance(p.getX(i+j),p.getZ(i+j));
assert.ok(rock<r.innerMeters-r.irregularityMeters || rock>r.outerMeters+r.irregularityMeters &&
(path<t.innerMeters-t.irregularityMeters||path>t.outerMeters+t.irregularityMeters));
}
}
assert.ok(large>1000);terrain.dispose();mesh.dispose();
});
test('coarse triangles reuse adjoining fine-edge vertices and attributes',()=>{
const g=new BufferGeometry();
const points=[[0,0,0],[4,0,0],[0,0,4],[0,0,0],[2,0,-1],[2,0,0],[2,0,0],[2,0,-1],[4,0,0]];
g.setAttribute('position',new Float32BufferAttribute(points.flat(),3));
g.setAttribute('color',new Float32BufferAttribute(points.flatMap(p=>p[0]===2&&p[2]===0?[.2,.3,.4]:[1,1,1]),3));
const stitched=stitchSummitGeometry(g),p=stitched.getAttribute('position'),color=stitched.getAttribute('color');
assert.equal(p.count,12);
let midpointUses=0;
for(let i=0;i<p.count;i++)if(p.getX(i)===2&&p.getZ(i)===0){midpointUses++;assert.equal(color.getX(i),Math.fround(.2));}
assert.equal(midpointUses,4);
g.dispose();stitched.dispose();
});
+69
View File
@@ -0,0 +1,69 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PerspectiveCamera, Vector3 } from 'three';
import { createFpsCameraController } from '../src/controls/fpsCameraController.ts';
import { directionFromAngles } from '../src/astronomy/direction.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, picks: number[][]) => void) {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const oldDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const doc = Object.assign(new EventTarget(), { querySelector: () => ({ textContent: '' }) });
const canvas = Object.assign(new EventTarget(), { ownerDocument: doc, focus() {}, blur() {}, tabIndex: 0,
setPointerCapture() {}, releasePointerCapture() {}, hasPointerCapture() { return true; } });
Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true });
Object.defineProperty(globalThis, 'document', { value: doc, configurable: true });
const camera = new PerspectiveCamera();
const picks: number[][] = [];
const controller = createFpsCameraController(camera, canvas as unknown as HTMLCanvasElement,
(x, y) => { picks.push([x, y]); return new Vector3(x, 0, y); });
try { run(camera, canvas, picks); }
finally {
controller.dispose();
for (const [key, value] of [['window', oldWindow], ['document', oldDocument]] as const) {
if (value) Object.defineProperty(globalThis, key, value);
else Reflect.deleteProperty(globalThis, key);
}
}
}
function pointer(canvas: EventTarget, type: string, x: number, y: number, pointerType = 'touch', button = 0) {
canvas.dispatchEvent(Object.assign(new Event(type, { cancelable: true }),
{ pointerId: 1, isPrimary: true, pointerType, button, clientX: x, clientY: y }));
}
test('touch tap and mouse right click select a destination, left click does not', () => {
withController((_camera, canvas, picks) => {
for (const [type, button] of [['touch', 0], ['mouse', 2], ['mouse', 0]] as const) {
pointer(canvas, 'pointerdown', 100, 120, type, button);
pointer(canvas, 'pointerup', 100, 120, type, button);
}
assert.deepEqual(picks, [[100, 120], [100, 120]]);
});
});
test('drag changes the view without selecting a destination, including when returning to its start', () => {
withController((camera, canvas, picks) => {
pointer(canvas, 'pointerdown', 100, 100);
pointer(canvas, 'pointermove', 130, 110);
assert.notEqual(camera.rotation.y, 0);
pointer(canvas, 'pointerup', 100, 100);
assert.deepEqual(picks, []);
pointer(canvas, 'pointerdown', 100, 100);
pointer(canvas, 'pointercancel', 100, 100);
pointer(canvas, 'pointerup', 100, 100);
assert.deepEqual(picks, []);
});
});
test('first horizontal drag after a southward reset preserves camera pitch', () => {
withController((camera, canvas) => {
camera.lookAt(new Vector3(...directionFromAngles(SUMMIT_TERRAIN.heading, SUMMIT_TERRAIN.pitch)));
const before = camera.getWorldDirection(new Vector3());
pointer(canvas, 'pointerdown', 100, 100, 'mouse');
pointer(canvas, 'pointermove', 110, 100, 'mouse');
const after = camera.getWorldDirection(new Vector3());
assert.ok(Math.abs(after.y - before.y) < 1e-8);
assert.ok(before.angleTo(after) > 0);
assert.ok(before.angleTo(after) < 0.04);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { createTerrainGeometry, terrainGeometryHeightAt } from '../src/scene/terrainGeometry.ts';
import { applySummitTerrain } from '../src/scene/summitTerrain.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
import { structurePosition } from '../src/scene/structures.ts';
import { STRUCTURES } from '../src/constants/structures.ts';
const meta = JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const bytes = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, meta.origin);
test('local reconstruction preserves source data and every vertex outside 200m', () => {
const geometry = createTerrainGeometry(meta, heights, true);
const p = geometry.getAttribute('position');
const before = p.array.slice();
const source = heights.slice();
applySummitTerrain(geometry, meta);
let changed = 0;
for (let i=0; i<p.count; i++) {
const distance = Math.hypot(p.getX(i)-start.x, p.getZ(i)-start.z);
if (distance >= 200) assert.equal(p.getY(i), before[i*3+1]);
if (p.getY(i) !== before[i*3+1]) changed++;
}
assert.ok(changed > 100);
assert.deepEqual(heights, source);
geometry.dispose();
});
test('radar building and dome clear the saddle from the estimated eye position', () => {
const geometry = createTerrainGeometry(meta, heights, true);
applySummitTerrain(geometry, meta);
const ground = terrainGeometryHeightAt(geometry,start.x,start.z);
assert.ok(Math.abs(ground + meta.observerSurfaceElevation - 620) < 1);
const radar = structurePosition(STRUCTURES.radar.latitude,STRUCTURES.radar.longitude,meta.origin);
radar.y = terrainGeometryHeightAt(geometry,radar.x,radar.z) + 7;
const eye = new Vector3(start.x,ground+1.7,start.z);
assert.ok(radar.y > eye.y + 8, 'radar must stand above observer on its rock');
const material = new MeshBasicMaterial();
const mesh = new Mesh(geometry,material);
const ray = new Raycaster(eye,radar.clone().sub(eye).normalize(),0,eye.distanceTo(radar));
assert.equal(ray.intersectObject(mesh).length,0,'ridge must not hide radar building');
for (const [x,z] of [[start.x,start.z],[radar.x,radar.z],[0,65],[start.x+199,start.z],
[start.x-12,start.z-22],[start.x-2,start.z-45],[start.x+5,start.z-35],
[start.x-9,start.z-12],[start.x-9,start.z-11],[start.x-9,start.z-13]]) {
const hit = new Raycaster(new Vector3(x,1000,z),new Vector3(0,-1,0)).intersectObject(mesh)[0];
assert.ok(Math.abs(hit.point.y-terrainGeometryHeightAt(geometry,x,z))<1e-4);
}
const joint = terrainGeometryHeightAt(geometry,start.x-9,start.z-12);
const shoulders = (terrainGeometryHeightAt(geometry,start.x-9,start.z-11)
+ terrainGeometryHeightAt(geometry,start.x-9,start.z-13))/2;
assert.ok(shoulders-joint>.15,'large fracture must be a physical depression, not only a painted line');
geometry.dispose(); material.dispose();
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSummitWalking } from '../src/controls/summitWalking.ts';
import { structurePosition } from '../src/scene/structures.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
const origin = { latitude: 37.445, longitude: 126.964 };
const walking = createSummitWalking(origin);
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, origin);
test('estimated start connects to the mapped public summit route', () => {
const junction = structurePosition(37.4447614,126.9641235,origin);
assert.ok(walking.canTraverse(start,junction));
for (const route of walking.routes) for (let i=1;i<route.points.length;i++) {
const a=route.points[i-1], b=route.points[i];
if (walking.inside(a.x,a.z) && walking.inside(b.x,b.z)) assert.ok(walking.canTraverse(a,b),route.id);
}
});
test('walking cannot cross forest, radar facility or the 200m boundary', () => {
const radar = structurePosition(37.444028,126.963808,origin);
assert.equal(walking.walkable(radar.x,radar.z),false);
assert.equal(walking.walkable(start.x+201,start.z),false);
assert.equal(walking.canTraverse(start,{x:start.x+80,z:start.z}),false);
const north=structurePosition(37.4449609,126.9641628,origin);
const east=structurePosition(37.4448264,126.9646956,origin);
assert.equal(walking.canTraverse(north,east),false,'cannot cut the corner between valid endpoints');
});
test('summit slab allows lateral movement beside the peak and connects to the lower forecourt', () => {
// OSM peak is north of the estimated camera; photos show a broad slab, not just a trail.
const peak = structurePosition(37.4451398,126.9642379,origin);
const eastSlab = { x: peak.x+7, z: peak.z+4 };
const westSlab = { x: peak.x-12, z: peak.z+4 };
assert.ok(walking.canTraverse(westSlab,eastSlab),'cross the upper slab beside the summit marker');
assert.ok(walking.canTraverse(start,eastSlab),'lower forecourt joins the summit slab');
const mast = structurePosition(37.4450675,126.9638043,origin);
assert.equal(walking.walkable(mast.x,mast.z),false,'mast compound remains excluded');
assert.equal(walking.walkable(peak.x+30,peak.z),false,'eastern cliff is not part of the slab');
});
test('visible summit slab stops at its walking edge and the marker footprint is blocked', () => {
const outside={x:start.x-32,z:start.z-34};
assert.equal(walking.onPlatform(outside.x,outside.z),false);
assert.ok(walking.rockDistance(outside.x,outside.z)>0,'no circular rock patch beyond the slab boundary');
assert.equal(walking.walkable(start.x-12,start.z-22),false,'cannot walk through the summit stone');
assert.ok(walking.walkable(start.x-15,start.z-22),'can walk beside the stone');
});
+97
View File
@@ -0,0 +1,97 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { observer } from '../src/config/observer.ts';
import { createTerrainGeometry, terrainGeometryHeightAt, terrainHeightAt, validateTerrain } from '../src/scene/terrainGeometry.ts';
import type { TerrainMetadata } from '../src/scene/terrainGeometry.ts';
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
import { directionFromAngles } from '../src/astronomy/direction.ts';
import { SCENE } from '../src/constants/scene.ts';
const metadata: TerrainMetadata = JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const bytes = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
test('near terrain refinement preserves samples and bounds interpolated elevations', () => {
const g = createTerrainGeometry(metadata, heights, true);
const p = g.getAttribute('position');
assert.ok(p.count > heights.length, 'nearby terrain needs intermediate vertices');
let foundOrigin = false;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
if (x === 0 && z === 0) { foundOrigin = true; assert.ok(Math.abs(p.getY(i)) < 1e-5); }
if (Math.abs(x) > 100 || Math.abs(z) > 100) continue;
const col = Math.min(metadata.width - 2, Math.floor((x + metadata.radiusMeters) / metadata.spacingMeters));
const row = Math.min(metadata.height - 2, Math.floor((z + metadata.radiusMeters) / metadata.spacingMeters));
const k = row * metadata.width + col;
const corners = [heights[k], heights[k+1], heights[k+metadata.width], heights[k+metadata.width+1]];
const h = p.getY(i) + metadata.observerSurfaceElevation + (x*x+z*z)/(2*6371008.8);
assert.ok(h >= Math.min(...corners)-0.001 && h <= Math.max(...corners)+0.001, 'no invented peaks or pits');
}
assert.ok(foundOrigin);
const mesh = new Mesh(g, new MeshBasicMaterial());
for (const [x,z] of [[2,3],[-17.5,28.2],[179.5,180.5],[210.4,-302.8]]) {
const ray = new Raycaster(new Vector3(x,1000,z),new Vector3(0,-1,0));
const hit = ray.intersectObject(mesh)[0];
assert.ok(hit);
assert.ok(Math.abs(terrainGeometryHeightAt(g,x,z)-hit.point.y)<1e-4, 'walking follows the actual refined triangle');
}
mesh.material.dispose();
g.dispose();
});
test('downloaded grid covers both longitude tiles, no holes, and observer surface is consistent', () => {
validateTerrain(metadata, heights, observer);
assert.equal(metadata.width, 801);
assert.equal(heights.length, 801 * 801);
assert.equal(metadata.sources.length, 2);
assert.ok(metadata.geographicCorners.some(([lon]) => lon < 127));
assert.ok(metadata.geographicCorners.some(([lon]) => lon > 127));
assert.equal(terrainHeightAt(metadata, heights, 0, 0), metadata.observerSurfaceElevation);
assert.ok(metadata.observerSurfaceElevation > 500 && metadata.observerSurfaceElevation < 650);
assert.throws(() => validateTerrain(metadata, heights.subarray(1), observer));
assert.throws(() => validateTerrain(metadata, heights, { ...observer, longitude: 127 }));
});
test('mesh keeps east +X, north -Z, true scale, and the camera above its center', () => {
const geometry = createTerrainGeometry(metadata, heights);
const positions = geometry.getAttribute('position');
assert.equal(positions.getX(0), -12000);
assert.equal(positions.getZ(0), -12000);
assert.equal(positions.getX(800), 12000);
assert.equal(positions.getZ(801 * 800), 12000);
assert.equal(positions.getY(400 * 801 + 400), 0);
const expectedCorner = heights[0] - metadata.observerSurfaceElevation - (12000 ** 2 * 2) / (2 * 6371008.8);
assert.ok(Math.abs(positions.getY(0) - expectedCorner) < 0.001);
const mesh = new Mesh(geometry, new MeshBasicMaterial());
const hits = new Raycaster(new Vector3(0, observer.eyeHeight, 0), new Vector3(0, -1, 0)).intersectObject(mesh);
assert.ok(hits.length > 0, 'triangles must face upward and be visible to a downward ray');
assert.ok(Math.abs(hits[0].distance - observer.eyeHeight) < 0.001);
geometry.dispose();
});
test('a synthetic ridge blocks a low sun direction and allows a higher one', () => {
const grid = { ...metadata, width: 3, height: 3, spacingMeters: 30, radiusMeters: 30, observerSurfaceElevation: 0 };
const data = new Float32Array([0, 0, 12, 0, 0, 12, 0, 0, 12]);
assert.equal(terrainHeightAt(grid, data, 15, 0), 6);
const geometry = createTerrainGeometry(grid, data);
const mesh = new Mesh(geometry, new MeshBasicMaterial());
const origin = new Vector3(0, 1.7, 0);
assert.ok(new Raycaster(origin, new Vector3(1, 0.1, 0).normalize()).intersectObject(mesh).length > 0);
assert.equal(new Raycaster(origin, new Vector3(1, 1, 0).normalize()).intersectObject(mesh).length, 0);
geometry.dispose();
});
test('real Gwanaksan ridge blocks the March 06:40 sun center but clears it by 06:50', () => {
const geometry = createTerrainGeometry(metadata, heights, true);
const mesh = new Mesh(geometry, new MeshBasicMaterial());
for (const [time, blocked] of [['06:40', true], ['06:50', false]] as const) {
const sun = calculateSun(new Date(`2026-03-20T${time}:00+09:00`), observer);
const direction = new Vector3(...directionFromAngles(sun.azimuth, sun.altitude));
const hits = new Raycaster(new Vector3(0, observer.eyeHeight, 0), direction, 0, SCENE.sunDistance).intersectObject(mesh);
assert.equal(hits.length > 0, blocked, time);
}
assert.ok(SCENE.sunDistance > Math.SQRT2 * metadata.radiusMeters);
geometry.dispose();
});
+107
View File
@@ -0,0 +1,107 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TimeController } from '../src/ui/timeController.ts';
class Field extends EventTarget {
value = '';
max = '';
step = '';
disabled = false;
validity = { valid: true };
attributes = new Map<string, string>();
textContent = '';
setAttribute(name: string, value: string) { this.attributes.set(name, value); }
matches() { return this.disabled; }
closest() { return this; }
}
test('live ticks preserve date edits; custom freezes; live and visibility resync wall clock', (t) => {
const fields = new Map(['#date', '#date-display', '#time-slider', '#time-output', '#live-button'].map(id => [id, new Field()]));
let dialogOpen = false;
const doc = Object.assign(new EventTarget(), {
activeElement: null as Field | null,
querySelector: (id: string) => id === 'dialog[open]' ? (dialogOpen ? {} : null) : fields.get(id),
});
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'document', { configurable: true, value: doc });
Object.defineProperty(globalThis, 'window', { configurable: true, value: new EventTarget() });
t.mock.timers.enable({ apis: ['Date', 'setInterval'], now: Date.parse('2026-10-29T00:00:00Z') });
const calls: { date: Date; mode: string }[] = [];
const controller = new TimeController((date, mode) => calls.push({ date, mode }));
assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'true');
assert.equal(fields.get('#date-display')!.textContent, '2026년 10월 29일');
const arrow = (key: string) => {
const event = Object.assign(new Event('keydown', { cancelable: true }), { key });
window.dispatchEvent(event);
return event;
};
assert.equal(arrow('ArrowRight').defaultPrevented, true);
assert.equal(fields.get('#time-output')!.value, '09:00:10');
assert.equal(controller.mode, 'CUSTOM');
assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'false');
arrow('ArrowLeft');
assert.equal(fields.get('#time-output')!.value, '09:00:00');
dialogOpen = true;
assert.equal(arrow('ArrowRight').defaultPrevented, false);
assert.equal(fields.get('#time-output')!.value, '09:00:00');
dialogOpen = false;
doc.activeElement = fields.get('#time-slider')!;
assert.equal(arrow('ArrowRight').defaultPrevented, false, 'focused slider keeps its native keyboard handling');
doc.activeElement = null;
fields.get('#live-button')!.dispatchEvent(new Event('click'));
t.after(() => {
controller.dispose();
for (const [key, descriptor] of [['document', originalDocument], ['window', originalWindow]] as const) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const date = fields.get('#date')!;
doc.activeElement = date;
assert.equal(arrow('ArrowRight').defaultPrevented, false, 'date editing keeps its native keyboard handling');
date.value = '';
t.mock.timers.tick(1000);
assert.equal(date.value, '', 'live refresh must not overwrite an unfinished date edit');
date.value = '2026-12-21';
fields.get('#time-slider')!.value = '28810';
date.dispatchEvent(new Event('change'));
assert.equal(calls.at(-1)!.mode, 'CUSTOM');
assert.equal(calls.at(-1)!.date.toISOString(), '2026-12-20T23:00:10.000Z');
assert.equal(fields.get('#time-output')!.value, '08:00:10');
assert.equal(fields.get('#date-display')!.textContent, '2026년 12월 21일');
assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'false');
assert.equal(fields.get('#time-slider')!.step, '10');
fields.get('#time-slider')!.value = '86390';
fields.get('#time-slider')!.dispatchEvent(new Event('input'));
assert.equal(calls.at(-1)!.date.toISOString(), '2026-12-21T14:59:50.000Z');
assert.equal(fields.get('#time-output')!.value, '23:59:50');
assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'false');
const count = calls.length;
t.mock.timers.tick(3000);
doc.dispatchEvent(new Event('visibilitychange'));
assert.equal(calls.length, count);
doc.activeElement = null;
fields.get('#live-button')!.dispatchEvent(new Event('click'));
assert.equal(calls.at(-1)!.mode, 'LIVE');
assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'true');
assert.equal(calls.at(-1)!.date.getTime(), Date.now());
t.mock.timers.setTime(Date.parse('2026-10-30T00:00:00Z'));
doc.dispatchEvent(new Event('visibilitychange'));
assert.equal(date.value, '2026-10-30');
const serverDate = new Date('2026-11-01T21:00:00Z');
controller.setSharedClock(() => serverDate);
assert.equal(fields.get('#date')!.disabled, true);
assert.equal(calls.at(-1)!.date.getTime(), +serverDate);
doc.activeElement = null;
assert.equal(arrow('ArrowRight').defaultPrevented, false);
assert.equal(calls.at(-1)!.date.getTime(), +serverDate, 'arrows cannot change shared time');
date.value = '2027-01-01';
date.dispatchEvent(new Event('change'));
assert.equal(controller.mode, 'LIVE', 'programmatic events cannot change shared time');
controller.setSharedClock(null);
assert.equal(fields.get('#date')!.disabled, false);
date.value = '2027-01-01';
date.dispatchEvent(new Event('change'));
assert.equal(controller.mode, 'CUSTOM');
});
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MemoryVisitStore, type VisitRecord } from '../server/visits.ts';
test('closed visits cannot be resurrected by heartbeat; unfinished departure remains unknown', async () => {
const store = new MemoryVisitStore();
const record: VisitRecord = { _id: 'session', visitorId: 'anonymous', nickname: 'Visitor', locationId: 'gwanaksan', roomId: 'gwanaksan-001', joinedAt: new Date(1), lastSeenAt: new Date(1), leftAt: null, endReason: null, serverInstanceId: 'old' };
await store.insert(record); await store.heartbeat(['session'], new Date(2));
await store.finish('session', new Date(3), 'disconnect');
await store.heartbeat(['session'], new Date(4));
assert.equal(+store.records.get('session')!.lastSeenAt, 3);
await store.insert({ ...record, _id: 'stale' });
assert.equal(store.records.get('stale')!.endReason, null);
assert.equal(store.records.get('stale')!.leftAt, null);
assert.equal(+store.records.get('stale')!.lastSeenAt, 1);
});
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { walkStep, walkingCoordinates, walkingHeight } from '../src/controls/walking.ts';
import { createTerrainGeometry } from '../src/scene/terrainGeometry.ts';
import { structurePosition } from '../src/scene/structures.ts';
test('walking approaches a destination horizontally and stops without overshooting', () => {
const origin=new Vector3(0,2,0), target=new Vector3(3,100,4);
const next=walkStep(origin,target,0.05,100);
assert.ok(Math.abs(next.x-0.12)<1e-8);
assert.ok(Math.abs(next.z-0.16)<1e-8);
assert.equal(next.y,2);
assert.deepEqual(walkStep(origin,new Vector3(0.05,100,0),0.05,100),new Vector3(0.05,2,0));
assert.deepEqual(walkStep(origin,origin,0.05,100),origin);
});
test('walking caps stalled frames and stays inside terrain coverage',()=>{
const result=walkStep(new Vector3(9.9,0,0),new Vector3(20,0,0),30,10);
assert.equal(result.x,10);
assert.equal(walkStep(new Vector3(),new Vector3(10,0,0),30,10).x,0.2);
});
test('walking coordinate conversion is inverse to structure placement',()=>{
const origin={latitude:37.445,longitude:126.964};
const geo=walkingCoordinates(500,-300,origin);
const local=structurePosition(geo.latitude,geo.longitude,origin);
assert.ok(Math.abs(local.x-500)<1e-6 && Math.abs(local.z+300)<1e-6);
});
test('walking feet match actual DSM triangles including curvature',()=>{
const meta=JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json',import.meta.url),'utf8'));
const b=readFileSync(new URL('../public/terrain/gwanaksan.f32',import.meta.url));
const heights=new Float32Array(b.buffer,b.byteOffset,b.byteLength/4);
const geometry=createTerrainGeometry(meta,heights), material=new MeshBasicMaterial();
const mesh=new Mesh(geometry,material); mesh.updateMatrixWorld(true);
for(const [x,z] of [[0,0],[8,5],[21,23],[180,-213],[2000,1850]]){
const ray=new Raycaster(new Vector3(x,10000,z),new Vector3(0,-1,0));
const hit=ray.intersectObject(mesh)[0];
assert.ok(hit);
assert.ok(Math.abs(hit.point.y-walkingHeight(meta,heights,x,z))<0.001);
}
geometry.dispose();material.dispose();
});
+153
View File
@@ -0,0 +1,153 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { weatherRequest, parseWeather, sampleWeather, weatherAppearance, WeatherController } from '../src/weather/weather.ts';
import { createSun } from '../src/scene/sun.ts';
import { Vector3 } from 'three';
import { WEATHER } from '../src/constants/weather.ts';
import { createSky } from '../src/scene/sky.ts';
const now = new Date('2026-10-30T03:00:00Z');
const point = { latitude: 37.445, longitude: 126.964 };
const start = Date.parse('2026-10-29T21:00:00Z') / 1000;
function payload(cloud = [20, 80], visibility: (number | null)[] = [20000, 4000]) {
return { hourly: { time: [start, start + 3600], cloud_cover: cloud, visibility } };
}
test('routes forecast, past forecast and archive with precipitation; rejects unsupported dates', () => {
const live = weatherRequest(now, point, now)!;
assert.equal(new URL(live.url).hostname, 'api.open-meteo.com');
assert.equal(new URL(live.url).searchParams.get('timeformat'), 'unixtime');
const past = weatherRequest(new Date('2026-03-20T00:00:00Z'), point, now)!;
assert.equal(new URL(past.url).hostname, 'historical-forecast-api.open-meteo.com');
const archive = weatherRequest(new Date('2000-01-01T00:00:00Z'), point, now)!;
assert.equal(new URL(archive.url).hostname, 'archive-api.open-meteo.com');
for (const request of [live, past, archive]) {
const variables = new URL(request.url).searchParams.get('hourly')!.split(',');
assert.ok(variables.includes('rain') && variables.includes('snowfall'));
assert.equal(variables.includes('showers'), request !== archive);
}
assert.equal(weatherRequest(new Date('1939-01-01T00:00:00Z'), point, now), null);
assert.equal(weatherRequest(new Date('2099-01-01T00:00:00Z'), point, now), null);
});
test('KST 06:30 samples the correct UTC hour and interpolates smoothly', () => {
const series = parseWeather(payload());
assert.deepEqual(sampleWeather(series, new Date('2026-10-30T06:30:00+09:00')), {
cloudCover: 50, visibility: 12000, rain: null, snowfall: null,
});
assert.equal(sampleWeather(series, new Date((start - 1) * 1000)), null);
assert.equal(sampleWeather(series, new Date((start + 7200) * 1000)), null);
});
test('rain includes showers, snow stays in cm, and hourly totals are not interpolated into false sleet', () => {
const rows = parseWeather({ hourly: { ...payload().hourly, rain: [2, 0], showers: [1, 0], snowfall: [0, 0.7] } });
const rain = sampleWeather(rows, new Date(start * 1000))!;
assert.equal(rain.rain, 3);
assert.equal(rain.snowfall, 0);
const snow = sampleWeather(rows, new Date((start + 1800) * 1000))!;
assert.equal(snow.rain, 0);
assert.equal(snow.snowfall, 0.7);
assert.equal(sampleWeather(rows, new Date((start + 3601) * 1000))!.snowfall, null);
});
test('archive rain needs no showers; missing or invalid precipitation remains unknown', () => {
const date = new Date(start * 1000);
const sample = (fields: object) => sampleWeather(parseWeather({ hourly: { ...payload().hourly, ...fields } }), date)!;
assert.equal(sample({ rain: [4, 0], snowfall: [0, 0] }).rain, 4);
assert.equal(sample({ rain: [4, 0], showers: [null, 0] }).rain, null);
assert.equal(sample({ rain: [-1, 0], snowfall: [null, 0] }).rain, null);
assert.equal(sample({ snowfall: [NaN, 0] }).snowfall, null);
});
test('missing visibility is not treated as zero fog; missing cloud is not clear weather', () => {
const date = new Date((start + 1800) * 1000);
assert.equal(sampleWeather(parseWeather(payload([20, 80], [null, null])), date)!.visibility, null);
assert.equal(sampleWeather(parseWeather({ hourly: { time: [start], cloud_cover: [null] } }), new Date(start * 1000)), null);
assert.throws(() => parseWeather({ error: true }), /weather/i);
});
test('overcast and poor visibility progressively reduce sun light and halo without moving the sun', () => {
const clear = weatherAppearance({ cloudCover: 0, visibility: 50000, rain: 0, snowfall: 0 });
const cloudy = weatherAppearance({ cloudCover: 100, visibility: 50000, rain: 0, snowfall: 0 });
const foggy = weatherAppearance({ cloudCover: 100, visibility: 500, rain: 0, snowfall: 0 });
assert.ok(clear.transmission > cloudy.transmission && cloudy.transmission > foggy.transmission);
assert.ok(cloudy.transmission < 0.1);
assert.ok(foggy.fogFar < clear.fogFar);
const sun = createSun(new Vector3());
const direction = new Vector3(1, 0.1, 0).normalize();
sun.update(direction, 10, clear.transmission);
const position = sun.group.position.clone(), intensity = sun.light.intensity;
sun.update(direction, 10, cloudy.transmission);
assert.ok(sun.light.intensity < intensity * 0.1);
assert.deepEqual(sun.group.position, position);
for (const mesh of sun.group.children as any[]) assert.equal(mesh.material.uniforms.transmission.value, cloudy.transmission);
});
test('sky and sun receive the same coverage so clouds can occlude the disc and halo', () => {
const sky = createSky(), sun = createSun(new Vector3());
const direction = new Vector3(1, 0.02, 0).normalize();
for (const coverage of [0, 0.5, 1]) {
sky.update(direction, 1, coverage);
sun.update(direction, 1, 0.5, coverage);
assert.equal(sky.mesh.material.uniforms.cloudCoverage.value, coverage);
for (const mesh of sun.group.children as any[]) {
assert.equal(mesh.material.uniforms.cloudCoverage.value, coverage);
}
}
sky.mesh.geometry.dispose(); sky.mesh.material.dispose();
for (const mesh of sun.group.children as any[]) { mesh.geometry.dispose(); mesh.material.dispose(); }
});
test('same-day scrubbing reuses in-flight data and obsolete responses cannot replace the selected day', async () => {
const pending: ((response: Response) => void)[] = [];
const fetcher = (() => new Promise<Response>(resolve => pending.push(resolve))) as typeof fetch;
const states: any[] = [];
const controller = new WeatherController(point, state => states.push(state), fetcher);
controller.update(new Date(start * 1000));
controller.update(new Date((start + 1800) * 1000));
assert.equal(pending.length, 1);
controller.update(new Date((start - 86400) * 1000));
assert.equal(pending.length, 2);
pending[1](Response.json({ hourly: { time: [start - 86400, start - 82800], cloud_cover: [90, 90], visibility: [1000, 1000] } }));
await new Promise(resolve => setImmediate(resolve));
assert.equal(states.at(-1).weather.cloudCover, 90);
pending[0](Response.json(payload()));
await new Promise(resolve => setImmediate(resolve));
assert.equal(states.at(-1).weather.cloudCover, 90);
controller.update(new Date((start + 1800) * 1000));
assert.equal(pending.length, 2);
assert.equal(states.at(-1).weather.cloudCover, 50);
controller.dispose();
});
test('a paused custom selection retries after failure and stops after disposal', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: now.getTime() });
let calls = 0;
const fetcher = (async () => {
calls++;
return calls === 1 ? new Response('', { status: 503 }) : Response.json(payload());
}) as typeof fetch;
const states: any[] = [];
const controller = new WeatherController(point, state => states.push(state), fetcher);
t.after(() => controller.dispose());
controller.update(new Date(start * 1000));
await new Promise(resolve => setImmediate(resolve));
assert.equal(states.at(-1).status, 'error');
t.mock.timers.tick(WEATHER.retryMs);
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls, 2);
assert.equal(states.at(-1).status, 'ready');
controller.dispose();
t.mock.timers.tick(WEATHER.refreshMs * 2);
assert.equal(calls, 2);
});
test('browser fetch is called with the global receiver, not the controller', async () => {
let receiver: unknown;
const fetcher = (function (this: unknown) { receiver = this; return Promise.resolve(Response.json(payload())); }) as typeof fetch;
const controller = new WeatherController(point, () => {}, fetcher);
controller.update(new Date(start * 1000));
await new Promise(resolve => setImmediate(resolve));
controller.dispose();
assert.equal(receiver, globalThis);
});