import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Rooms } from './rooms'; import { chooseBotTurn } from '../../src/lib/game/bot'; import { Store } from './store'; import { SPECTATOR } from '../../src/lib/game/state'; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'rooms-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); /** A table of three with Ann, who opened it, and Bo seated, the duel begun. */ function table() { const rooms = new Rooms(new Store(dir)); const host = rooms.create('Ann', 3); const guest = rooms.join(host.room, 'Bo'); rooms.begin(host.room, host.token); return { rooms, room: host.room, ann: host.token, bo: guest.token }; } describe('rooms', () => { 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(new Store(dir)).get(room.id); expect(rooms.seatOf(reopened, token).id).toBe('B'); expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/); }); it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => { const { rooms, room } = table(); expect(room.galleryTalk).toBe(false); rooms.keeperSays(room, 'Kestrel', 'I came too late for the seat; call me again.'); const said = { id: SPECTATOR, name: 'Kestrel', keeper: true, text: 'I came too late for the seat; call me again.' }; expect(room.chat.at(-1)).toMatchObject(said); expect(new Rooms(new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said); }); it('re-seats only a wizard, and says when each last acted', () => { const { rooms, room, ann } = table(); expect(() => rooms.reseat(room, 'Z')).toThrow(/No wizard sits/); rooms.submit(room, ann, { left: 'P', right: '-', casts: [] }, 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(); }); }); describe('the host and the other seats', () => { it("gives a wizard's seat to a bot, which duels the turn it was waiting on, and the wizard's token opens nothing", () => { const { rooms, room, ann, bo } = table(); rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0); rooms.standIn(room, ann, 'B'); expect(room.resolved).toBe(1); 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 }); }); it('keeps the gestures a wizard made before the bot stood in', () => { const { rooms, room, ann, bo } = table(); const mine = { left: 'P', right: 'W', casts: [] } as const; rooms.submit(room, bo, mine, 0); rooms.standIn(room, ann, 'B'); rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0); expect(room.state!.wizards.B.history.at(-1)).toMatchObject({ left: 'P', right: 'W' }); }); it("is the host's call alone, in a duel under way, for a wizard other than the host", () => { const { rooms, room, ann, bo } = table(); expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the wizard who opened/); expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/); expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No wizard 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(new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true }); const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase); expect(rooms.seatOf(room, claimed.token).name).toBe('Bo'); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); const reopened = new Rooms(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 wizard whose browser lost the seat, by the host's phrase, once, and only the host makes one", () => { const { rooms, room, ann, bo } = table(); expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the wizard who opened/); expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other wizard/); const { phrase } = rooms.hostPhrase(room, ann, 'B'); expect(rooms.claim(phrase).token).not.toBe(bo); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); }); it('voids a phrase for the seat once a bot stands in, and holds the wizard\'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/); room.state = { ...room.state!, over: { winner: 'A', reason: 'test' } } as typeof room.state; const next = rooms.get(rooms.rematch(room, ann).roomId); expect(next.expected).toEqual(['Bo']); expect(next.seats.map((s) => s.name)).toEqual(['Ann']); }); }); describe('a seat a browser lost', () => { it('notes the address a seat is used from, once per change and not on its heels, without waking the duel, and a restart keeps it', () => { const { rooms, room, bo } = table(); const seq = room.seq; rooms.seen(room, bo, 'home'); rooms.seen(room, bo, 'home'); expect(room.seats.find((s) => s.id === 'B')?.addr).toBe('home'); expect(room.seq).toBe(seq); rooms.seen(room, 'no-such-token', 'elsewhere'); rooms.seen(room, bo, 'office'); expect(new Store(dir).read(room.id).filter((l) => l.t === 'seen')).toHaveLength(1); expect(new Rooms(new Store(dir)).get(room.id).seats.find((s) => s.id === 'B')?.addr).toBe('home'); }); it('gives the seat back, under its name, to the address it was last used from, with a new token', () => { const { rooms, room, bo } = table(); rooms.seen(room, bo, 'home'); const found = rooms.findSeats('bo', 'home'); expect(found.elsewhere).toBe(0); expect(found.seated.map((s) => [s.roomId, s.seat])).toEqual([[room.id, 'B']]); expect(rooms.seatOf(room, found.seated[0].token).name).toBe('Bo'); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); }); it('only counts the duel when the address differs, or another person at it shares the address, or a bot stands in', async () => { const { rooms, room, ann, bo } = table(); rooms.seen(room, bo, 'home'); expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: 1 }); rooms.seen(room, ann, 'home'); expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 }); expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: 0 }); expect(rooms.seatOf(room, bo).id).toBe('B'); rooms.standIn(room, ann, 'B'); await new Promise((r) => setImmediate(r)); expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 }); expect(rooms.tablesForKeeper('bo')).toMatchObject([{ roomId: room.id, seat: 'B', standIn: true, players: ['Ann'] }]); expect(rooms.tablesForKeeper('Dee')).toEqual([]); }); it('looks only at the duel named when one is, and passes over a duel that is over', () => { const { rooms, room, bo } = table(); rooms.seen(room, bo, 'home'); const other = rooms.create('Bo', 3); rooms.seen(other.room, other.token, 'home'); const first = rooms.findSeats('Bo', 'home', other.room.id).seated; expect(first.map((s) => s.roomId)).toEqual([other.room.id]); // A seat given back is noted afresh from its next use; until then its old address claims nothing. const second = rooms.findSeats('Bo', 'home').seated; expect(second.map((s) => s.roomId)).toEqual([room.id]); rooms.seen(other.room, first[0].token, 'home'); rooms.seen(room, second[0].token, 'home'); room.state!.over = { winner: 'A', reason: 'the test says so' }; expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId)).toEqual([other.room.id]); }); });