net.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { Packr } from 'msgpackr';
  2. import { afterEach, describe, expect, it } from 'vitest';
  3. import WebSocket from 'ws';
  4. import { loadCharacterStats, loadMap } from '../src/server/assets';
  5. import { Game } from '../src/server/game';
  6. import { GameServer } from '../src/server/net';
  7. import { HASH_CHECK_INTERVAL_S, PROTOCOL_VERSION, START_COUNTDOWN_S, TICK_RATE } from '../src/shared/constants';
  8. import { Button } from '../src/shared/input';
  9. import type { ClientMessage, ServerMessage } from '../src/shared/protocol';
  10. import type { TerrainBitmap } from '../src/shared/terrain/bitmap';
  11. import { applyTerrainEvent } from '../src/shared/terrain/events';
  12. import { ASSETS, realMaterials } from './helpers';
  13. const packr = new Packr({ useRecords: false });
  14. const materials = realMaterials();
  15. const freshTerrain = (): TerrainBitmap => loadMap(ASSETS, 'gateau-etages', materials).terrain;
  16. const flush = (ms = 30): Promise<void> => new Promise((r) => setTimeout(r, ms));
  17. /** A headless client that keeps its own bitmap in sync, like the renderer does. */
  18. class TestClient {
  19. readonly messages: ServerMessage[] = [];
  20. readonly socket: WebSocket;
  21. terrain = freshTerrain();
  22. lastSeq = 0;
  23. playerId = -1;
  24. hashChecks: { seq: number; server: number; mine: number }[] = [];
  25. constructor(port: number) {
  26. this.socket = new WebSocket(`ws://127.0.0.1:${port}`);
  27. this.socket.on('message', (data: Buffer) => this.onMessage(packr.unpack(data) as ServerMessage));
  28. }
  29. async hello(name: string, characterId = 'generic', version = PROTOCOL_VERSION): Promise<void> {
  30. await new Promise((r) => this.socket.once('open', r));
  31. this.send({ t: 'hello', version, name, characterId });
  32. }
  33. send(msg: ClientMessage): void {
  34. this.socket.send(packr.pack(msg));
  35. }
  36. last<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
  37. return [...this.messages].reverse().find((m) => m.t === t) as Extract<ServerMessage, { t: T }> | undefined;
  38. }
  39. private onMessage(msg: ServerMessage): void {
  40. this.messages.push(msg);
  41. if (msg.t === 'welcome') {
  42. this.playerId = msg.playerId;
  43. for (const { seq, ev } of msg.events) this.apply(seq, ev);
  44. } else if (msg.t === 'start') {
  45. this.terrain = freshTerrain();
  46. this.lastSeq = 0;
  47. } else if (msg.t === 'terrain') {
  48. this.apply(msg.seq, msg.ev);
  49. } else if (msg.t === 'hash') {
  50. expect(this.lastSeq).toBe(msg.seq);
  51. this.hashChecks.push({ seq: msg.seq, server: msg.hash, mine: this.terrain.hash() });
  52. }
  53. }
  54. private apply(seq: number, ev: Parameters<typeof applyTerrainEvent>[1]): void {
  55. expect(seq).toBe(this.lastSeq + 1);
  56. this.lastSeq = seq;
  57. applyTerrainEvent(this.terrain, ev, materials);
  58. }
  59. }
  60. describe('LAN server (M3)', () => {
  61. let server: GameServer | null = null;
  62. const clients: TestClient[] = [];
  63. afterEach(() => {
  64. for (const c of clients) c.socket.close();
  65. clients.length = 0;
  66. server?.close();
  67. server = null;
  68. });
  69. async function startServer(): Promise<GameServer> {
  70. await new Promise<void>((resolve) => {
  71. server = new GameServer({
  72. createGame: (seed) => {
  73. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  74. return new Game({ map: definition, terrain, materials, seed, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  75. },
  76. seed: 42,
  77. port: 0,
  78. host: '127.0.0.1',
  79. onListening: resolve,
  80. });
  81. });
  82. return server!;
  83. }
  84. function tick(n: number): void {
  85. for (let i = 0; i < n; i++) server!.tickOnce();
  86. }
  87. it('rejects a client with another protocol version', async () => {
  88. const s = await startServer();
  89. const c = new TestClient(s.port);
  90. clients.push(c);
  91. await c.hello('Vieux', 'generic', PROTOCOL_VERSION - 1);
  92. await flush();
  93. expect(c.last('reject')).toMatchObject({ reason: 'version', serverVersion: PROTOCOL_VERSION });
  94. });
  95. it('lobby → everyone ready → countdown → match on a fresh map, clients stay bit-identical', async () => {
  96. const s = await startServer();
  97. const a = new TestClient(s.port);
  98. const b = new TestClient(s.port);
  99. clients.push(a, b);
  100. await a.hello('Alice');
  101. await b.hello('Bob', 'castor');
  102. await flush();
  103. expect(a.last('lobby')).toMatchObject({ phase: 'lobby', countdown: null });
  104. expect(a.last('lobby')!.players.map((p) => p.name)).toEqual(['Alice', 'Bob']);
  105. // Warm-up destruction, then only one player ready: no countdown.
  106. tick(120);
  107. a.send({ t: 'ready', ready: true });
  108. await flush();
  109. tick(1);
  110. await flush();
  111. expect(b.last('lobby')).toMatchObject({ countdown: null });
  112. b.send({ t: 'ready', ready: true });
  113. await flush();
  114. expect(a.last('lobby')!.countdown).toBe(START_COUNTDOWN_S);
  115. tick(START_COUNTDOWN_S * TICK_RATE);
  116. await flush();
  117. expect(a.last('start')).toBeDefined();
  118. expect(b.last('start')!.players.map((p) => p.id).sort()).toEqual([a.playerId, b.playerId].sort());
  119. expect(a.last('lobby')).toMatchObject({ phase: 'playing' });
  120. // Both dig for a while; snapshots, terrain events and hash checks flow to both clients.
  121. for (let i = 0; i < HASH_CHECK_INTERVAL_S * TICK_RATE * 2; i++) {
  122. if (i % 10 === 0) {
  123. const snap = a.last('snapshot');
  124. for (const [c, dy] of [
  125. [a, 40],
  126. [b, 60],
  127. ] as const) {
  128. const me = snap?.players.find((p) => p.id === c.playerId);
  129. if (me) c.send({ t: 'input', buttons: c === a ? Button.Attack : Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + dy) });
  130. }
  131. await flush(5);
  132. }
  133. tick(1);
  134. }
  135. await flush(100);
  136. expect(a.lastSeq).toBeGreaterThan(5);
  137. for (const c of [a, b]) {
  138. expect(c.hashChecks.length).toBeGreaterThanOrEqual(2);
  139. for (const h of c.hashChecks) expect(h.mine).toBe(h.server);
  140. }
  141. expect(a.terrain.hash()).toBe(b.terrain.hash());
  142. });
  143. it('a player leaving is announced and an empty server goes back to the lobby', async () => {
  144. const s = await startServer();
  145. const a = new TestClient(s.port);
  146. const b = new TestClient(s.port);
  147. clients.push(a, b);
  148. await a.hello('Alice');
  149. await b.hello('Bob');
  150. await flush();
  151. b.socket.close();
  152. await flush();
  153. expect(a.last('playerLeft')).toMatchObject({ id: b.playerId });
  154. expect(a.last('lobby')!.players).toHaveLength(1);
  155. a.socket.close();
  156. await flush();
  157. expect(s.currentPhase).toBe('lobby');
  158. expect(s.playerCount).toBe(0);
  159. });
  160. });