Files
wizwar6e/packages/web/src/local.svelte.ts
T
Eric WagonerandClaude Fable 5 d1eace9530 Rules rev 4: a creature's blow can be counteracted
The WRAITH card assumes the window exists — "REFLECTIONs used on the
wraith's touch will damage the wraith" — but creature damage applied
instantly, so BLUNT had nothing to catch, as playtesting found. Under
revision 4 every creature blow against a wizard opens the same
counteraction stack a spell does: the wraith's entry touch, the
democratic monster's claw, and commanded troll/skeleton/shadow
attacks. The blow's damage rides the stack; BLUNT halves it (round
up); reflections work by name against creatures — half back for
REFLECTION, the whole blow for FULL REFLECTION — landing on the
creature, not its controller; FULL SHIELD correctly does nothing
("does not stop any physical attack"); the wraith's card theft is a
secondary effect that lands only if damage does. No aim-miss rolls
against invisible or shrunk defenders — the creature is already in
the square. The attack fanfare shows the creature's own card.

Earlier revisions keep the instant touch so every stored game — and
the live one that found this — replays unchanged.

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

234 lines
7.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,
} 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[]>([]);
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;
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: 4,
};
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();
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;
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();