Files
waving-hands/server/src/rooms.test.ts
T

180 lines
8.6 KiB
TypeScript

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<void>((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]);
});
});