Files
wizwar6e/packages/web/src/local.svelte.ts
T
Eric WagonerandClaude Fable 5 bca34ae9e4 Flourishes: the spells become visible (branch only — not for the droplet yet)
A cosmetic effects layer on the board svg, fed by the same event
stream that writes the log. Fireballs streak and burst; waterbolts
arc and splash; lightning jags and flickers; teleports shimmer at
both ends; walls rise and fall in dust; stopped attacks flash a
golden shield; hits ring red; misses whiff; other attack spells
sparkle at the caster. Successive visuals from one command stagger by
a beat, everything self-expires, reduced-motion hides the layer
whole, and nothing here touches game state — the effects are mapped
from events in fx.ts and drawn in Board.svelte, online and hotseat
alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:43:16 -04:00

279 lines
9.5 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. */
/** 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;
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: 14,
};
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) {
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.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) {
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();