Wording throughout the app, the server's messages and the engine's refusals trades its stacked dashes for sentences, while the maze still trusts you to honor its curses, the gallery still listens, and the clockwork still learns its best tricks from the humans at the table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
9.8 KiB
TypeScript
283 lines
9.8 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 { type LogLine, humanize, net } from "./net.svelte";
|
|
import { receiptFor } from "./receipt";
|
|
|
|
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<LogLine[]>([]);
|
|
/** The opening roll-off, shown once as the boards flip. */
|
|
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
|
/** Board flourishes: the app hooks in to animate command results. */
|
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
|
/** 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;
|
|
|
|
/** A line of the chronicle for an event, and the receipt under a resolved attack. */
|
|
private chronicle(e: GameEvent, batch: readonly GameEvent[]): void {
|
|
const line = humanize(e);
|
|
if (line) this.log = [...this.log, { text: line, turn: null, notable: false }];
|
|
const receipt = receiptFor(batch, e);
|
|
if (receipt) this.log = [...this.log, { text: receipt.title, turn: null, notable: false, receipt: receipt.lines }];
|
|
}
|
|
|
|
/** 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, { text: `\u{1F3B2} ${this.viewerId} rolls a ${roll}.`, turn: null, notable: false }];
|
|
}
|
|
|
|
/** 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 } : {}),
|
|
};
|
|
const { state, events } = createGame(config);
|
|
for (const e of events) {
|
|
if (e.type === "gameStarted") {
|
|
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
|
}
|
|
}
|
|
this.config = config;
|
|
this.commands = [];
|
|
this.gameState = state;
|
|
this.log = [];
|
|
for (const e of events) {
|
|
this.chronicle(e, events);
|
|
}
|
|
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) {
|
|
this.chronicle(e, events);
|
|
}
|
|
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) {
|
|
this.chronicle(e, result.events);
|
|
}
|
|
}
|
|
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, { text: `— ${result.error} —`, turn: null, notable: false }];
|
|
return;
|
|
}
|
|
this.onFx?.(result.events);
|
|
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) {
|
|
this.chronicle(e, result.events);
|
|
}
|
|
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();
|