Engine: the facade re-exports the board in one block; describe() and the dead letters preference, void ply, the King-taken line, the identity maps and the unused imports are gone; SIDE_NAMES is the one spelling of a side; the drake's hunter comment no longer claims only four kinds reach the air. Prose: no shatranj and no sitting duck, which Gygax never wrote; the Thief statistic and the Basilisk's "one trick" are gone; lessons say what the board shows instead of repeating the panel; a Thief's leap counts four. Browser: one keyboard walk, in BoardArtwork; the lesson board and page share one game and one inspected piece; the chronicle's possessive is told, not patched by a regex; the chronicle colours and the reading layout are global rules instead of three copies; the guide and the about panel say what the code and the article actually do. Server and docs: the room server states that it is the kit's, kept whole; the skills, the deploy README and the share card name files that exist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
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 { square } from '../../src/lib/game/board';
|
|
import type { GameSpec } from '../../src/lib/game/spec';
|
|
import { Rooms } from './rooms';
|
|
import { Store } from './store';
|
|
import { SPECTATOR } from '../../src/lib/game/spec';
|
|
|
|
let dir: string;
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
|
|
});
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Gold's first Warrior step and Scarlet's answer. */
|
|
const GOLD_OPENS: Input = { from: square('2c2'), to: square('2c3') };
|
|
const SCARLET_ANSWERS: Input = { from: square('2c7'), to: square('2c6') };
|
|
|
|
/** The bots move once the request that made their move due has been answered: after this tick. */
|
|
const answered = () => new Promise<void>((r) => setImmediate(r));
|
|
|
|
/** A table of two: Ann, who opened it and plays Gold, and Bo, the game begun. */
|
|
function table(spec: GameSpec<State, Input> = game) {
|
|
const rooms = new Rooms(spec, new Store(dir));
|
|
const host = rooms.create('Ann', 2);
|
|
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('plays a move as soon as the side to move sends it, and refuses one for a round already played', () => {
|
|
const { rooms, room, ann, bo } = table();
|
|
expect(rooms.waitingOn(room)).toEqual(['A']);
|
|
expect(() => rooms.submit(room, bo, SCARLET_ANSWERS, 0)).toThrow(/not your move/);
|
|
rooms.submit(room, ann, GOLD_OPENS, 0);
|
|
expect(room.resolved).toBe(1);
|
|
expect(rooms.waitingOn(room)).toEqual(['B']);
|
|
expect(() => rooms.submit(room, ann, GOLD_OPENS, 0)).toThrow(/already been played/);
|
|
rooms.submit(room, bo, SCARLET_ANSWERS, 1);
|
|
expect(room.resolved).toBe(2);
|
|
expect(room.state!.moves).toHaveLength(2);
|
|
});
|
|
|
|
it('refuses a move the rules refuse, with the reason', () => {
|
|
const { rooms, room, ann } = table();
|
|
expect(() => rooms.submit(room, ann, { from: square('2c2'), to: square('2c4') }, 0)).toThrow(/Warrior cannot go to 2c4/);
|
|
expect(() => rooms.submit(room, ann, { from: square('2c7'), to: square('2c6') }, 0)).toThrow(/not one of your pieces/);
|
|
});
|
|
|
|
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, 'I came too late for the seat; call me again.');
|
|
const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' };
|
|
expect(room.chat.at(-1)).toMatchObject(said);
|
|
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
|
|
});
|
|
|
|
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, GOLD_OPENS, 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('replays a game from its ledger to the same position after a restart', () => {
|
|
const { rooms, room, ann, bo } = table();
|
|
rooms.submit(room, ann, GOLD_OPENS, 0);
|
|
rooms.submit(room, bo, SCARLET_ANSWERS, 1);
|
|
const reopened = new Rooms(game, new Store(dir)).get(room.id);
|
|
expect(reopened.state!.cells).toEqual(room.state!.cells);
|
|
expect(reopened.state!.toMove).toBe('gold');
|
|
expect(reopened.resolved).toBe(2);
|
|
});
|
|
|
|
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("shows a seat its last move played, and nobody else's", () => {
|
|
const { rooms, room, ann } = table();
|
|
rooms.submit(room, ann, GOLD_OPENS, 0);
|
|
expect(rooms.view(room, 'A').yourInput).toBeNull();
|
|
expect(rooms.view(room, 'A').yourLastInput).toEqual(GOLD_OPENS);
|
|
expect(rooms.view(room, 'B').yourLastInput).toBeNull();
|
|
expect(rooms.view(room, '').yourLastInput).toBeNull();
|
|
});
|
|
|
|
it('shows a private line to its sender and the seat it names, never to the gallery', () => {
|
|
const { rooms, room, ann, bo } = table();
|
|
rooms.say(room, ann, 'Everyone, hello.');
|
|
rooms.say(room, ann, 'Bo, mind your Dragon.', ['B']);
|
|
const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text);
|
|
expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, mind your Dragon.']);
|
|
expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, mind your Dragon.']);
|
|
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('seats the host on the side they chose, and a drake across from them, which moves once the host has been answered', async () => {
|
|
const rooms = new Rooms(game, new Store(dir));
|
|
const host = rooms.create('Ann', 2, { side: 'scarlet', setup: 'shifted' });
|
|
const bot = rooms.addBot(host.room, host.token);
|
|
expect(game.botNames).toContain(bot.name);
|
|
rooms.begin(host.room, host.token);
|
|
expect(host.room.state!.players.A.side).toBe('scarlet');
|
|
expect(host.room.state!.players.B.side).toBe('gold');
|
|
expect(host.room.state!.setup).toBe('shifted');
|
|
// Gold moves first, and Gold is the drake: it moves once the host's request has been answered, not inside it.
|
|
expect(host.room.state!.moves).toHaveLength(0);
|
|
await answered();
|
|
expect(host.room.state!.moves).toHaveLength(1);
|
|
expect(rooms.waitingOn(host.room)).toEqual(['A']);
|
|
});
|
|
|
|
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('refuses private talk to a drake, which reads none', () => {
|
|
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<Count, null> = {
|
|
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;
|
|
vi.runOnlyPendingTimers();
|
|
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 drake, which plays it at once, and the person's token opens nothing", async () => {
|
|
const { rooms, room, ann, bo } = table();
|
|
rooms.submit(room, ann, GOLD_OPENS, 0);
|
|
rooms.standIn(room, ann, 'B');
|
|
// The move was Bo's; the drake makes it as soon as the host has been answered.
|
|
await answered();
|
|
expect(room.resolved).toBe(2);
|
|
expect(rooms.waitingOn(room)).toEqual(['A']);
|
|
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a drake/);
|
|
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
|
rooms.submit(room, ann, { from: square('2d2'), to: square('2d3') }, 2);
|
|
await answered();
|
|
expect(room.resolved).toBe(4);
|
|
});
|
|
|
|
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.submit(room, ann, GOLD_OPENS, 0);
|
|
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/);
|
|
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 drake stands 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/);
|
|
});
|
|
});
|