import { Packr } from 'msgpackr'; import { afterEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import { loadCharacterStats, loadMap } from '../src/server/assets'; import { Game } from '../src/server/game'; import { GameServer, type GameServerOptions } from '../src/server/net'; import { HASH_CHECK_INTERVAL_S, PROTOCOL_VERSION, START_COUNTDOWN_S, TICK_RATE } from '../src/shared/constants'; import { Button } from '../src/shared/input'; import type { ClientMessage, ServerMessage } from '../src/shared/protocol'; import type { TerrainBitmap } from '../src/shared/terrain/bitmap'; import { applyTerrainEvent } from '../src/shared/terrain/events'; import { ASSETS, realMaterials } from './helpers'; const packr = new Packr({ useRecords: false }); const materials = realMaterials(); const freshTerrain = (): TerrainBitmap => loadMap(ASSETS, 'gateau-etages', materials).terrain; const flush = (ms = 30): Promise => new Promise((r) => setTimeout(r, ms)); /** A headless client that keeps its own bitmap in sync, like the renderer does. */ class TestClient { readonly messages: ServerMessage[] = []; readonly socket: WebSocket; terrain = freshTerrain(); lastSeq = 0; playerId = -1; hashChecks: { seq: number; server: number; mine: number }[] = []; constructor(port: number) { this.socket = new WebSocket(`ws://127.0.0.1:${port}`); this.socket.on('message', (data: Buffer) => this.onMessage(packr.unpack(data) as ServerMessage)); } async hello(name: string, characterId = 'generic', version = PROTOCOL_VERSION): Promise { await new Promise((r) => this.socket.once('open', r)); this.send({ t: 'hello', version, name, characterId }); } send(msg: ClientMessage): void { this.socket.send(packr.pack(msg)); } last(t: T): Extract | undefined { return [...this.messages].reverse().find((m) => m.t === t) as Extract | undefined; } private onMessage(msg: ServerMessage): void { this.messages.push(msg); if (msg.t === 'welcome') { this.playerId = msg.playerId; for (const { seq, ev } of msg.events) this.apply(seq, ev); } else if (msg.t === 'start') { this.terrain = freshTerrain(); this.lastSeq = 0; } else if (msg.t === 'terrain') { this.apply(msg.seq, msg.ev); } else if (msg.t === 'hash') { expect(this.lastSeq).toBe(msg.seq); this.hashChecks.push({ seq: msg.seq, server: msg.hash, mine: this.terrain.hash() }); } } private apply(seq: number, ev: Parameters[1]): void { expect(seq).toBe(this.lastSeq + 1); this.lastSeq = seq; applyTerrainEvent(this.terrain, ev, materials); } } describe('LAN server (M3)', () => { let server: GameServer | null = null; const clients: TestClient[] = []; afterEach(() => { for (const c of clients) c.socket.close(); clients.length = 0; server?.close(); server = null; }); async function startServer(pacing: Partial = {}): Promise { await new Promise((resolve) => { server = new GameServer({ ...pacing, createGame: (seed, rules, stats) => { const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials); return new Game({ map: definition, terrain, materials, seed, rules, stats, loadCharacter: (id) => loadCharacterStats(ASSETS, id) }); }, seed: 42, port: 0, host: '127.0.0.1', onListening: resolve, }); }); return server!; } function tick(n: number): void { for (let i = 0; i < n; i++) server!.tickOnce(); } it('rejects a client with another protocol version', async () => { const s = await startServer(); const c = new TestClient(s.port); clients.push(c); await c.hello('Vieux', 'generic', PROTOCOL_VERSION - 1); await flush(); expect(c.last('reject')).toMatchObject({ reason: 'version', serverVersion: PROTOCOL_VERSION }); }); it('lobby → everyone ready → countdown → match on a fresh map, clients stay bit-identical', async () => { const s = await startServer(); const a = new TestClient(s.port); const b = new TestClient(s.port); clients.push(a, b); await a.hello('Alice'); await b.hello('Bob', 'castor'); await flush(); expect(a.last('lobby')).toMatchObject({ phase: 'lobby', countdown: null }); expect(a.last('lobby')!.players.map((p) => p.name)).toEqual(['Alice', 'Bob']); // Warm-up destruction, then only one player ready: no countdown. tick(120); a.send({ t: 'ready', ready: true }); await flush(); tick(1); await flush(); expect(b.last('lobby')).toMatchObject({ countdown: null }); b.send({ t: 'ready', ready: true }); await flush(); expect(a.last('lobby')!.countdown).toBe(START_COUNTDOWN_S); tick(START_COUNTDOWN_S * TICK_RATE); await flush(); expect(a.last('start')).toBeDefined(); expect(b.last('start')!.players.map((p) => p.id).sort()).toEqual([a.playerId, b.playerId].sort()); expect(a.last('lobby')).toMatchObject({ phase: 'playing' }); // Both dig for a while; snapshots, terrain events and hash checks flow to both clients. for (let i = 0; i < HASH_CHECK_INTERVAL_S * TICK_RATE * 2; i++) { if (i % 10 === 0) { const snap = a.last('snapshot'); for (const [c, dy] of [ [a, 40], [b, 60], ] as const) { const me = snap?.players.find((p) => p.id === c.playerId); if (me) c.send({ t: 'input', buttons: c === a ? Button.Attack : Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + dy) }); } await flush(5); } tick(1); } await flush(100); expect(a.lastSeq).toBeGreaterThan(5); for (const c of [a, b]) { expect(c.hashChecks.length).toBeGreaterThanOrEqual(2); for (const h of c.hashChecks) expect(h.mine).toBe(h.server); } expect(a.terrain.hash()).toBe(b.terrain.hash()); }); it('a player leaving is announced and an empty server goes back to the lobby', async () => { const s = await startServer(); const a = new TestClient(s.port); const b = new TestClient(s.port); clients.push(a, b); await a.hello('Alice'); await b.hello('Bob'); await flush(); b.socket.close(); await flush(); expect(a.last('playerLeft')).toMatchObject({ id: b.playerId }); expect(a.last('lobby')!.players).toHaveLength(1); a.socket.close(); await flush(); expect(s.currentPhase).toBe('lobby'); expect(s.playerCount).toBe(0); }); it('a whole match: rounds with results in between, final results with trophies, then back to the lobby', async () => { const s = await startServer({ rounds: 2, roundDuration: 1, roundEndPause: 0.25, matchEndPause: 0.25 }); const a = new TestClient(s.port); clients.push(a); await a.hello('Alice', 'castor'); await flush(); a.send({ t: 'ready', ready: true }); await flush(); tick(START_COUNTDOWN_S * TICK_RATE); await flush(); expect(a.last('start')).toMatchObject({ round: 1, rounds: 2 }); // Alice eats the cake during round 1 so she earns a trophy. for (let i = 0; i < TICK_RATE + 2; i++) { const me = a.last('snapshot')?.players.find((p) => p.id === a.playerId); if (me && i % 10 === 0) { a.send({ t: 'input', buttons: Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + 100) }); await flush(5); } tick(1); } await flush(); expect(a.last('roundEnd')).toMatchObject({ round: 1, rounds: 2 }); expect(a.last('lobby')).toMatchObject({ phase: 'roundEnd' }); tick(Math.round(0.25 * TICK_RATE)); await flush(); expect(a.last('start')).toMatchObject({ round: 2 }); tick(TICK_RATE + 2); await flush(); const end = a.last('matchEnd')!; expect(end).toBeDefined(); expect(end.results.map((r) => r.id)).toEqual([a.playerId]); expect(end.trophies).toContainEqual(expect.objectContaining({ id: 'glutton', playerId: a.playerId })); tick(Math.round(0.25 * TICK_RATE)); await flush(); expect(a.last('start')).toMatchObject({ round: 0 }); expect(a.last('lobby')).toMatchObject({ phase: 'lobby' }); expect(a.last('lobby')!.players[0].ready).toBe(false); }); });