Phase 3: hotseat play — the whole game in one browser
Hotseat runs the engine entirely client-side; no server is involved. The lobby takes comma-separated names (2-6 wizards), and the game plays through the same table UI, with one addition: a full-screen hand-off card between actors — "pass the device to Morgana, tap when only they can see the screen" — shown whenever the needed input moves to another wizard (turns, counteractions, forced discards, interrupts). Each player sees only their own hand while seated. The game saves itself to localStorage after every command (config + command log, replayed on resume — the server's own determinism trick), so "set the game aside" keeps it and "abandon game" forgets it; a Resume Hotseat button appears whenever a save exists. Fixed en route: the engine's structuredClone cannot digest Svelte's reactive proxies, so hotseat snapshots state before every engine call. Verified live: 3-player game started, reloaded, resumed, turn ended, device handed to the next wizard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
85b40be113
commit
9323399b60
+107
-32
@@ -3,6 +3,7 @@
|
||||
import Board from "./Board.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Help from "./Help.svelte";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
|
||||
@@ -13,6 +14,7 @@
|
||||
let joinCode = $state("");
|
||||
let claimPhrase = $state("");
|
||||
let showHelp = $state(false);
|
||||
let hotseatNames = $state("");
|
||||
let drawCount = $state(2);
|
||||
let withExpansion = $state(true);
|
||||
/** Your creature selected for movement/attacks. */
|
||||
@@ -45,7 +47,7 @@
|
||||
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
|
||||
let discardMode = $state(false);
|
||||
|
||||
const view = $derived(net.view);
|
||||
const view = $derived(local.active ? local.view : net.view);
|
||||
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
|
||||
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
||||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||
@@ -75,6 +77,11 @@
|
||||
);
|
||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||||
|
||||
function dispatch(command: Parameters<typeof net.command>[0]) {
|
||||
if (local.active) local.command(command);
|
||||
else net.command(command);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
trapCells = [];
|
||||
tradeFrom = null;
|
||||
@@ -112,7 +119,7 @@
|
||||
function selectCard(card: CardInstance) {
|
||||
if (!view) return;
|
||||
if (youMustRespond) {
|
||||
net.command({ type: "counteract", instanceId: card.instanceId });
|
||||
dispatch({ type: "counteract", instanceId: card.instanceId });
|
||||
return;
|
||||
}
|
||||
if (youMustDiscard || discardMode || discardSelection.size > 0) {
|
||||
@@ -163,7 +170,7 @@
|
||||
const add = view.yourHand.find((c) => c.cardId === "add");
|
||||
if (add) cmd.addInstanceId = add.instanceId;
|
||||
}
|
||||
net.command(cmd);
|
||||
dispatch(cmd);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@@ -180,13 +187,13 @@
|
||||
if (!selectedCard) return;
|
||||
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
|
||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
net.command(cmd);
|
||||
dispatch(cmd);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function castPowerRun() {
|
||||
if (!selectedCard) return;
|
||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
||||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@@ -194,7 +201,7 @@
|
||||
if (!view || !isYourTurn) return;
|
||||
if (pendingCellFor && selectedCard) {
|
||||
// Stage 2 of teleport-opponent: destination chosen.
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "player", playerId: pendingCellFor },
|
||||
params: { cell },
|
||||
@@ -207,7 +214,7 @@
|
||||
pendingSectorFrom = cell; // first click: the sector to move
|
||||
return;
|
||||
}
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
|
||||
});
|
||||
@@ -217,14 +224,14 @@
|
||||
if (selectedCard?.cardId === "boobytrap") {
|
||||
trapCells = [...trapCells, cell];
|
||||
if (trapCells.length === 4) {
|
||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
||||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
||||
clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
||||
if (!tradeFrom) { tradeFrom = cell; return; }
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
||||
});
|
||||
@@ -232,7 +239,7 @@
|
||||
return;
|
||||
}
|
||||
if (selectedCard?.cardId === "rotate-sector") {
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
|
||||
});
|
||||
@@ -240,7 +247,7 @@
|
||||
return;
|
||||
}
|
||||
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell },
|
||||
});
|
||||
@@ -254,7 +261,7 @@
|
||||
const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (cellKey(n) === cellKey(cell)) {
|
||||
net.command({ type: "moveCreature", creatureId: selectedCreature, direction: side });
|
||||
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: side });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -268,7 +275,7 @@
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view.board, me.position, side);
|
||||
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
||||
net.command({ type: "move", direction: side });
|
||||
dispatch({ type: "move", direction: side });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -278,7 +285,7 @@
|
||||
const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (cellKey(n) === cellKey(cell)) {
|
||||
net.command({ type: "move", direction: side });
|
||||
dispatch({ type: "move", direction: side });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -289,7 +296,7 @@
|
||||
// Standing on the opening: step through. Otherwise treat it as a cell
|
||||
// click on the opening square (e.g. to walk toward it).
|
||||
if (me.position.x === cell.x && me.position.y === cell.y) {
|
||||
net.command({ type: "move", direction: side });
|
||||
dispatch({ type: "move", direction: side });
|
||||
} else {
|
||||
clickCell(cell);
|
||||
}
|
||||
@@ -297,7 +304,7 @@
|
||||
|
||||
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
||||
if (!selectedCard || !edgeSelectMode) return;
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast",
|
||||
instanceId: selectedCard.instanceId,
|
||||
target: { kind: "edge", cell, side },
|
||||
@@ -310,7 +317,7 @@
|
||||
const creature = view.creatures.find((c) => c.id === creatureId);
|
||||
if (!creature) return;
|
||||
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
||||
net.command({
|
||||
dispatch({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "creature", creatureId },
|
||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||
@@ -320,7 +327,7 @@
|
||||
}
|
||||
if (selectedCreature && selectedCreature !== creatureId) {
|
||||
// Your selected creature attacks another creature in its square.
|
||||
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
|
||||
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
|
||||
selectedCreature = null;
|
||||
return;
|
||||
}
|
||||
@@ -333,7 +340,7 @@
|
||||
function clickPlayer(playerId: string) {
|
||||
if (!view || !isYourTurn) return;
|
||||
if (selectedCreature) {
|
||||
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
|
||||
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
|
||||
selectedCreature = null;
|
||||
return;
|
||||
}
|
||||
@@ -342,7 +349,7 @@
|
||||
const me = view.players.find((p) => p.id === view.you)!;
|
||||
const them = view.players.find((p) => p.id === playerId)!;
|
||||
if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
|
||||
net.command({ type: "punch", targetId: playerId });
|
||||
dispatch({ type: "punch", targetId: playerId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -365,24 +372,24 @@
|
||||
if (!id) return; // needs a card name typed first
|
||||
cmd.params = { cardId: id };
|
||||
}
|
||||
net.command(cmd);
|
||||
dispatch(cmd);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function doDiscard() {
|
||||
net.command({ type: "discard", instanceIds: [...discardSelection] });
|
||||
dispatch({ type: "discard", instanceIds: [...discardSelection] });
|
||||
discardSelection = new Set();
|
||||
discardMode = false;
|
||||
}
|
||||
|
||||
function endTurn() {
|
||||
clearSelection();
|
||||
net.command({ type: "endTurn", draw: drawCount });
|
||||
dispatch({ type: "endTurn", draw: drawCount });
|
||||
}
|
||||
|
||||
function pickUp() { net.command({ type: "pickUpTreasure" }); }
|
||||
function drop() { net.command({ type: "dropTreasure" }); }
|
||||
function pass() { net.command({ type: "pass" }); }
|
||||
function pickUp() { dispatch({ type: "pickUpTreasure" }); }
|
||||
function drop() { dispatch({ type: "dropTreasure" }); }
|
||||
function pass() { dispatch({ type: "pass" }); }
|
||||
|
||||
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
|
||||
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
||||
@@ -401,6 +408,7 @@
|
||||
let chronicleEl = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
void net.log.length;
|
||||
void local.log.length;
|
||||
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
||||
});
|
||||
|
||||
@@ -450,7 +458,11 @@
|
||||
<header class="masthead">
|
||||
<span class="mast-title">Wiz-War</span>
|
||||
<span class="mast-sub">sixth edition</span>
|
||||
{#if net.roomId}
|
||||
{#if local.active}
|
||||
<span class="mast-room">hotseat</span>
|
||||
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
|
||||
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
|
||||
{:else if net.roomId}
|
||||
<span class="mast-room">room <b>{net.roomId}</b></span>
|
||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
|
||||
@@ -479,7 +491,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !net.roomId}
|
||||
{#if local.handoffTo}
|
||||
<div class="scrim-handoff" role="button" tabindex="0"
|
||||
onclick={() => local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}>
|
||||
<div class="handoff-card">
|
||||
<div class="handoff-eyebrow">pass the device to</div>
|
||||
<div class="handoff-name">{local.handoffTo}</div>
|
||||
<div class="handoff-hint">tap when only they can see the screen</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !net.roomId && !local.active}
|
||||
<section class="boxlid">
|
||||
<div class="boxlid-inner">
|
||||
<div class="boxlid-title">Wiz-War</div>
|
||||
@@ -499,6 +522,24 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hotseat-row">
|
||||
<input class="claim-input wide" bind:value={hotseatNames}
|
||||
placeholder="hotseat: names, comma-separated" aria-label="hotseat player names" />
|
||||
<button class="stamp tiny" disabled={hotseatNames.split(",").filter((n) => n.trim()).length < 2}
|
||||
onclick={() => {
|
||||
const err = local.start(hotseatNames.split(","), withExpansion);
|
||||
if (err) net.error = err;
|
||||
}}>
|
||||
Play here
|
||||
</button>
|
||||
{#if local.hasSave()}
|
||||
<button class="stamp tiny" onclick={() => local.resume()}>Resume hotseat</button>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="check hotseat-exp">
|
||||
<input type="checkbox" bind:checked={withExpansion} /> with Expansion Set #1
|
||||
</label>
|
||||
|
||||
<div class="claim-row">
|
||||
<input class="claim-input" bind:value={claimPhrase}
|
||||
placeholder="ember-troll-dagger" aria-label="seat transfer phrase" />
|
||||
@@ -545,7 +586,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if !net.started}
|
||||
{:else if !local.active && !net.started}
|
||||
<section class="boxlid">
|
||||
<div class="boxlid-inner">
|
||||
<div class="boxlid-title small">Room {net.roomId}</div>
|
||||
@@ -630,7 +671,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
||||
{#each net.log.slice(-60) as line, i (i)}
|
||||
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
|
||||
<div>{line}</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -679,7 +720,7 @@
|
||||
<button class="stamp tiny" onclick={() => {
|
||||
const id = nameToCardId(nameInput);
|
||||
if (id && selectedCard) {
|
||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
|
||||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
|
||||
clearSelection();
|
||||
}
|
||||
}}>Retrieve</button>
|
||||
@@ -712,7 +753,7 @@
|
||||
|
||||
<div class="actions">
|
||||
{#if isYourTurn && onWarpToken}
|
||||
<button class="stamp" onclick={() => net.command({ type: "warpStep" })}>Step through the warp</button>
|
||||
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
|
||||
{/if}
|
||||
{#if isYourTurn}
|
||||
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
|
||||
@@ -806,6 +847,40 @@
|
||||
}
|
||||
.mast-leave:hover { color: #d8d2c0; }
|
||||
.mast-help-solo { margin-left: auto; }
|
||||
.claim-input.wide { width: 17rem; }
|
||||
.hotseat-row { display: flex; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
|
||||
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
|
||||
|
||||
.scrim-handoff {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #14161b;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 60;
|
||||
cursor: pointer;
|
||||
}
|
||||
.handoff-card {
|
||||
background: #e9e1cb;
|
||||
color: #43331f;
|
||||
border-radius: 8px;
|
||||
padding: 2.4rem 3.2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.handoff-eyebrow {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #6b5a41;
|
||||
}
|
||||
.handoff-name {
|
||||
font-family: "Caveat", cursive;
|
||||
font-size: 3rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; }
|
||||
|
||||
.toast {
|
||||
background: #6d2119;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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 } from "./net.svelte";
|
||||
|
||||
const SAVE_KEY = "wizwar-hotseat";
|
||||
|
||||
interface SavedHotseat {
|
||||
config: GameConfig;
|
||||
commands: { playerId: PlayerId; command: Command }[];
|
||||
}
|
||||
|
||||
/** 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);
|
||||
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 }[] = [];
|
||||
|
||||
hasSave(): boolean {
|
||||
return localStorage.getItem(SAVE_KEY) !== null;
|
||||
}
|
||||
|
||||
start(names: PlayerId[], expansion: boolean): 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"],
|
||||
};
|
||||
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.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.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 }];
|
||||
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 } satisfies SavedHotseat),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const local = new LocalGame();
|
||||
@@ -5,7 +5,7 @@ import { cardDef } from "@wizwar/engine";
|
||||
|
||||
const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787";
|
||||
|
||||
function humanize(e: GameEvent): string | null {
|
||||
export function humanize(e: GameEvent): string | null {
|
||||
switch (e.type) {
|
||||
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
|
||||
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
||||
|
||||
Reference in New Issue
Block a user