import { appendFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { game, type Input, type State } from '../../src/lib/game'; import type { GameSpec } from '../../src/lib/game/spec'; import { Rooms } from './rooms'; import { Store } from './store'; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'rooms-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); /** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */ function table(spec: GameSpec = game, withCy = false) { const rooms = new Rooms(spec, new Store(dir)); const host = rooms.create('Ann', 3); const guest = rooms.join(host.room, 'Bo'); const third = withCy ? rooms.join(host.room, 'Cy') : null; rooms.begin(host.room, host.token); return { rooms, room: host.room, ann: host.token, bo: guest.token, cy: third?.token }; } describe('rooms', () => { it('resolves a round once every seat has moved, and refuses a move for a round already played', () => { const { rooms, room, ann, bo } = table(); rooms.submit(room, ann, { pick: 5 }, 0); expect(rooms.waitingOn(room)).toEqual(['B']); rooms.submit(room, bo, { pick: 2 }, 0); expect(room.resolved).toBe(1); expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/); }); it('gives a seat a new token at the keeper\'s word: the old one opens nothing, and a restart keeps the change', () => { const { rooms, room, bo } = table(); const phrase = rooms.transfer(room, bo).phrase; const { seat, token } = rooms.reseat(room, 'B'); expect(seat.name).toBe('Bo'); expect(rooms.seatOf(room, token).id).toBe('B'); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); // A phrase minted with the old token carries nothing now. expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); const reopened = new Rooms(game, new Store(dir)).get(room.id); expect(rooms.seatOf(reopened, token).id).toBe('B'); expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/); }); it('re-seats only a person, and says when each person last acted', () => { const { rooms, room, ann } = table(); expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/); rooms.submit(room, ann, { pick: 5 }, 0); const seats = rooms.seatsForKeeper(room); expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]); expect(seats[0].lastActed).toBeGreaterThan(0); expect(seats[1].lastActed).toBeNull(); }); it('leaves a room with an unreadable ledger unloaded and opens the rest', () => { const { room } = table(); appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn'); const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); const reopened = new Rooms(game, new Store(dir)); expect(errors).toHaveBeenCalledWith(expect.stringMatching(/ZZZZ: ledger unreadable/)); expect(reopened.get(room.id).state).not.toBeNull(); errors.mockRestore(); }); it('holds a draft until it is settled, and lets it be replaced until then', () => { type Draft = Input & { final?: boolean }; const drafts: GameSpec = { ...game, cleanInput: (raw) => ({ ...game.cleanInput(raw), final: (raw as Draft)?.final === true }) as Draft, settled: (input) => (input as Draft).final === true }; const { rooms, room, ann, bo } = table(drafts); rooms.submit(room, ann, { pick: 5 }, 0); rooms.submit(room, bo, { pick: 2, final: true }, 0); expect(rooms.waitingOn(room)).toEqual(['A']); rooms.submit(room, ann, { pick: 4 }, 0); expect(room.resolved).toBe(0); rooms.submit(room, ann, { pick: 3, final: true }, 0); expect(room.resolved).toBe(1); expect(room.state!.rounds[0].picks).toEqual({ A: 3, B: 2 }); rooms.submit(room, bo, { pick: 1, final: true }, 1); expect(() => rooms.submit(room, bo, { pick: 2, final: true }, 1)).toThrow(/already in/); rooms.submit(room, bo, { pick: 2 }, 1); expect(rooms.waitingOn(room)).toEqual(['A', 'B']); }); it("shows a seat its own move in waiting and its last move played, and nobody else's", () => { const { rooms, room, ann, bo } = table(); rooms.submit(room, ann, { pick: 5 }, 0); expect(rooms.view(room, 'A').yourInput).toEqual({ pick: 5 }); expect(rooms.view(room, 'B').yourInput).toBeNull(); expect(rooms.view(room, '').yourInput).toBeNull(); rooms.submit(room, bo, { pick: 2 }, 0); expect(rooms.view(room, 'A').yourInput).toBeNull(); expect(rooms.view(room, 'A').yourLastInput).toEqual({ pick: 5 }); expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 }); expect(rooms.view(room, '').yourLastInput).toBeNull(); }); it('shows a private line to its sender and the seats it names, never to anyone else or the gallery', () => { const { rooms, room, ann, bo } = table(game, true); rooms.say(room, ann, 'Everyone, hello.'); rooms.say(room, ann, 'Bo, lay a five.', ['B']); const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text); expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']); expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']); expect(heard('C')).toEqual(['Everyone, hello.']); expect(heard('')).toEqual(['Everyone, hello.']); expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 }); expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/); }); it('keeps each ranking secret until the deal, which reads them all from the start line', () => { const dealt: Record[] = []; const ranked: GameSpec = { ...game, rankable: () => [ { value: 'red', label: 'Red' }, { value: 'blue', label: 'Blue' } ], create: (names, seed, rules, options, rankings) => { dealt.push(rankings ?? {}); return game.create(names, seed, rules, options); } }; const rooms = new Rooms(ranked, new Store(dir)); const host = rooms.create('Ann', 2); const guest = rooms.join(host.room, 'Bo'); const room = host.room; expect(() => rooms.rank(room, host.token, ['red'])).toThrow(/every choice once/); rooms.rank(room, host.token, ['blue', 'red']); expect(rooms.view(room, 'A').yourRanking).toEqual(['blue', 'red']); expect(rooms.view(room, 'B')).toMatchObject({ yourRanking: null, ranked: 1 }); rooms.begin(room, host.token); expect(dealt).toEqual([{ A: ['blue', 'red'] }]); expect(() => rooms.rank(room, guest.token, ['red', 'blue'])).toThrow(/ranking is closed/); new Rooms(ranked, new Store(dir)); expect(dealt[1]).toEqual({ A: ['blue', 'red'] }); }); it('refuses a ranking at a table that asks for none', () => { const rooms = new Rooms(game, new Store(dir)); const host = rooms.create('Ann', 2); expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/); }); it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => { const heard: string[][] = []; const talking: GameSpec = { ...game, botInput: (state, seat, press) => { heard.push((press ?? []).map((l) => l.text)); return game.botInput(state, seat); }, botPress: (state, seat, press, people, trigger) => trigger === 'phase' ? [{ to: people, text: `Round ${game.turn(state)}.`, note: 'secret' }] : [{ to: [press[press.length - 1].id], text: 'I hear you.' }], parting: (_press, viewer) => [{ id: 'B', text: `Farewell, ${viewer}.` }] }; const rooms = new Rooms(talking, new Store(dir)); const host = rooms.create('Ann', 3); rooms.addBot(host.room, host.token); const cy = rooms.join(host.room, 'Cy'); const room = host.room; rooms.begin(room, host.token); rooms.say(room, host.token, 'Psst.', ['B']); rooms.say(room, cy.token, 'Hello, all.'); const annSees = rooms.view(room, 'A').chat; expect(annSees.map((l) => l.text)).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.']); expect(annSees.some((l) => 'note' in l)).toBe(false); expect(rooms.view(room, 'C').chat.map((l) => l.text)).toEqual(['Round 1.', 'Hello, all.', 'I hear you.']); expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']); rooms.submit(room, host.token, { pick: 1 }, 0); rooms.submit(room, cy.token, { pick: 2 }, 0); expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']); expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']); expect(rooms.view(room, 'A').parting).toEqual([]); }); it('refuses private talk to a bot when the game gives bots no words', () => { const rooms = new Rooms(game, new Store(dir)); const host = rooms.create('Ann', 2); rooms.addBot(host.room, host.token); rooms.begin(host.room, host.token); expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/); }); describe('a table the bots play on alone', () => { /** A count to ten that only the bots play: the host is never owed a move. */ type Count = { n: number; over: { winner: null; reason: string } | null }; const count: GameSpec = { seatIds: ['A', 'B'], minSeats: 2, botNames: ['Aldric'], currentRules: 1, create: () => ({ n: 0, over: null }), resolve: (s) => ({ n: s.n + 1, over: s.n + 1 >= 10 ? { winner: null, reason: 'Ten.' } : null }), needsInput: (s, seat) => seat === 'B' && !s.over, over: (s) => s.over, turn: (s) => s.n + 1, view: (s) => s, botInput: () => null, cleanInput: () => null, nameOf: (_s, seat) => seat }; beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('plays a few rounds at once, then one at a pace to watch, and the rest at once when hurried', () => { const rooms = new Rooms(count, new Store(dir)); const host = rooms.create('Ann', 2); rooms.addBot(host.room, host.token); rooms.begin(host.room, host.token); const room = host.room; expect(room.state!.n).toBe(4); expect(rooms.view(room, 'A').playingOn).toBe(true); vi.advanceTimersByTime(2000); expect(room.state!.n).toBe(5); rooms.hurry(room, host.token); vi.runAllTimers(); expect(room.state!.n).toBe(10); expect(room.over).toBe(true); expect(rooms.view(room, 'A').playingOn).toBe(false); }); }); }); describe('the host and the other seats', () => { it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", () => { const { rooms, room, ann, bo } = table(); rooms.submit(room, ann, { pick: 5 }, 0); rooms.standIn(room, ann, 'B'); // The round waited on Bo alone; the bot played it. expect(room.resolved).toBe(1); expect(rooms.waitingOn(room)).toEqual(['A']); expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/); expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true }); rooms.submit(room, ann, { pick: 3 }, 1); expect(room.resolved).toBe(2); }); it('keeps a move the person made before the bot stood in', () => { const { rooms, room, ann, bo } = table(); rooms.submit(room, bo, { pick: 2 }, 0); rooms.standIn(room, ann, 'B'); rooms.submit(room, ann, { pick: 5 }, 0); expect(room.state!.rounds[0].picks).toEqual({ A: 5, B: 2 }); }); it('is the host\'s call alone, in a game under way, for a person other than the host', () => { const { rooms, room, ann, bo } = table(); expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/); expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/); expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person sits/); const lobby = rooms.create('Ann', 2); rooms.join(lobby.room, 'Bo'); expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/); }); it('gives the seat back by the host\'s phrase, with a new token, and a restart keeps both changes', () => { const { rooms, room, ann, bo } = table(); rooms.standIn(room, ann, 'B'); expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true }); const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase); expect(claimed.seat).toBe('B'); expect(rooms.seatOf(room, claimed.token).name).toBe('Bo'); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); expect(rooms.waitingOn(room)).toContain('B'); const reopened = new Rooms(game, new Store(dir)).get(room.id); expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false }); }); it("re-seats a person whose browser lost the seat, by the host's phrase, once", () => { const { rooms, room, ann, bo } = table(); const { phrase } = rooms.hostPhrase(room, ann, 'B'); const claimed = rooms.claim(phrase); expect(claimed.token).not.toBe(bo); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); }); it("makes a phrase only for the host, and only for another person's seat", () => { const { rooms, room, ann, bo } = table(); expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the player who opened/); expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/); const withBot = rooms.create('Ann', 2); const bot = rooms.addBot(withBot.room, withBot.token); expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/); }); it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', () => { const { rooms, room, ann } = table(); const { phrase } = rooms.hostPhrase(room, ann, 'B'); rooms.standIn(room, ann, 'B'); expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved); const next = rooms.get(rooms.rematch(room, ann).roomId); expect(next.expected).toEqual(['Bo']); expect(next.seats.map((s) => s.name)).toEqual(['Ann']); }); });