Playtesting read the card right and the engine had it wrong. "Placing the A tokens on one set of exits & the B tokens on the other" means the two exits you mark BECOME a pair — and their former partners pair with each other — not that the two exits trade destinations. Under rev 9, click the exit and then the exit you want it to connect TO (the hint now says so); already-connected exits are refused. Earlier games keep the swap their logs were validated against. Test pins the full four-way rewiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
// Hotseat: the whole game runs in this browser — the engine is pure
|
|
// TypeScript, so no server is involved at all. The device is passed between
|
|
// players; a hand-off screen keeps hands private between seats. The game
|
|
// saves itself after every command (config + command log, replayed on
|
|
// resume — the same determinism trick the server uses).
|
|
|
|
import {
|
|
applyCommand,
|
|
createGame,
|
|
viewFor,
|
|
type Command,
|
|
type GameConfig,
|
|
type GameState,
|
|
type GameView,
|
|
type PlayerId,
|
|
type GameEvent,
|
|
redactEvent,
|
|
} from "@wizwar/engine";
|
|
import { humanize, net } from "./net.svelte";
|
|
|
|
const SAVE_KEY = "wizwar-hotseat";
|
|
|
|
interface SavedHotseat {
|
|
config: GameConfig;
|
|
commands: { playerId: PlayerId; command: Command }[];
|
|
/** Anonymous tally identity + table-time bookkeeping; absent in older saves. */
|
|
tallyId?: string;
|
|
activeMs?: number;
|
|
lastMoveAt?: number;
|
|
}
|
|
|
|
/** Whose input does the game need right now? */
|
|
function actorId(state: GameState): PlayerId {
|
|
return (
|
|
state.pendingDiscard ??
|
|
state.outOfTurnWindow?.playerId ??
|
|
state.stack?.waitingOn ??
|
|
state.players[state.turn.activeIndex]!.id
|
|
);
|
|
}
|
|
|
|
class LocalGame {
|
|
active = $state(false);
|
|
/** Hotseat setup round: players name themselves one by one. */
|
|
setup = $state<{ count: number; expansion: boolean; names: PlayerId[]; colors: number[] } | null>(null);
|
|
gameState = $state<GameState | null>(null);
|
|
viewerId = $state<PlayerId | null>(null);
|
|
/** Set while the device should be handed to the named player. */
|
|
handoffTo = $state<PlayerId | null>(null);
|
|
log = $state<string[]>([]);
|
|
/** The finished game as a reel, each step from its actor's own seat. */
|
|
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
|
view = $derived(
|
|
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
|
);
|
|
|
|
private config: GameConfig | null = null;
|
|
private commands: { playerId: PlayerId; command: Command }[] = [];
|
|
private tallyId: string | null = null;
|
|
private activeMs = 0;
|
|
private lastMoveAt = 0;
|
|
|
|
/** The tabletop D4 for house calls: chronicle only, no game state. */
|
|
rollTableDie(): void {
|
|
if (!this.viewerId) return;
|
|
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
|
|
this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`];
|
|
}
|
|
|
|
/** Rebuild the whole game as replay steps (finished games only). */
|
|
buildReplay(): void {
|
|
if (!this.config || this.gameState?.phase !== "finished") return;
|
|
const { state } = createGame(this.config);
|
|
let current = state;
|
|
const steps: { seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] = [];
|
|
this.commands.forEach((c, i) => {
|
|
const r = applyCommand(current, c.playerId, c.command);
|
|
if (!r.ok) return;
|
|
current = r.state;
|
|
steps.push({
|
|
seq: i,
|
|
actor: c.playerId,
|
|
events: r.events.map((e) => redactEvent(e, c.playerId)).filter((e): e is GameEvent => e !== null),
|
|
view: viewFor(current, c.playerId),
|
|
});
|
|
});
|
|
this.replaySteps = steps;
|
|
}
|
|
|
|
hasSave(): boolean {
|
|
return localStorage.getItem(SAVE_KEY) !== null;
|
|
}
|
|
|
|
beginSetup(count: number, expansion: boolean): void {
|
|
this.setup = { count, expansion, names: [], colors: [] };
|
|
}
|
|
|
|
cancelSetup(): void {
|
|
this.setup = null;
|
|
}
|
|
|
|
/** One wizard names themselves and picks a standee; the last starts the game. */
|
|
submitName(raw: string, colorIndex: number): string | null {
|
|
if (!this.setup) return "no setup in progress";
|
|
const name = raw.trim();
|
|
if (!name) return "every wizard needs a name";
|
|
if (this.setup.names.some((n) => n.toLowerCase() === name.toLowerCase())) {
|
|
return "that name is already taken at this table";
|
|
}
|
|
if (this.setup.colors.includes(colorIndex)) {
|
|
return "that wizard has already been claimed";
|
|
}
|
|
this.setup = {
|
|
...this.setup,
|
|
names: [...this.setup.names, name],
|
|
colors: [...this.setup.colors, colorIndex],
|
|
};
|
|
if (this.setup.names.length === this.setup.count) {
|
|
const { names, expansion, colors } = this.setup;
|
|
this.setup = null;
|
|
return this.start(names, expansion, colors);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
start(names: PlayerId[], expansion: boolean, colors?: number[]): string | null {
|
|
const cleaned = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
|
|
if (cleaned.length < 2 || cleaned.length > 6) return "two to six wizards, each with a name";
|
|
const seed = crypto.getRandomValues(new Uint32Array(1))[0]!;
|
|
const config: GameConfig = {
|
|
playerIds: cleaned,
|
|
seed,
|
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
|
...(colors ? { colors } : {}),
|
|
deckRev: 9,
|
|
};
|
|
const { state, events } = createGame(config);
|
|
this.config = config;
|
|
this.commands = [];
|
|
this.gameState = state;
|
|
this.log = [];
|
|
for (const e of events) {
|
|
const line = humanize(e);
|
|
if (line) this.log = [...this.log, line];
|
|
}
|
|
this.active = true;
|
|
this.viewerId = null;
|
|
this.handoffTo = actorId(state);
|
|
this.tallyId = crypto.randomUUID();
|
|
this.activeMs = 0;
|
|
this.lastMoveAt = Date.now();
|
|
// iOS evicts unprotected origin storage under pressure; ask for durability
|
|
// so a saved hotseat game survives more than a forced reload.
|
|
navigator.storage?.persist?.().catch(() => {});
|
|
net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length });
|
|
this.persist();
|
|
return null;
|
|
}
|
|
|
|
resume(): boolean {
|
|
const raw = localStorage.getItem(SAVE_KEY);
|
|
if (!raw) return false;
|
|
try {
|
|
const saved = JSON.parse(raw) as SavedHotseat;
|
|
const { state, events } = createGame(saved.config);
|
|
let current = state;
|
|
this.log = [];
|
|
for (const e of events) {
|
|
const line = humanize(e);
|
|
if (line) this.log = [...this.log, line];
|
|
}
|
|
for (const c of saved.commands) {
|
|
const result = applyCommand(current, c.playerId, c.command);
|
|
if (!result.ok) throw new Error(`replay failed: ${result.error}`);
|
|
current = result.state;
|
|
for (const e of result.events) {
|
|
const line = humanize(e);
|
|
if (line) this.log = [...this.log, line];
|
|
}
|
|
}
|
|
this.config = saved.config;
|
|
this.commands = saved.commands;
|
|
this.tallyId = saved.tallyId ?? crypto.randomUUID();
|
|
this.activeMs = saved.activeMs ?? 0;
|
|
this.lastMoveAt = saved.lastMoveAt ?? Date.now();
|
|
this.gameState = current;
|
|
this.active = true;
|
|
this.viewerId = null;
|
|
navigator.storage?.persist?.().catch(() => {});
|
|
this.handoffTo = actorId(current);
|
|
return true;
|
|
} catch (e) {
|
|
console.error("hotseat resume failed:", e);
|
|
localStorage.removeItem(SAVE_KEY);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** The named player takes the device: their hand becomes visible. */
|
|
takeSeat(): void {
|
|
if (!this.handoffTo) return;
|
|
this.viewerId = this.handoffTo;
|
|
this.handoffTo = null;
|
|
}
|
|
|
|
command(command: Command): void {
|
|
if (!this.gameState || !this.viewerId) return;
|
|
// The engine deep-clones with structuredClone, which cannot handle
|
|
// Svelte's reactive proxies — hand it a plain snapshot.
|
|
const plain = $state.snapshot(this.gameState) as GameState;
|
|
const result = applyCommand(plain, this.viewerId, command);
|
|
if (!result.ok) {
|
|
this.log = [...this.log, `— ${result.error} —`];
|
|
return;
|
|
}
|
|
this.gameState = result.state;
|
|
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
|
const now = Date.now();
|
|
if (this.lastMoveAt && now - this.lastMoveAt < 10 * 60 * 1000) this.activeMs += now - this.lastMoveAt;
|
|
this.lastMoveAt = now;
|
|
if (this.gameState.phase === "finished" && this.tallyId) {
|
|
net.reportHotseat({
|
|
id: this.tallyId, stage: "finished",
|
|
players: this.gameState.players.length,
|
|
commands: this.commands.length,
|
|
minutes: Math.round(this.activeMs / 60_000),
|
|
winReason: this.gameState.winReason ?? undefined,
|
|
});
|
|
}
|
|
for (const e of result.events) {
|
|
const line = humanize(e);
|
|
if (line) this.log = [...this.log, line];
|
|
}
|
|
this.persist();
|
|
if (this.gameState.phase === "playing") {
|
|
const next = actorId(this.gameState);
|
|
if (next !== this.viewerId) this.handoffTo = next;
|
|
}
|
|
}
|
|
|
|
/** End the hotseat session (the save survives unless the game is over). */
|
|
leave(): void {
|
|
if (this.gameState?.phase === "finished") localStorage.removeItem(SAVE_KEY);
|
|
this.active = false;
|
|
this.gameState = null;
|
|
this.viewerId = null;
|
|
this.handoffTo = null;
|
|
this.log = [];
|
|
}
|
|
|
|
abandon(): void {
|
|
localStorage.removeItem(SAVE_KEY);
|
|
this.leave();
|
|
}
|
|
|
|
private persist(): void {
|
|
if (!this.config) return;
|
|
localStorage.setItem(
|
|
SAVE_KEY,
|
|
JSON.stringify({
|
|
config: this.config, commands: this.commands,
|
|
tallyId: this.tallyId ?? undefined, activeMs: this.activeMs, lastMoveAt: this.lastMoveAt,
|
|
} satisfies SavedHotseat),
|
|
);
|
|
}
|
|
}
|
|
|
|
export const local = new LocalGame();
|