A "seats" button beside "transfer seat" opens the host's panel. In a game under way, "give to the clockwork" lets an automaton play a person's seat from the next beat, with their hand, treasures and place untouched; a standIn line on the ledger records it, the chronicle says so, the stand-in never talks under the person's name, and the person's token opens nothing after. "Make a phrase" mints the four-word phrase for any person's seat; claiming it re-seats on a new token, and a seat the clockwork stood in for goes back to its person mid-turn. A rematch holds a stood-in seat for its person. The gallery now has its own claim box, for a player who opens the invite link in a fresh browser. The client's watchdog pinged and then closed a socket quiet for 45 seconds. A background tab's timers run about once a minute, so the last pong was always a minute old when it looked, and idle tabs reconnected every 58 seconds. It now closes only when its own ping went unanswered. The server pings every socket at the protocol level every 30 seconds and drops one that left the last ping unanswered, so a crashed browser's socket no longer lingers until TCP gives up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
155 lines
7.3 KiB
TypeScript
155 lines
7.3 KiB
TypeScript
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
// The store reads its directory when first imported, so the rooms module is
|
|
// loaded only after the ledgers have somewhere of their own to go.
|
|
process.env.WIZWAR_DATA_DIR = join(mkdtempSync(join(tmpdir(), "wizwar-seats-")), "rooms");
|
|
|
|
let rooms: typeof import("../src/rooms");
|
|
beforeAll(async () => {
|
|
rooms = await import("../src/rooms");
|
|
rooms.loadPersistedRooms();
|
|
});
|
|
|
|
/** Ann hosts, Bo and Cy sit, and the boards have flipped. */
|
|
function playing() {
|
|
const { room, token: ann } = rooms.createRoom("Ann");
|
|
const bo = token(rooms.joinRoom(room, "Bo", null));
|
|
const cy = token(rooms.joinRoom(room, "Cy", null));
|
|
const started = rooms.startGame(room, false);
|
|
if ("error" in started) throw new Error(started.error);
|
|
return { room, ann, bo, cy };
|
|
}
|
|
|
|
function token(result: { token: string } | { error: string }): string {
|
|
if ("error" in result) throw new Error(result.error);
|
|
return result.token;
|
|
}
|
|
|
|
function phrase(result: { code: string } | { error: string }): string {
|
|
if ("error" in result) throw new Error(result.error);
|
|
return result.code;
|
|
}
|
|
|
|
describe("a stand-in", () => {
|
|
it("plays a person's seat the host gives it, and the person's token opens nothing", () => {
|
|
const { room, bo } = playing();
|
|
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ event: { type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" } });
|
|
expect(room.bots.get("Bo")).toMatchObject({ standIn: true, secret: true });
|
|
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" });
|
|
expect(room.players).toContain("Bo");
|
|
});
|
|
|
|
it("takes the turn when the seat it stands in for holds it, and leaves the host's to the host", () => {
|
|
const { room } = playing();
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
rooms.standIn(room, "Ann", "Cy");
|
|
const holder = room.state!.players[room.state!.turn.activeIndex]!.id;
|
|
expect(rooms.driveOneAutomaton(room)?.seat ?? null).toBe(holder === "Ann" ? null : holder);
|
|
});
|
|
|
|
it("is the host's call alone, in a game under way, for a person still in it", () => {
|
|
const { room } = playing();
|
|
expect(rooms.standIn(room, "Bo", "Cy")).toEqual({ error: "only the host may give a seat to the clockwork" });
|
|
expect(rooms.standIn(room, "Ann", "Ann")).toEqual({ error: "the host keeps their own seat" });
|
|
expect(rooms.standIn(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ error: "no person sits in that seat" });
|
|
|
|
const { room: lobby } = rooms.createRoom("Ann");
|
|
rooms.joinRoom(lobby, "Bo", null);
|
|
expect(rooms.standIn(lobby, "Ann", "Bo")).toEqual({ error: "only a game under way needs a stand-in" });
|
|
});
|
|
|
|
it("is still standing in after a reload from the ledger", async () => {
|
|
const { room, bo } = playing();
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
vi.resetModules();
|
|
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
|
const again = reloaded.getRoom(room.id)!;
|
|
expect(again).not.toBe(room);
|
|
expect(again.bots.get("Bo")).toEqual(room.bots.get("Bo"));
|
|
expect(again.events.filter((e) => e.type === "seatChanged")).toEqual([{ type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" }]);
|
|
expect("error" in reloaded.joinRoom(again, "Bo", bo)).toBe(true);
|
|
});
|
|
it("holds the person's seat at a rematch, and brings no automaton for it", () => {
|
|
const { room } = playing();
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
room.state = { ...room.state!, phase: "finished" };
|
|
const called = rooms.callRematch(room, "Ann");
|
|
if ("error" in called) throw new Error(called.error);
|
|
const next = rooms.getRoom(called.roomId)!;
|
|
expect(next.expected).toEqual(["Bo", "Cy"]);
|
|
expect(next.bots.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("a phrase the host makes", () => {
|
|
it("re-seats a person whose browser lost the seat: the old token is refused, the new one opens it", () => {
|
|
const { room, bo } = playing();
|
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
|
if ("error" in claimed) throw new Error(claimed.error);
|
|
expect(claimed).toMatchObject({ roomId: room.id, name: "Bo", reseated: true });
|
|
expect(claimed.token).not.toBe(bo);
|
|
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
|
|
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
|
});
|
|
|
|
it("takes a seat back from the clockwork", () => {
|
|
const { room } = playing();
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
|
if ("error" in claimed) throw new Error(claimed.error);
|
|
expect(room.bots.has("Bo")).toBe(false);
|
|
expect(claimed.returned).toEqual({ type: "seatChanged", player: "Bo", to: "person" });
|
|
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
|
});
|
|
|
|
it("keeps the seat a person's again after a reload from the ledger", async () => {
|
|
const { room } = playing();
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
|
if ("error" in claimed) throw new Error(claimed.error);
|
|
vi.resetModules();
|
|
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
|
const again = reloaded.getRoom(room.id)!;
|
|
expect(again.bots.has("Bo")).toBe(false);
|
|
expect(again.events.filter((e) => e.type === "seatChanged").map((e) => (e as { to: string }).to)).toEqual(["clockwork", "person"]);
|
|
expect(reloaded.joinRoom(again, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
|
});
|
|
|
|
it("is the host's to make, and only for a person's seat", () => {
|
|
const { room } = playing();
|
|
expect(rooms.makeSeatPhrase(room, "Bo", "Cy")).toEqual({ error: "only the host may make a phrase for another seat" });
|
|
expect(rooms.makeSeatPhrase(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
|
|
const { room: withBot } = rooms.createRoom("Ann");
|
|
const bot = rooms.addAutomaton(withBot);
|
|
if ("error" in bot) throw new Error(bot.error);
|
|
expect(rooms.makeSeatPhrase(withBot, "Ann", bot.name)).toEqual({ error: "no person sits in that seat" });
|
|
});
|
|
|
|
it("works once", () => {
|
|
const { room } = playing();
|
|
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Cy"));
|
|
expect("error" in rooms.claimTransferCode(code)).toBe(false);
|
|
expect(rooms.claimTransferCode(code)).toEqual({ error: "that transfer phrase is unknown or has expired" });
|
|
});
|
|
|
|
it("is voided when the host gives the seat to the clockwork after making it", () => {
|
|
const { room } = playing();
|
|
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"));
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
expect("error" in rooms.claimTransferCode(code)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("a player's own transfer phrase", () => {
|
|
it("still hands over the same token, without re-seating", () => {
|
|
const { room, bo } = playing();
|
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeTransferCode(room, "Bo", bo)));
|
|
expect(claimed).toEqual({ roomId: room.id, name: "Bo", token: bo, reseated: false });
|
|
});
|
|
});
|