| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- 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<void> => 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<void> {
- 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 extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
- return [...this.messages].reverse().find((m) => m.t === t) as Extract<ServerMessage, { t: T }> | 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<typeof applyTerrainEvent>[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<GameServerOptions> = {}): Promise<GameServer> {
- await new Promise<void>((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);
- });
- });
|