import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Raycaster, Vector3 } 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('offscreen players skip posing but keep movement current and reappear when the camera turns', () => { const scene = new Group(), camera = new PerspectiveCamera(60, 1, .1, 100); let poses = 0; const template = { group: new Group(), clone() { return { group: new Group(), setEquipment() {}, pose() { poses++; return true; }, dispose() {} } as unknown as Sheep; } } as Sheep; const remote = createRemotePlayers(template, scene, () => 0); const player: PlayerState = { id: 'other', nickname: '친구', x: 0, z: 10, yaw: 0, moving: false, emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 }; remote.synchronize([player], 'self'); for (let i = 0; i < 300; i++) remote.update(1 / 30, 1000 + i * 1000 / 30, camera); assert.equal(poses, 0); assert.equal(remote.get('other')!.visible, false); remote.synchronize([{ ...player, x: 2, updatedAt: 11000 }], 'self'); remote.update(.2, 11200, camera); assert.equal(remote.get('other')!.position.x, 2); camera.lookAt(2, 0, 10); assert.equal(remote.update(.03, 11230, camera), true); assert.equal(poses, 1); assert.equal(remote.get('other')!.visible, true); }); test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => { const scene = new Group(); const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial(); const template = { clone() { const group = new Group(); group.add(new Mesh(geometry, material)); return { group, setEquipment() {}, pose: () => false, dispose() { group.removeFromParent(); } } as unknown as Sheep; } } as Sheep; const remote = createRemotePlayers(template, scene, () => 0); const player: PlayerState = { id: 'friend', nickname: '친구', x: 0, z: 0, yaw: 0, moving: false, emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 }; remote.synchronize([player], 'self'); const ray = new Raycaster(new Vector3(0, 0, 5), new Vector3(0, 0, -1)); assert.equal(remote.pick(ray, 10), 'friend'); assert.equal(remote.pick(ray, 2), undefined, 'terrain in front prevents selection'); const from = new Vector3(3, 0, 0); const right = remote.beside('friend', from, () => true)!; assert.ok(right.position.x > 0 && right.position.z === 0); assert.equal(right.yaw, 0); const left = remote.beside('friend', from, point => point.x < 0)!; assert.ok(left.position.x < 0); assert.equal(remote.beside('friend', from, () => false), undefined); remote.synchronize([player, { ...player, id: 'occupied', x: right.position.x }], 'self'); assert.ok(remote.beside('friend', from, () => true)!.position.x < 0); remote.synchronize([], 'self'); assert.equal(remote.get('friend'), undefined); assert.equal(remote.beside('friend', from, () => true), undefined); geometry.dispose(); material.dispose(); }); 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; let heightSamples = 0; const remote = createRemotePlayers(template, scene, () => { heightSamples++; return 3; }); const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false, emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, 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, true]); 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); const samplesAfterMoving = heightSamples; for (let i = 0; i < 300; i++) remote.update(1 / 30, 1300 + i * 1000 / 30); assert.equal(heightSamples, samplesAfterMoving, 'stationary players reuse their terrain height'); remote.synchronize([], 'self'); assert.equal(scene.children.length, 0); assert.equal(disposed, 1); });