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 }); }); });