net.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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, type GameServerOptions } 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(pacing: Partial<GameServerOptions> = {}): Promise<GameServer> {
  70. await new Promise<void>((resolve) => {
  71. server = new GameServer({
  72. ...pacing,
  73. createGame: (seed, rules, stats) => {
  74. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  75. return new Game({ map: definition, terrain, materials, seed, rules, stats, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  76. },
  77. seed: 42,
  78. port: 0,
  79. host: '127.0.0.1',
  80. onListening: resolve,
  81. });
  82. });
  83. return server!;
  84. }
  85. function tick(n: number): void {
  86. for (let i = 0; i < n; i++) server!.tickOnce();
  87. }
  88. it('rejects a client with another protocol version', async () => {
  89. const s = await startServer();
  90. const c = new TestClient(s.port);
  91. clients.push(c);
  92. await c.hello('Vieux', 'generic', PROTOCOL_VERSION - 1);
  93. await flush();
  94. expect(c.last('reject')).toMatchObject({ reason: 'version', serverVersion: PROTOCOL_VERSION });
  95. });
  96. it('lobby → everyone ready → countdown → match on a fresh map, clients stay bit-identical', async () => {
  97. const s = await startServer();
  98. const a = new TestClient(s.port);
  99. const b = new TestClient(s.port);
  100. clients.push(a, b);
  101. await a.hello('Alice');
  102. await b.hello('Bob', 'castor');
  103. await flush();
  104. expect(a.last('lobby')).toMatchObject({ phase: 'lobby', countdown: null });
  105. expect(a.last('lobby')!.players.map((p) => p.name)).toEqual(['Alice', 'Bob']);
  106. // Warm-up destruction, then only one player ready: no countdown.
  107. tick(120);
  108. a.send({ t: 'ready', ready: true });
  109. await flush();
  110. tick(1);
  111. await flush();
  112. expect(b.last('lobby')).toMatchObject({ countdown: null });
  113. b.send({ t: 'ready', ready: true });
  114. await flush();
  115. expect(a.last('lobby')!.countdown).toBe(START_COUNTDOWN_S);
  116. tick(START_COUNTDOWN_S * TICK_RATE);
  117. await flush();
  118. expect(a.last('start')).toBeDefined();
  119. expect(b.last('start')!.players.map((p) => p.id).sort()).toEqual([a.playerId, b.playerId].sort());
  120. expect(a.last('lobby')).toMatchObject({ phase: 'playing' });
  121. // Both dig for a while; snapshots, terrain events and hash checks flow to both clients.
  122. for (let i = 0; i < HASH_CHECK_INTERVAL_S * TICK_RATE * 2; i++) {
  123. if (i % 10 === 0) {
  124. const snap = a.last('snapshot');
  125. for (const [c, dy] of [
  126. [a, 40],
  127. [b, 60],
  128. ] as const) {
  129. const me = snap?.players.find((p) => p.id === c.playerId);
  130. if (me) c.send({ t: 'input', buttons: c === a ? Button.Attack : Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + dy) });
  131. }
  132. await flush(5);
  133. }
  134. tick(1);
  135. }
  136. await flush(100);
  137. expect(a.lastSeq).toBeGreaterThan(5);
  138. for (const c of [a, b]) {
  139. expect(c.hashChecks.length).toBeGreaterThanOrEqual(2);
  140. for (const h of c.hashChecks) expect(h.mine).toBe(h.server);
  141. }
  142. expect(a.terrain.hash()).toBe(b.terrain.hash());
  143. });
  144. it('a player leaving is announced and an empty server goes back to the lobby', async () => {
  145. const s = await startServer();
  146. const a = new TestClient(s.port);
  147. const b = new TestClient(s.port);
  148. clients.push(a, b);
  149. await a.hello('Alice');
  150. await b.hello('Bob');
  151. await flush();
  152. b.socket.close();
  153. await flush();
  154. expect(a.last('playerLeft')).toMatchObject({ id: b.playerId });
  155. expect(a.last('lobby')!.players).toHaveLength(1);
  156. a.socket.close();
  157. await flush();
  158. expect(s.currentPhase).toBe('lobby');
  159. expect(s.playerCount).toBe(0);
  160. });
  161. it('a whole match: rounds with results in between, final results with trophies, then back to the lobby', async () => {
  162. const s = await startServer({ rounds: 2, roundDuration: 1, roundEndPause: 0.25, matchEndPause: 0.25 });
  163. const a = new TestClient(s.port);
  164. clients.push(a);
  165. await a.hello('Alice', 'castor');
  166. await flush();
  167. a.send({ t: 'ready', ready: true });
  168. await flush();
  169. tick(START_COUNTDOWN_S * TICK_RATE);
  170. await flush();
  171. expect(a.last('start')).toMatchObject({ round: 1, rounds: 2 });
  172. // Alice eats the cake during round 1 so she earns a trophy.
  173. for (let i = 0; i < TICK_RATE + 2; i++) {
  174. const me = a.last('snapshot')?.players.find((p) => p.id === a.playerId);
  175. if (me && i % 10 === 0) {
  176. a.send({ t: 'input', buttons: Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + 100) });
  177. await flush(5);
  178. }
  179. tick(1);
  180. }
  181. await flush();
  182. expect(a.last('roundEnd')).toMatchObject({ round: 1, rounds: 2 });
  183. expect(a.last('lobby')).toMatchObject({ phase: 'roundEnd' });
  184. tick(Math.round(0.25 * TICK_RATE));
  185. await flush();
  186. expect(a.last('start')).toMatchObject({ round: 2 });
  187. tick(TICK_RATE + 2);
  188. await flush();
  189. const end = a.last('matchEnd')!;
  190. expect(end).toBeDefined();
  191. expect(end.results.map((r) => r.id)).toEqual([a.playerId]);
  192. expect(end.trophies).toContainEqual(expect.objectContaining({ id: 'glutton', playerId: a.playerId }));
  193. tick(Math.round(0.25 * TICK_RATE));
  194. await flush();
  195. expect(a.last('start')).toMatchObject({ round: 0 });
  196. expect(a.last('lobby')).toMatchObject({ phase: 'lobby' });
  197. expect(a.last('lobby')!.players[0].ready).toBe(false);
  198. });
  199. });