A seat lived in the browser's storage alone, and Safari clears that after a week away, or at a word from the person on another device. Now every seat taken is banked as well in an HttpOnly cookie the server writes, which such clearing leaves alone, and the wallet takes its seats back from the bank on arrival. For a browser that lost even the cookie, the ledger notes the address each seat was last used from, as a salted hash, at most once in ten minutes, and a name given in the lobby or from the gallery gets its seats back from the same address, with a new token; where the address differs, two people at the table share it, or the host gave the seat to the clockwork, the table is only counted, and the host's phrase or the keeper's link is the way back. A name refused as taken is looked for the same way before the refusal shows. The keeper's script finds a name across tables. The seat endpoints answer only this site's own pages, under a limit of their own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
190 lines
9.2 KiB
TypeScript
190 lines
9.2 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 });
|
|
});
|
|
});
|
|
|
|
describe("a seat a browser lost", () => {
|
|
it("notes the address a seat is used from, once per change and not on its heels, and gives the seat back to that address by name", () => {
|
|
const { room, bo } = playing();
|
|
rooms.noteSeen(room, "Bo", "home");
|
|
rooms.noteSeen(room, "Bo", "home");
|
|
rooms.noteSeen(room, "Bo", "office");
|
|
expect(room.addrs.get("Bo")).toBe("home");
|
|
expect(rooms.findSeats("bo", "away", room.id)).toEqual({ seated: [], elsewhere: 1 });
|
|
const found = rooms.findSeats("bo", "home", room.id);
|
|
expect(found.elsewhere).toBe(0);
|
|
expect(found.seated.map((s) => [s.roomId, s.name])).toEqual([[room.id, "Bo"]]);
|
|
expect(rooms.seatOfToken(room, found.seated[0]!.token)).toBe("Bo");
|
|
expect(rooms.seatOfToken(room, bo)).toBeNull();
|
|
// Given a new token, the seat is noted afresh at once.
|
|
rooms.noteSeen(room, "Bo", "office");
|
|
expect(room.addrs.get("Bo")).toBe("office");
|
|
});
|
|
|
|
it("only counts the table when another person at it shares the address, or the clockwork stands in, or the game is finished", () => {
|
|
const { room, ann, bo } = playing();
|
|
rooms.noteSeen(room, "Bo", "home");
|
|
rooms.noteSeen(room, "Ann", "home");
|
|
expect(rooms.findSeats("Bo", "home", room.id)).toEqual({ seated: [], elsewhere: 1 });
|
|
expect(rooms.findSeats("Dee", "home", room.id)).toEqual({ seated: [], elsewhere: 0 });
|
|
expect(rooms.seatOfToken(room, bo)).toBe("Bo");
|
|
rooms.noteSeen(room, "Ann", "office");
|
|
rooms.standIn(room, "Ann", "Bo");
|
|
expect(rooms.findSeats("Bo", "home", room.id)).toEqual({ seated: [], elsewhere: 1 });
|
|
expect(rooms.tablesForKeeper("bo").filter((t) => t.roomId === room.id)).toMatchObject([{ roomId: room.id, name: "Bo", standIn: true, players: ["Ann", "Cy"] }]);
|
|
expect(rooms.seatOfToken(room, ann)).toBe("Ann");
|
|
room.state!.phase = "finished";
|
|
expect(rooms.findSeats("Cy", "home", room.id)).toEqual({ seated: [], elsewhere: 0 });
|
|
});
|
|
});
|