anvilsign in

collin/mahjong

1import { createServer } from 'node:http';
2import { afterAll, beforeAll, expect, test } from 'vitest';
3import { joinRoom } from '../src/net/room.ts';
4import { attachRooms } from './rooms.ts';
5
6// The real client against the real relay, in one process. Plain .mjs on
7// purpose: the client is typed for the browser and the relay for node, and
8// neither tsconfig wants to swallow the other — vitest transforms both.
9
10let server;
11let url;
12const opened = [];
13
14beforeAll(async () => {
15 server = createServer();
16 attachRooms(server);
17 await new Promise((r) => server.listen(0, '127.0.0.1', r));
18 url = `ws://127.0.0.1:${server.address().port}/ws`;
19});
20afterAll(async () => {
21 for (const r of opened) r.leave();
22 await new Promise((r) => server.close(r));
23});
24
25const join = async (roomCode) => {
26 const r = await joinRoom({ room: roomCode, server: url });
27 opened.push(r);
28 return r;
29};
30const next = (sub) => new Promise((r) => {
31 const un = sub((...args) => {
32 un();
33 r(args);
34 });
35});
36
37test('a full little room: join, state, events, migration', async () => {
38 const a = await join();
39 expect(a.code).toMatch(/^[A-Z2-9]{6}$/);
40 expect(a.isHost).toBe(true);
41 expect(a.me.name).toBe('Guest 1');
42
43 const joinSeen = next((cb) => a.onJoin(cb));
44 const b = await join(a.code);
45 expect(b.code).toBe(a.code);
46 expect(b.isHost).toBe(false);
47 expect(b.players.length).toBe(2);
48 expect((await joinSeen)[0].id).toBe(b.me.id);
49
50 // Host writes, mirror reads.
51 const stateSeen = next((cb) => b.onState(cb));
52 a.setState({ mode: 'party', game: { turn: 2 } });
53 await stateSeen;
54 expect(b.state.mode).toBe('party');
55 expect(b.state.game).toEqual({ turn: 2 });
56
57 // Only the host writes.
58 expect(() => b.setState({ evil: true })).toThrow();
59
60 // Events go the other way, stamped with the sender.
61 const actSeen = next((cb) => a.on('act', (payload, from) => cb(payload, from)));
62 b.send('act', { seat: 1 });
63 const [payload, from] = await actSeen;
64 expect(payload).toEqual({ seat: 1 });
65 expect(from).toBe(b.me.id);
66
67 // The host leaves; the room passes to the next in line, state intact.
68 const leaveSeen = next((cb) => b.onLeave(cb));
69 const hostSeen = next((cb) => b.onHostChange(cb));
70 a.leave();
71 expect((await leaveSeen)[0].id).toBe(a.me.id);
72 await hostSeen;
73 expect(b.isHost).toBe(true);
74 expect(b.state.mode).toBe('party');
75 b.setState({ mode: 'party', n: 1 }); // and the crown is real
76});
77
78test('a late joiner walks in on the state as it stands', async () => {
79 const a = await join();
80 a.setState({ mode: 'online', seats: [1, 2, 3, 4] });
81 // The relay applies the (microtask-coalesced) write before answering a
82 // later hello, both being one client's messages in order — but ours is a
83 // second connection, so give the flush a beat.
84 await new Promise((r) => setTimeout(r, 50));
85 const b = await join(a.code);
86 expect(b.state.mode).toBe('online');
87 expect(b.state.seats).toEqual([1, 2, 3, 4]);
88});
89
90test('an unknown code becomes a fresh room, not an error', async () => {
91 const a = await join('REBORN');
92 expect(a.code).toBe('REBORN');
93 expect(a.isHost).toBe(true);
94 expect(a.state).toEqual({});
95});