From 9323399b60ba0c6a1b8015aa717488d69951b9c4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 00:13:59 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203:=20hotseat=20play=20=E2=80=94=20the?= =?UTF-8?q?=20whole=20game=20in=20one=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/src/App.svelte | 139 +++++++++++++++++++------ packages/web/src/local.svelte.ts | 168 +++++++++++++++++++++++++++++++ packages/web/src/net.svelte.ts | 2 +- 3 files changed, 276 insertions(+), 33 deletions(-) create mode 100644 packages/web/src/local.svelte.ts diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index dced1a9..05463e2 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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[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[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(null); $effect(() => { void net.log.length; + void local.log.length; if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight; }); @@ -450,7 +458,11 @@
Wiz-War sixth edition - {#if net.roomId} + {#if local.active} + hotseat + + + {:else if net.roomId} room {net.roomId} @@ -479,7 +491,18 @@ {/if} - {#if !net.roomId} + {#if local.handoffTo} +
local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}> +
+
pass the device to
+
{local.handoffTo}
+
tap when only they can see the screen
+
+
+ {/if} + + {#if !net.roomId && !local.active}
Wiz-War
@@ -499,6 +522,24 @@
+
+ + + {#if local.hasSave()} + + {/if} +
+ +
@@ -545,7 +586,7 @@ {/if}
- {:else if !net.started} + {:else if !local.active && !net.started}
Room {net.roomId}
@@ -630,7 +671,7 @@
- {#each net.log.slice(-60) as line, i (i)} + {#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
{line}
{/each}
@@ -679,7 +720,7 @@ @@ -712,7 +753,7 @@
{#if isYourTurn && onWarpToken} - + {/if} {#if isYourTurn}