Files
wizwar6e/packages/web/src/App.svelte
T
Eric WagonerandClaude Fable 5 9323399b60 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>
2026-08-16 00:13:59 -04:00

1215 lines
44 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { net } from "./net.svelte";
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";
net.connect();
net.startGamePolling();
let name = $state("");
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. */
let selectedCreature = $state<string | null>(null);
/** Card selected in hand, pending a target. */
let selectedCard = $state<CardInstance | null>(null);
/** Number card attached to the pending cast. */
let attachedNumber = $state<CardInstance | null>(null);
/** Waterbolt split. */
let wbDamage = $state(0);
/** teleport-opponent: player chosen, waiting for the destination cell. */
let pendingCellFor = $state<string | null>(null);
/** card-erasure / drop-object: the named card. */
let nameInput = $state("");
/** power-run points. */
let runPoints = $state(1);
/** Modifier cards attached to the pending cast. */
let attachedMods = $state<CardInstance[]>([]);
/** relocate-sector: the sector picked up, awaiting its destination. */
let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
/** boobytrap: cells picked so far (first is the real one). */
let trapCells = $state<{ x: number; y: number }[]>([]);
/** trader: first item square picked. */
let tradeFrom = $state<{ x: number; y: number } | null>(null);
/** rotate-sector direction. */
let rotateCW = $state(true);
/** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set());
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
let discardMode = $state(false);
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);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const EDGE_CARDS = new Set([
"create-wall", "destroy-wall", "wall-of-fire", "waterwall",
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
"warp-wand", "create-door", "stone-to-water",
]);
const CELL_CARDS = new Set([
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
"rotate-sector", "relocate-sector",
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
"killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
"handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
"dimensional-warp", "redirection", "thumb-of-god",
]);
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
const cellSelectMode = $derived(
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
);
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;
selectedCreature = null;
selectedCard = null;
attachedNumber = null;
attachedMods = [];
wbDamage = 0;
pendingCellFor = null;
pendingSectorFrom = null;
nameInput = "";
}
/** Fill the modifier fields of a cast command from the attachments. */
function applyMods(cmd: Parameters<typeof net.command>[0] & { type: "cast" }) {
for (const m of attachedMods) {
if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId);
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
}
}
/** Map a typed card name to its id (case-insensitive). */
function nameToCardId(name: string): string | null {
const wanted = name.trim().toLowerCase();
if (!wanted) return null;
if (wanted === "treasure") return "treasure";
for (const def of allCardDefs()) {
if (def.name.toLowerCase() === wanted || def.id === wanted) return def.id;
}
return null;
}
function selectCard(card: CardInstance) {
if (!view) return;
if (youMustRespond) {
dispatch({ type: "counteract", instanceId: card.instanceId });
return;
}
if (youMustDiscard || discardMode || discardSelection.size > 0) {
const next = new Set(discardSelection);
next.has(card.instanceId) ? next.delete(card.instanceId) : next.add(card.instanceId);
discardSelection = next;
return;
}
if (!isYourTurn) return;
if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
wbDamage = numberTotal;
return;
}
if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) {
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
: [...attachedMods, card];
return;
}
if (selectedCard?.instanceId === card.instanceId) {
clearSelection();
return;
}
selectedCard = card;
attachedNumber = null;
// Nothing casts on first click: every play is confirmed from the hint
// bar or by choosing a target. Misclicks cost nothing.
}
/** Untargeted spells confirmed from the hint bar (with optional number). */
const CONFIRM_CAST = new Set([
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
"gift-from-above", "chaos", "interrupt", "opportunity-fire",
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
]);
function playNumberForMovement() {
if (!selectedCard || !view) return;
const cmd: Parameters<typeof net.command>[0] = {
type: "playNumberForMovement",
instanceId: selectedCard.instanceId,
};
// A second movement number needs an ADD — spend one from hand automatically.
if (view.turn.numberPlayedForMovement) {
const add = view.yourHand.find((c) => c.cardId === "add");
if (add) cmd.addInstanceId = add.instanceId;
}
dispatch(cmd);
clearSelection();
}
function startDiscardMode() {
clearSelection();
discardMode = true;
}
function cancelDiscardMode() {
discardMode = false;
discardSelection = new Set();
}
function castSelfWithNumber() {
if (!selectedCard) return;
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
dispatch(cmd);
clearSelection();
}
function castPowerRun() {
if (!selectedCard) return;
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
clearSelection();
}
function clickCell(cell: { x: number; y: number }) {
if (!view || !isYourTurn) return;
if (pendingCellFor && selectedCard) {
// Stage 2 of teleport-opponent: destination chosen.
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "player", playerId: pendingCellFor },
params: { cell },
});
clearSelection();
return;
}
if (selectedCard?.cardId === "relocate-sector") {
if (!pendingSectorFrom) {
pendingSectorFrom = cell; // first click: the sector to move
return;
}
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
});
clearSelection();
return;
}
if (selectedCard?.cardId === "boobytrap") {
trapCells = [...trapCells, cell];
if (trapCells.length === 4) {
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
clearSelection();
}
return;
}
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
if (!tradeFrom) { tradeFrom = cell; return; }
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { cell: tradeFrom },
});
clearSelection();
return;
}
if (selectedCard?.cardId === "rotate-sector") {
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
});
clearSelection();
return;
}
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell },
});
clearSelection();
return;
}
if (selectedCreature) {
const creature = view.creatures.find((c) => c.id === selectedCreature);
if (creature) {
for (const side of SIDES) {
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)) {
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: side });
return;
}
}
}
selectedCreature = null;
return;
}
const me = view.players.find((p) => p.id === view.you)!;
// A cell click is a move if the cell is one legal step away (the server
// also lets doors/walls pass when unlocked/misted — try the direction).
for (const side of SIDES) {
const t = stepTarget(view.board, me.position, side);
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
dispatch({ type: "move", direction: side });
return;
}
}
// Adjacent but blocked? Send the move anyway — unlocked doors, mist-body
// and pass-through-wall are resolved server-side.
for (const side of SIDES) {
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)) {
dispatch({ type: "move", direction: side });
return;
}
}
}
function clickWarp(cell: { x: number; y: number }, side: Side) {
if (!view || !isYourTurn || !me) return;
// 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) {
dispatch({ type: "move", direction: side });
} else {
clickCell(cell);
}
}
function clickEdge(cell: { x: number; y: number }, side: Side) {
if (!selectedCard || !edgeSelectMode) return;
dispatch({
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side },
});
clearSelection();
}
function clickCreature(creatureId: string) {
if (!view || !isYourTurn) return;
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")) {
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "creature", creatureId },
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
return;
}
if (selectedCreature && selectedCreature !== creatureId) {
// Your selected creature attacks another creature in its square.
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
selectedCreature = null;
return;
}
const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
if (mine) {
selectedCreature = selectedCreature === creatureId ? null : creatureId;
}
}
function clickPlayer(playerId: string) {
if (!view || !isYourTurn) return;
if (selectedCreature) {
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
selectedCreature = null;
return;
}
if (!selectedCard) {
// No card selected: same-square click = punch.
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)) {
dispatch({ type: "punch", targetId: playerId });
}
return;
}
if (selectedCard.cardId === "teleport-opponent" || selectedCard.cardId === "shift-wand") {
pendingCellFor = playerId;
return; // next: click the destination cell
}
const cmd: Parameters<typeof net.command>[0] = {
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "player", playerId },
};
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
applyMods(cmd);
if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
}
if (NAMED_CARDS.has(selectedCard.cardId)) {
const id = nameToCardId(nameInput);
if (!id) return; // needs a card name typed first
cmd.params = { cardId: id };
}
dispatch(cmd);
clearSelection();
}
function doDiscard() {
dispatch({ type: "discard", instanceIds: [...discardSelection] });
discardSelection = new Set();
discardMode = false;
}
function endTurn() {
clearSelection();
dispatch({ type: "endTurn", draw: drawCount });
}
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);
const treasureHere = $derived(
view != null && me != null &&
view.treasures.some((t) => t.position && t.position.x === me.position.x &&
t.position.y === me.position.y && !t.carriedBy),
);
const onWarpToken = $derived(
view != null && me != null &&
view.dimWarps.some((w) =>
(w.a.x === me.position.x && w.a.y === me.position.y) ||
(w.b.x === me.position.x && w.b.y === me.position.y)),
);
let chronicleEl = $state<HTMLElement | null>(null);
$effect(() => {
void net.log.length;
void local.log.length;
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
});
// Tab title + favicon carry the turn signal even from another tab.
const anyTurnWaiting = $derived(
(view != null && view.phase === "playing" &&
(isYourTurn || youMustRespond || youMustDiscard ||
view.outOfTurnWindow?.playerId === view.you)) ||
net.games.some((g) => g.yourTurn && g.roomId !== net.roomId),
);
const FAVICON_IDLE =
"data:image/svg+xml," + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%23171a20"/><text x="16" y="23" font-family="Georgia" font-size="19" font-weight="bold" fill="%23e9e1cb" text-anchor="middle">W</text></svg>`.replaceAll("%23", "#"),
);
const FAVICON_TURN =
"data:image/svg+xml," + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%23171a20"/><text x="16" y="23" font-family="Georgia" font-size="19" font-weight="bold" fill="%23e9e1cb" text-anchor="middle">W</text><circle cx="25" cy="7" r="6" fill="%232e7d32"/></svg>`.replaceAll("%23", "#"),
);
$effect(() => {
document.title = anyTurnWaiting ? "● Your turn — Wiz-War" : "Wiz-War";
let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = anyTurnWaiting ? FAVICON_TURN : FAVICON_IDLE;
});
function timeAgo(iso: string | null): string {
if (!iso) return "no moves yet";
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (s < 90) return "moments ago";
if (s < 3600) return `${Math.round(s / 60)} min ago`;
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
return `${Math.round(s / 86400)} d ago`;
}
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
function playerColor(id: string): string {
const idx = view?.players.findIndex((p) => p.id === id) ?? 0;
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
}
</script>
<div class="table">
<header class="masthead">
<span class="mast-title">Wiz-War</span>
<span class="mast-sub">sixth edition</span>
{#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>
{/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => (showHelp = true)}>
help &amp; rules
</button>
<span class="mast-status" class:offline={net.status !== "connected"}>
{net.status === "connected" ? "" : "reconnecting…"}
</span>
</header>
{#if showHelp}
<Help onclose={() => (showHelp = false)} />
{/if}
{#if net.error}
<div class="toast" role="alert">{net.error}</div>
{/if}
{#if net.transferCode}
<div class="slip transfer-slip">
Speak this phrase into your other device (good for 10 minutes, one use):
<strong class="transfer-phrase">{net.transferCode.code}</strong>
<button class="hint-cancel" onclick={() => (net.transferCode = null)}>dismiss</button>
</div>
{/if}
{#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>
<div class="boxlid-tag">A game of magical combat in a stone labyrinth</div>
<label class="field">
<span>Your wizard's name</span>
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
</label>
<div class="boxlid-actions">
<button class="stamp" disabled={!name.trim()} onclick={() => net.create(name)}>
Create a game
</button>
<span class="or">or join one</span>
<input class="code" bind:value={joinCode} maxlength="4" placeholder="CODE" />
<button class="stamp" disabled={!name.trim() || !joinCode.trim()} onclick={() => net.join(joinCode, name)}>
Join
</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" />
<button class="stamp tiny" disabled={!claimPhrase.trim()}
onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
Claim a transferred seat
</button>
</div>
{#if net.seats.length > 0}
<div class="ledger">
<div class="ledger-head">
<span>your games</span>
{#if !net.notificationsEnabled}
<button class="hint-cancel" onclick={() => net.enableNotifications()}>
notify me on my turn
</button>
{/if}
</div>
{#each net.seats as seat (seat.roomId + seat.name)}
{@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)}
<div class="ledger-row" class:your-turn={g?.yourTurn}>
<button class="ledger-resume" onclick={() => net.resume(seat)}>
<span class="ledger-code">{seat.roomId}</span>
<span class="ledger-info">
{#if !g}
as {seat.name} — unreachable
{:else if g.finished}
{g.winner === seat.name ? "you won! 🏆" : `${g.winner} won`}
{:else if !g.started}
waiting to start · {g.players.join(", ")}
{:else if g.yourTurn}
YOUR TURN · round {g.round} · {timeAgo(g.lastMoveAt)}
{:else}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}
{/if}
</span>
</button>
<button class="ledger-forget" title="forget this game"
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
</div>
{/each}
</div>
{/if}
</div>
</section>
{:else if !local.active && !net.started}
<section class="boxlid">
<div class="boxlid-inner">
<div class="boxlid-title small">Room {net.roomId}</div>
<div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div>
<ul class="roster">
{#each net.players as p (p)}
<li><span class="dot" style:background={playerColor(p)}></span>{p}{p === net.hostId ? " — host" : ""}</li>
{/each}
</ul>
{#if net.you === net.hostId}
<label class="check">
<input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands
</label>
<button
class="stamp big"
disabled={net.players.length < 2 || net.players.length > 6}
onclick={() => net.start(withExpansion)}
>
Flip the boards ({net.players.length} wizard{net.players.length === 1 ? "" : "s"})
</button>
{:else}
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
{/if}
</div>
</section>
{:else if view}
<div class="game">
<section class="board-zone">
<Board
{view}
edgeSelectMode={edgeSelectMode && isYourTurn}
selectedCreatureId={selectedCreature}
onCellClick={clickCell}
onEdgeClick={clickEdge}
onPlayerClick={clickPlayer}
onCreatureClick={clickCreature}
onWarpClick={clickWarp}
/>
</section>
<aside class="paper-rail">
{#if view.phase === "finished"}
<div class="slip winner">🏆 {view.winner} wins!</div>
{:else if youMustRespond}
<div class="slip urgent">
{#if view.stack?.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you
{#if view.stack.attackCard}with <strong>{cardDef(view.stack.attackCard.cardId).name}</strong>{:else}with a punch{/if}!
Play a counteraction, or
{:else}
Respond to the counteraction, or
{/if}
<button class="stamp tiny" onclick={pass}>let it resolve</button>
</div>
{:else if view.stack}
<div class="slip">Waiting on {view.stack.waitingOn}</div>
{:else if view.outOfTurnWindow?.playerId === view.you}
<div class="slip urgent">Your interruption — cast now, or <button class="stamp tiny" onclick={pass}>waste it</button></div>
{:else if isYourTurn}
<div class="slip yours">
Your turn — round {view.turn.round}.
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
</div>
{:else}
<div class="slip">{view.activePlayerId} is taking their turn…</div>
{/if}
<div class="scoresheet">
<div class="scoresheet-head">life-points</div>
{#each view.players as p (p.id)}
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
<span class="dot" style:background={playerColor(p.id)}></span>
<span class="score-name">{p.id}{p.id === view.you ? " (you)" : ""}</span>
<span class="score-life">{p.life}</span>
<span class="score-marks">
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if}
</span>
</div>
{/each}
<div class="deck-line">draw pile {view.deckCount} · discards {view.discardCount}</div>
</div>
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
<div>{line}</div>
{/each}
</div>
</aside>
</div>
<div class="table-edge">
{#if selectedDef || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip">
{#if youMustDiscard}
<span class="hint-alert">Hand over the limit — mark cards and discard.</span>
{:else if discardMode && discardSelection.size === 0}
<span>Mark the cards to throw away, then confirm.</span>
{/if}
{#if discardSelection.size > 0}
<button class="stamp tiny" onclick={doDiscard}>Discard {discardSelection.size} marked</button>
{/if}
{#if discardMode || (discardSelection.size > 0 && !youMustDiscard)}
<button class="hint-cancel" onclick={cancelDiscardMode}>never mind</button>
{/if}
{#if selectedDef}
<strong class="hint-name">{selectedDef.name}</strong>
{#if edgeSelectMode}<span>— click a wall line</span>{/if}
{#if selectedDef.cardType === "attack" && !edgeSelectMode && !cellSelectMode}
<span>— click a target{attachedNumber ? ` (powered by a ${numberTotal})` : " (tap a number card to power it)"}</span>
{/if}
{#if attachedMods.length > 0}
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
{/if}
{#if selectedCard?.cardId === "boobytrap"}
<span>— place 4 tokens ({trapCells.length}/4; the first is real)</span>
{/if}
{#if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
<span>{tradeFrom ? "— now the second square" : "— click the first square"}</span>
{/if}
{#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span>— click the sector</span>
{/if}
{#if selectedCard?.cardId === "relocate-sector"}
<span>{pendingSectorFrom ? "— now the destination area" : "— click the sector to move"}</span>
{/if}
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
<label class="inline">name <input class="text-input" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
{#if selectedCard.cardId === "deja-vu"}
<button class="stamp tiny" onclick={() => {
const id = nameToCardId(nameInput);
if (id && selectedCard) {
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
clearSelection();
}
}}>Retrieve</button>
{:else}
<span>— then click the target</span>
{/if}
{/if}
{#if selectedCard?.cardId === "waterbolt"}
<label class="inline">damage <input class="num-input" type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
<span>(knockback {numberTotal - wbDamage})</span>
{/if}
{#if selectedCard?.cardId === "power-run"}
<label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
<button class="stamp tiny" onclick={castPowerRun}>Run!</button>
{/if}
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>
Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
</button>
{/if}
{#if selectedCard && isNumberCard(selectedCard.cardId)}
<button class="stamp tiny" onclick={playNumberForMovement}>
Play for +{cardDef(selectedCard.cardId).value} movement{view.turn.numberPlayedForMovement ? " (uses your Add)" : ""}
</button>
{/if}
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
{/if}
</div>
{/if}
<div class="actions">
{#if isYourTurn && onWarpToken}
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
{/if}
{#if isYourTurn}
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
onclick={pickUp}>Pick up treasure</button>
<button class="stamp" disabled={!carryingTreasure} onclick={drop}>Drop treasure</button>
<button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
<label class="inline draw-pick">
draw
<select bind:value={drawCount}>
<option value={0}>0</option>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</label>
<button class="stamp primary" onclick={endTurn}>End turn</button>
{/if}
</div>
<div class="hand" aria-label="your hand">
{#each view.yourHand as card (card.instanceId)}
<Card
{card}
selected={selectedCard?.instanceId === card.instanceId}
attached={attachedNumber?.instanceId === card.instanceId ||
attachedMods.some((m) => m.instanceId === card.instanceId)}
marked={discardSelection.has(card.instanceId)}
displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false}
charges={view.wandCharges[card.instanceId] ?? null}
onclick={() => selectCard(card)}
/>
{/each}
</div>
</div>
{/if}
</div>
<style>
:global(html, body) {
margin: 0;
background: #171a20;
}
.table {
min-height: 100vh;
background:
radial-gradient(ellipse at 50% 0%, rgba(64, 74, 92, 0.25), transparent 55%),
repeating-linear-gradient(115deg, rgba(255,255,255,0.012) 0 3px, transparent 3px 7px),
#171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
display: flex;
flex-direction: column;
padding: 0 1.1rem 1.1rem;
box-sizing: border-box;
}
.masthead {
display: flex;
align-items: baseline;
gap: 0.7rem;
padding: 0.7rem 0.2rem 0.6rem;
border-bottom: 1px solid rgba(216, 210, 192, 0.15);
margin-bottom: 0.9rem;
}
.mast-title {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 1.35rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #e9e1cb;
}
.mast-sub {
font-family: "Oswald", sans-serif;
font-weight: 400;
font-size: 0.7rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #8d8672;
}
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-status { font-size: 0.8rem; color: #c98a2a; }
.mast-leave {
background: none;
border: none;
color: #8d8672;
font-family: "Archivo Narrow", sans-serif;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
}
.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;
color: #f2e6d8;
border: 1px solid #96382c;
padding: 0.5rem 0.85rem;
border-radius: 4px;
margin-bottom: 0.7rem;
font-size: 0.95rem;
}
.boxlid {
flex: 1;
display: grid;
place-items: center;
padding: 2rem 0;
}
.boxlid-inner {
background: #e9e1cb;
color: #43331f;
border-radius: 8px;
border: 1px solid #b3a687;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
padding: 2.2rem 2.6rem 2rem;
max-width: 26rem;
width: 100%;
text-align: center;
}
.boxlid-title {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 3rem;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.boxlid-title.small { font-size: 1.7rem; }
.boxlid-tag {
font-size: 0.95rem;
color: #6b5a41;
margin: 0.5rem 0 1.4rem;
}
.field { display: block; text-align: left; margin-bottom: 1rem; }
.field span {
display: block;
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b5a41;
margin-bottom: 0.3rem;
}
.field input, .code {
width: 100%;
box-sizing: border-box;
background: #f4eede;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.55rem 0.7rem;
font-family: inherit;
font-size: 1rem;
color: #43331f;
}
.boxlid-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; justify-content: center; }
.or { font-size: 0.85rem; color: #6b5a41; }
.code { width: 6.5rem; padding: 0.55rem 0.4rem; text-transform: uppercase; text-align: center; letter-spacing: 0.15em; }
.roster { list-style: none; padding: 0; margin: 0 0 1rem; text-align: left; }
.roster li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 1.05rem; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.waiting { color: #6b5a41; font-style: italic; }
.transfer-slip {
max-width: 30rem;
margin: 0 auto 0.7rem;
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.transfer-phrase {
font-family: "Courier Prime", monospace;
font-size: 1.05rem;
letter-spacing: 0.04em;
background: rgba(67, 51, 31, 0.12);
padding: 0.1rem 0.45rem;
border-radius: 3px;
user-select: all;
}
.claim-row {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
margin-top: 1.1rem;
}
.claim-input {
background: #f4eede;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.4rem 0.6rem;
font-family: "Courier Prime", monospace;
font-size: 0.9rem;
color: #43331f;
width: 13rem;
}
/* the games ledger */
.ledger {
margin-top: 1.6rem;
border-top: 1.5px solid #6b5a41;
padding-top: 0.5rem;
text-align: left;
}
.ledger-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-family: "Caveat", cursive;
font-size: 1.15rem;
color: #6b5a41;
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-turn { background: rgba(46, 125, 50, 0.14); }
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.7rem;
background: none;
border: none;
padding: 0.3rem 0.2rem;
cursor: pointer;
text-align: left;
color: #43331f;
font-family: "Archivo Narrow", sans-serif;
font-size: 0.95rem;
}
.ledger-resume:hover .ledger-code { text-decoration: underline; }
.ledger-code {
font-family: "Oswald", sans-serif;
font-weight: 600;
letter-spacing: 0.12em;
}
.ledger-row.your-turn .ledger-info { color: #1d5720; font-weight: 600; }
.ledger-info { color: #6b5a41; }
.ledger-forget {
background: none;
border: none;
color: #a4906c;
font-size: 1.05rem;
cursor: pointer;
padding: 0 0.3rem;
}
.ledger-forget:hover { color: #b3372b; }
.stamp {
font-family: "Oswald", sans-serif;
font-weight: 500;
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: #e9e1cb;
color: #43331f;
border: 1.5px solid #43331f;
border-radius: 3px;
padding: 0.42rem 0.9rem;
cursor: pointer;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
}
.stamp:hover:not(:disabled) { background: #f4eede; }
.stamp:active:not(:disabled) { transform: translate(1px, 1px); box-shadow: 1px 1px 0 rgba(0,0,0,0.35); }
.stamp:disabled { opacity: 0.45; cursor: default; }
.stamp.primary { background: #43331f; color: #e9e1cb; }
.stamp.primary:hover:not(:disabled) { background: #5a4429; }
.stamp.big { font-size: 0.95rem; padding: 0.6rem 1.3rem; }
.stamp.tiny { font-size: 0.62rem; padding: 0.22rem 0.5rem; box-shadow: 1px 1px 0 rgba(0,0,0,0.3); }
.stamp:focus-visible { outline: 2px solid #d8b23a; outline-offset: 2px; }
.game {
display: grid;
grid-template-columns: minmax(340px, 1fr) minmax(250px, 330px);
gap: 1.1rem;
flex: 1;
min-height: 0;
}
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
.board-zone :global(svg.board) {
max-height: calc(100vh - 21.5rem);
width: auto;
max-width: 100%;
}
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
.slip {
background: #e9e1cb;
color: #43331f;
border-radius: 3px;
padding: 0.55rem 0.8rem;
font-size: 0.95rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
transform: rotate(-0.4deg);
}
.slip.yours { border-left: 4px solid #2e7d32; }
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
.slip.winner {
font-family: "Oswald", sans-serif;
font-size: 1.25rem;
text-transform: uppercase;
letter-spacing: 0.06em;
border-left: 4px solid #c9a72a;
}
.scoresheet {
background: #f2ecdb;
color: #43331f;
border-radius: 2px;
padding: 0.6rem 0.8rem 0.5rem;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
background-image: repeating-linear-gradient(
transparent 0 1.55rem,
rgba(95, 74, 51, 0.18) 1.55rem calc(1.55rem + 1px)
);
}
.scoresheet-head {
font-family: "Caveat", cursive;
font-size: 1.05rem;
color: #6b5a41;
border-bottom: 1.5px solid #6b5a41;
margin-bottom: 0.2rem;
}
.score-row {
display: flex;
align-items: center;
gap: 0.5rem;
height: 1.55rem;
}
.score-row.active .score-name { font-weight: 700; }
.score-row.dead { opacity: 0.5; text-decoration: line-through; }
.score-name { font-family: "Caveat", cursive; font-size: 1.25rem; line-height: 1; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.score-life { font-family: "Caveat", cursive; font-size: 1.4rem; font-weight: 700; min-width: 1.6rem; text-align: right; }
.score-marks { font-size: 0.75rem; color: #6b5a41; }
.deck-line {
font-family: "Courier Prime", monospace;
font-size: 0.72rem;
color: #8a7a5e;
padding-top: 0.3rem;
}
.dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; border: 1px solid rgba(0,0,0,0.4); }
.chronicle {
flex: 1;
min-height: 8rem;
max-height: 40vh;
overflow-y: auto;
background: #efe8d4;
color: #3a2f1f;
font-family: "Courier Prime", monospace;
font-size: 0.74rem;
line-height: 1.5;
padding: 0.6rem 0.75rem;
border-radius: 2px;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
}
.chronicle div + div { margin-top: 0.05rem; }
.table-edge {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.hint-strip {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
background: rgba(233, 225, 203, 0.12);
border: 1px dashed rgba(233, 225, 203, 0.35);
border-radius: 4px;
padding: 0.45rem 0.7rem;
font-size: 0.9rem;
}
.hint-name { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.06em; color: #e9e1cb; }
.hint-mods { color: #9fca6a; }
.hint-alert { color: #e8a03c; }
.hint-cancel {
margin-left: auto;
background: none;
border: none;
color: #a49c86;
text-decoration: underline;
cursor: pointer;
font-size: 0.85rem;
}
.inline { display: inline-flex; align-items: center; gap: 0.35rem; }
.num-input { width: 3.2rem; }
.text-input { width: 9rem; }
.num-input, .text-input, .draw-pick select {
background: #e9e1cb;
border: 1px solid #43331f;
border-radius: 3px;
padding: 0.2rem 0.35rem;
font-family: inherit;
color: #43331f;
}
.actions { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; }
.draw-pick { color: #a49c86; font-size: 0.9rem; }
.hand {
display: flex;
gap: 0.45rem;
padding: 0.9rem 0.3rem 0.35rem;
overflow-x: auto;
overflow-y: visible;
align-items: flex-end;
}
@media (max-width: 900px) {
.game { grid-template-columns: 1fr; }
.paper-rail { order: 2; }
.chronicle { max-height: 22vh; }
}
</style>