Files
wizwar6e/packages/web/src/App.svelte
T
Eric WagonerandClaude Fable 5 cdeb3d5103 Idiot enforces its card (rev 23) + attack sight-line tracing
Rules rev 23 — IDIOT, per the card and FAQ:
- No handling items: pick up / drop of treasures and objects refused
  (dropping was the exploit: capturing a stolen treasure on your own home,
  or dropping your own treasure underfoot for an instant cure)
- No punching, no thrown dagger / large rock (attacks on players)
- No effect on a victim already carrying one of their own treasures
Ungated (permissive): counteractions are now castable while idiotized (the
one thing the card expressly allows — the gate wrongly blocked them), and
goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per
the FAQ's 'you could, however, destroy a wall'.

Sight tracing: while an LOS attack sits on the stack, the board draws the
line it traveled — straight when direct, leg by leg through both warp
mouths (with pulsing rings) when the maze's wraparound carried it. Engine
traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown
live and in replays. Verified against H3PC's cross-board Idiot cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:03:09 -04:00

2633 lines
104 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 { attentionLabel, net, spellName } from "./net.svelte";
import Board from "./Board.svelte";
import { PLAYER_COLORS, wizardColor } from "./colors";
import Faq from "./Faq.svelte";
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } 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);
/** The finished game whose victory fanfare has been dismissed. */
let victorySeen = $state(false);
/** The fireworks get the stage before the modal takes it. */
let victoryCurtain = $state(false);
$effect(() => {
if (view?.phase === "finished" && view.winner && !victoryCurtain) {
const t = setTimeout(() => (victoryCurtain = true), 2400);
return () => clearTimeout(t);
}
});
/** Quiet mode: skip the attack announcement modal (device preference). */
let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
function setFanfare(announce: boolean) {
noFanfare = !announce;
localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
}
/** Which pending attack the player has already acknowledged (modal dismissed). */
const fxWorkshop = new URLSearchParams(location.search).has("fx");
let attackNoticeSeen = $state<string | null>(null);
/** The interruption fanfare, dismissed once per window. */
let momentSeen = $state(false);
$effect(() => {
if (!view?.outOfTurnWindow) momentSeen = false;
});
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null);
function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return;
for (const e of events) {
if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
}
fxCancels.push(scheduleFx(
events, view,
(fx) => (boardFx = [...boardFx, fx]),
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
));
}
$effect(() => {
net.onFx = playFx;
local.onFx = playFx;
return () => {
net.onFx = null;
local.onFx = null;
fxCancels.forEach((c) => c());
fxCancels = [];
};
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() {
net.openingRolls = null;
local.openingRolls = null;
}
/** A face-up card being peeked at from the scoresheet (view-only). */
let peekCard = $state<CardInstance | null>(null);
/** When the peeked card is a creature on the board, its live stats ride along. */
let peekCreatureId = $state<string | null>(null);
/** The clicked token itself, enlarged beside its card. */
let peekToken = $state<string | null>(null);
/** Bare-knuckle demolition: click a wall to punch it. */
let punchWallMode = $state(false);
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
let botTier = $state("adept");
/** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */
let discardPeek = $state<CardInstance | null>(null);
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2);
let setupName = $state("");
let setupColor = $state(0);
/** Ambush arming flow: the Interrupt/OF card, trigger, and committed attack. */
let ambushVia = $state<CardInstance | null>(null);
let ambushTrigger = $state<"los" | "near" | "treasure" | null>(null);
let ambushSpell = $state<CardInstance | null>(null);
// Default each new wizard to the first unclaimed standee.
$effect(() => {
if (local.setup && local.setup.colors.includes(setupColor)) {
setupColor = [0, 1, 2, 3, 4, 5].find((c) => !local.setup!.colors.includes(c)) ?? 0;
}
});
/** Tap-to-inspect: hide the enlarged card for the current selection. */
let inspectorHidden = $state(false);
$effect(() => {
void selectedCard?.instanceId;
inspectorHidden = false;
});
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);
/** pick-lock / master-key: prop the door for others once it opens. */
let holdDoor = $state(false);
/** mega-monster: which stat the chosen monster doubles. */
let megaBoost = $state<"life" | "movement">("life");
/** teleport: the marked destination awaiting its confirming second tap. */
let pendingTeleport = $state<{ x: number; y: number } | null>(null);
/** 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.phase === "playing" && view.activePlayerId === view.you && !view.stack,
);
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
/** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment:
* casting and targeting open up as if the turn were yours. */
const yourMoment = $derived(
isYourTurn || (view != null && view.outOfTurnWindow?.playerId === view.you),
);
const attackNoticeKey = $derived(
youMustRespond && view?.stack
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}`
: null,
);
const attackingCreature = $derived(
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
);
const myShieldstoneDisplayed = $derived(
view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "shieldstone") ?? false,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
/** Picking up an object ends the turn's actions: the hand goes quiet until
* the draw — but never while a counter, shield, or discard is owed. */
const actionsSpent = $derived(
view != null && isYourTurn && view.turn.actionsEnded &&
!youMustRespond && !youMustShield && !youMustDiscard,
);
const commandedCreature = $derived(
selectedCreature && view
? (view.creatures.find((c) => c.id === selectedCreature) ?? null)
: null,
);
/** The 5x5 sector a picked-up square belongs to (its origin), or null. */
const pendingSectorOrigin = $derived.by(() => {
if (!view || !pendingSectorFrom) return null;
const p = view.board.placements.find(
(p) =>
pendingSectorFrom!.x >= p.origin.x && pendingSectorFrom!.x < p.origin.x + 5 &&
pendingSectorFrom!.y >= p.origin.y && pendingSectorFrom!.y < p.origin.y + 5,
);
return p ? p.origin : null;
});
/** Legal empty slots the lifted sector may land on — mirroring the
* engine's relocateSector checks: on the 5-grid, non-negative, vacant,
* and leaving every sector adjacent to at least one other. */
const relocateGhosts = $derived.by(() => {
if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
const origins = view.board.placements.map((p) => p.origin);
const idx = origins.findIndex(
(o) => o.x === pendingSectorOrigin!.x && o.y === pendingSectorOrigin!.y,
);
const adjacent = (a: { x: number; y: number }, b: { x: number; y: number }) =>
(Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
const seen = new Set<string>();
const out: { x: number; y: number }[] = [];
for (let i = 0; i < origins.length; i++) {
if (i === idx) continue;
for (const [dx, dy] of [[5, 0], [-5, 0], [0, 5], [0, -5]] as const) {
const c = { x: origins[i]!.x + dx, y: origins[i]!.y + dy };
const k = `${c.x},${c.y}`;
if (seen.has(k)) continue;
seen.add(k);
// Before rev 11 the grid stopped at zero; newer games renormalize.
if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
const next = origins.map((o, j) => (j === idx ? c : o));
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
}
}
return out;
});
function confirmTeleport() {
if (!selectedCard || !pendingTeleport) return;
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell: pendingTeleport },
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
}
function clickGhostSlot(origin: { x: number; y: number }) {
if (!selectedCard || !pendingSectorFrom) return;
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell: origin }, params: { cell: pendingSectorFrom },
});
clearSelection();
}
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
/** A live moment to interrupt: another wizard acts, no attack is pending. */
const canInterruptNow = $derived(
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
!view.outOfTurnWindow && view.phase === "playing" && view.turn.round > 1 && !local.active,
);
const interruptCards = $derived(
canInterruptNow
? view!.yourHand.filter((c) => c.cardId === "interrupt" || c.cardId === "opportunity-fire")
: [],
);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const WAND_IDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
/** What the edge click actually is, per card — "wall line" fits few. */
const EDGE_HINTS: Record<string, string> = {
"create-wall": "click an open corridor line to wall it",
"destroy-wall": "click a wall or door to destroy it",
"illusion-wall": "click an open corridor line for the false wall",
"wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door",
"master-key": "click a locked door",
"jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door",
"warp-wand": "click a wall to warp it open",
"stone-to-water": "click a stone wall, or a stone-filled square",
"dispel-creation": "click the creation — a square, a creature, or a conjured wall",
};
const EDGE_CARDS = new Set([
"create-wall", "destroy-wall", "illusion-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 attackVsWall = $derived(
selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
);
const edgeSelectMode = $derived(
(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)) || attackVsWall || punchWallMode,
);
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() {
punchWallMode = false;
ambushVia = null;
ambushTrigger = null;
ambushSpell = null;
trapCells = [];
tradeFrom = null;
selectedCreature = null;
selectedCard = null;
megaBoost = "life";
holdDoor = false;
pickChoice = false;
pendingTeleport = 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;
}
}
/** Suggest-as-you-type pool for the card-naming field, tuned per card. */
const nameSuggestions = $derived.by(() => {
if (!selectedCard) return [];
const pool = allCardDefs().filter(
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
);
let picks = pool;
if (selectedCard.cardId === "illusionary-attack") {
picks = pool.filter((d) => d.cardType === "attack");
} else if (selectedCard.cardId === "drop-object") {
picks = pool.filter((d) => isMovableObject(d.id));
}
const names = picks.map((d) => d.name).sort();
return selectedCard.cardId === "drop-object" ? ["Treasure", ...names] : names;
});
/** 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;
peekCard = null;
peekCreatureId = null;
peekToken = null;
if (youMustRespond || youMustShield) {
if (youMustRespond && card.cardId === "teleport") {
selectedCard = card; // then click the escape square
return;
}
// A number can ride a counter (INVISIBLE's duration) — unless a
// displayed Shieldstone makes the number itself the counteraction.
if (youMustRespond && isNumberCard(card.cardId) && !myShieldstoneDisplayed) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
return;
}
dispatch({
type: "counteract", instanceId: card.instanceId,
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
attachedNumber = null;
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 (actionsSpent) return; // picking up an object ended the turn's actions
if (!isYourTurn && !yourMoment) {
// Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending).
if (!view.stack && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
dispatch({ type: "cast", instanceId: card.instanceId });
}
return;
}
// On your own turn, Interrupt / Opportunity Fire arm an ambush instead.
if (card.cardId === "interrupt" || card.cardId === "opportunity-fire") {
clearSelection();
ambushVia = card;
return;
}
if (ambushVia) {
if (isNumberCard(card.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
return;
}
if (cardDef(card.cardId).cardType === "attack") {
ambushSpell = ambushSpell?.instanceId === card.instanceId ? null : card;
return;
}
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 armAmbush() {
if (!ambushVia || !ambushTrigger || !ambushSpell) return;
dispatch({
type: "setAmbush",
instanceId: ambushVia.instanceId,
trigger: { kind: ambushTrigger },
spellInstanceId: ambushSpell.instanceId,
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
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) return;
if (youMustRespond && selectedCard?.cardId === "teleport") {
// Mark first, jump on the second tap: an escape spent on a misclick
// is an escape wasted.
if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
pendingTeleport = cell;
return;
}
dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
clearSelection();
return;
}
if (!yourMoment) { peekAt(cell); 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 === "teleport") {
// Teleport is one jump, not a walk: the first tap only marks the
// destination, the second (or the hint-bar button) commits it.
if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
pendingTeleport = cell;
return;
}
confirmTeleport();
return;
}
if (selectedCard?.cardId === "relocate-sector") {
// Any board click (re-)picks the sector; the landing is chosen from the
// dashed ghost slots beyond the maze, since every on-board slot is taken.
pendingSectorFrom = cell;
return;
}
if (selectedCard?.cardId === "boobytrap") {
// Tapping a placed token lifts it again; the four must be distinct.
const already = trapCells.findIndex((c) => cellKey(c) === cellKey(cell));
trapCells = already !== -1
? trapCells.filter((_, i) => i !== already)
: [...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 },
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
return;
}
const meNow = view.players.find((p) => p.id === view.you);
if (!selectedCard && meNow) {
const pos = meNow.position;
const pair = view.dimWarps.find((w) =>
(w.a.x === pos.x && w.a.y === pos.y) || (w.b.x === pos.x && w.b.y === pos.y));
if (pair) {
const dest = pair.a.x === pos.x && pair.a.y === pos.y ? pair.b : pair.a;
if (dest.x === cell.x && dest.y === cell.y) {
dispatch({ type: "warpStep" });
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;
}
}
// Standing on a board-edge opening: clicking the far mouth warps it.
const w = view.board.warps.find((w) =>
cellKey(w.from.cell) === cellKey(creature.position) && cellKey(w.to.cell) === cellKey(cell));
if (w) {
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.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;
}
}
// BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it.
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) {
for (const side of SIDES) {
const mid = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
const far = { x: me.position.x + (side === "E" ? 2 : side === "W" ? -2 : 0),
y: me.position.y + (side === "S" ? 2 : side === "N" ? -2 : 0) };
const hazard = view.squareContents[cellKey(mid)]?.kind;
if (cellKey(far) === cellKey(cell) &&
(hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
dispatch({ type: "move", direction: side, over: true });
return;
}
}
}
// The click claimed no action — offer the square's card instead.
peekAt(cell);
}
/** Card ids behind the terrain tokens, for tap-to-read. */
const CONTENT_CARD: Record<string, string> = {
stone: "fill-square-with-stone", thornbush: "thornbush", rosebush: "rosebush",
ooze: "killer-ooze", dust: "dust-cloud", slime: "fill-square-with-slime",
tacks: "handful-of-tacks", pit: "create-pit", safe: "safe",
};
/** Sustained ids include engine constructs the card library cannot show. */
function realCard(cardId: string): boolean {
try { cardDef(cardId); return true; } catch { return false; }
}
function creatureStats(c: NonNullable<GameView["creatures"]>[number]): string {
const hp = Number.isFinite(c.maxDamage)
? `${c.maxDamage - c.damage} of ${c.maxDamage} hits left`
: "unharmed by ordinary damage";
const moves = `${c.movesPerTurn - c.movementUsed} of ${c.movesPerTurn} moves`;
const atk = c.justCreated ? "no attack the turn it appears" : c.attackUsed ? "attack spent" : "attack ready";
return `${hp} · ${moves} · ${atk}`;
}
const peekNote = $derived.by(() => {
if (!view || !peekCreatureId) return null;
const c = view.creatures.find((c) => c.id === peekCreatureId);
return c ? creatureStats(c) : null;
});
/** Show the card behind whatever occupies this square (view-only). */
function peekAt(cell: { x: number; y: number }): boolean {
if (!view) return false;
const key = `${cell.x},${cell.y}`;
const creature = view.creatures.find((c) => c.position.x === cell.x && c.position.y === cell.y);
const content = view.squareContents[key];
const objects = view.groundObjects[key] ?? [];
const cardId =
creature ? creature.kind
: content ? CONTENT_CARD[content.kind]
: objects.length > 0 ? objects[objects.length - 1]!.cardId
: view.dimWarps.some((w) => (w.a.x === cell.x && w.a.y === cell.y) || (w.b.x === cell.x && w.b.y === cell.y)) ? "dimensional-warp"
: view.boobytraps.some((t) => t.cells.some((c) => c.x === cell.x && c.y === cell.y)) ? "boobytrap"
: null;
if (!cardId) return false;
peekCard = { instanceId: `peek-${cardId}`, cardId };
peekCreatureId = creature?.id ?? null;
peekToken =
creature && CREATURE_ART[creature.kind] ? tokenArt(CREATURE_ART[creature.kind]!, "creatures")
: content && TERRAIN_ART[content.kind] ? tokenArt(TERRAIN_ART[content.kind]!, "terrain")
: objects.length > 0 && objectArt(cardId) ? tokenArt(objectArt(cardId)!, "objects")
: cardId === "dimensional-warp" ? tokenArt("dimensional-warp", "terrain")
: cardId === "boobytrap" ? tokenArt("boobytrap", "terrain")
: null;
return true;
}
function clickWarp(cell: { x: number; y: number }, side: Side) {
if (!view || !isYourTurn || !me) return;
// Standing on the opening: step through — the selected creature first,
// your wizard otherwise. Elsewhere it is just a cell click.
const creature = selectedCreature ? view.creatures.find((c) => c.id === selectedCreature) : null;
if (creature && creature.position.x === cell.x && creature.position.y === cell.y) {
dispatch({ type: "moveCreature", creatureId: creature.id, direction: side });
} else 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 (punchWallMode) {
dispatch({ type: "punchWall", cell, side });
punchWallMode = false;
return;
}
if (!selectedCard || !edgeSelectMode) return;
// The attached number rides along for every edge cast: wand charges,
// wall-of-fire durations, wall attacks alike.
dispatch({
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side },
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
? { params: { hold: true } } : {}),
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
}
function clickCreature(creatureId: string) {
if (!view) return;
const creature = view.creatures.find((c) => c.id === creatureId);
if (!creature) return;
if (!yourMoment) { peekAt(creature.position); return; }
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
// Cell-target spells aimed at a creature (DISPEL CREATION on a wraith)
// land on its square — the engine finds the creature there.
clickCell(creature.position);
return;
}
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "creature", creatureId },
...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
...(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) {
// Commanding your own creature docks its card in the rail instead of
// opening the centered peek — a modal would block the very board
// squares you are about to march it across.
selectedCreature = selectedCreature !== creatureId ? creatureId : null;
} else {
peekAt(creature.position);
}
}
function clickPlayer(playerId: string) {
if (!view || !yourMoment) 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 });
}
let pickChoice = $state(false);
function pickUp() {
if (treasuresHere.length > 1) { pickChoice = true; return; }
dispatch({ type: "pickUpTreasure" });
}
function pickUpNamed(treasureId: string) {
pickChoice = false;
dispatch({ type: "pickUpTreasure", treasureId });
}
function drop() { dispatch({ type: "dropTreasure" }); }
function pass() { dispatch({ type: "pass" }); }
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
/** Two treasures can share a square: one dispatches, more ask whose. */
const treasuresHere = $derived(
view && me
? view.treasures.filter((t) => t.position && t.position.x === me.position.x &&
t.position.y === me.position.y && !t.carriedBy)
: [],
);
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),
);
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
// While an LOS attack sits on the stack, draw the line it traveled — the
// answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null);
const litCells = $derived.by(() => {
if (!view || !selectedDef || !yourMoment) return null;
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
if (!me) return null;
if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId);
}
if (selectedDef.adjacent === true) {
const { x, y } = me.position;
return new Set(
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
.map(([cx, cy]) => `${cx},${cy}`)
.filter((k) => view.board.cells[k]),
);
}
if (selectedDef.los !== true) return null;
const sighted = sightedCellsFor(view);
if (attachedMods.some((m) => m.cardId === "around-the-corner")) {
const widened = new Set(sighted);
for (const key of Object.keys(view.board.cells)) {
if (widened.has(key)) continue;
const [cx, cy] = key.split(",").map(Number);
if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) {
widened.add(key);
}
}
return widened;
}
return sighted;
});
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
const idleCreatures = $derived(
view != null && isYourTurn
? view.creatures.filter((c) =>
(c.controllerId === view!.you || c.kind === "democratic-monster") &&
(c.movementUsed < c.movesPerTurn || (!c.attackUsed && !c.justCreated)))
: [],
);
const objectsHere = $derived(
view != null && me != null
? (view.groundObjects[`${me.position.x},${me.position.y}`] ?? [])
: [],
);
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),
);
// The purple wizard from the physical counter sheet; a red badge marks your turn.
const FAVICON_IDLE = "/favicon.png";
const FAVICON_TURN = "/favicon-turn.png";
$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`;
}
function playerColor(id: string): string {
return view ? wizardColor(view, id) : PLAYER_COLORS[0]!;
}
</script>
{#if fxWorkshop}
<FxGallery />
{:else}
<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={() => { helpTab = "play"; showHelp = true; }}>
help &amp; rules
</button>
<span class="mast-status" class:offline={net.status !== "connected"}>
{net.status === "connected" ? "" : "reconnecting…"}
</span>
</header>
{#if showDiscards && view}
<div class="scrim attack-scrim" role="button" tabindex="-1" onclick={() => (showDiscards = false)} onkeydown={() => {}}>
<div class="discard-book" role="dialog" aria-label="the discard pile" tabindex="-1"
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
<header class="discard-head">
<span>The discard pile — {view.discardCount} card{view.discardCount === 1 ? "" : "s"}, newest first</span>
<button class="inspector-close discard-close" onclick={() => (showDiscards = false)} aria-label="close">×</button>
</header>
<div class="discard-grid">
{#each [...view.discardPile].reverse() as card (card.instanceId)}
<Card {card} onclick={() => (discardPeek = card)} onfaq={(id) => (faqCardId = id)} />
{/each}
{#if view.discardPile.length === 0}
<span class="discard-empty">Nothing has been cast away yet.</span>
{/if}
</div>
</div>
</div>
{/if}
{#if peekCard || discardPeek}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => { peekCard = null; peekCreatureId = null; peekToken = null; discardPeek = null; }} onkeydown={() => {}}>
<div class="big-peek">
{#if peekCard && peekToken}
<img class="big-peek-token" src={peekToken} alt="" />
{/if}
<div class="card-stage">
<Card card={(peekCard ?? discardPeek)!} onfaq={(id) => (faqCardId = id)} />
</div>
{#if peekNote}<div class="peek-note big-peek-note">{peekNote}</div>{/if}
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
{#if showHelp}
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
{/if}
{#if view?.phase === "finished" && view.winner && victoryCurtain && !victorySeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
<div class="attack-notice victory-notice">
<div class="victory-trophy">🏆</div>
<div class="victory-name">{view.winner}</div>
<div class="victory-how">
{#if view.winReason === "treasures"}
carried two stolen treasures home — the maze is theirs
{:else if view.winReason === "lastStanding"}
the last wizard standing
{:else}
wins the game
{/if}
</div>
<button class="stamp big" onclick={() => (victorySeen = true)}>Behold the final board</button>
</div>
</div>
{/if}
{#if trapNotice}
<div class="scrim attack-scrim" role="alertdialog" aria-label="a trap card">
<div class="attack-notice">
<div class="attack-headline">A cruel draw!</div>
<div class="attack-card"><Card card={{ instanceId: "trap-notice", cardId: trapNotice }} /></div>
<div class="attack-power">
{trapNotice === "gift-from-below"
? "It bites for 3 points, then the deck deals you another."
: "The trap snaps shut — you lose a turn."}
</div>
<button class="stamp big" onclick={() => (trapNotice = null)}>Curse the deck</button>
</div>
</div>
{/if}
{#if view && view.outOfTurnWindow?.playerId === view.you && !momentSeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="your interruption">
<div class="attack-notice">
<div class="attack-headline">The maze holds its breath</div>
<div class="attack-card"><Card card={{ instanceId: "moment", cardId: view.outOfTurnWindow.kind }} /></div>
<div class="attack-power">
Time parts for your {cardDef(view.outOfTurnWindow.kind).name}
pick an attack card and a target, or let the moment pass.
</div>
<button class="stamp big" onclick={() => (momentSeen = true)}>Seize it</button>
</div>
</div>
{/if}
{#if openingRolls}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the roll for first wizard">
<div class="attack-notice">
<div class="attack-headline">The dice decide who casts first</div>
<div class="rolloff">
{#each openingRolls.players as p (p)}
<div class="rolloff-row" class:rolloff-winner={p === openingRolls.first}>
<span class="rolloff-name">{p === openingRolls.first ? "🏆 " : ""}{p}</span>
<span class="rolloff-dice">
{#each openingRolls.rolls[p] ?? [] as r, i (i)}<span class="die-face">🎲{r}</span>{/each}
</span>
</div>
{/each}
</div>
{#if Object.values(openingRolls.rolls).some((r) => r.length > 1)}
<div class="rolloff-note">Tied wizards rolled again.</div>
{/if}
<button class="stamp big" onclick={dismissRolls}>{openingRolls.first} leads begin!</button>
</div>
</div>
{/if}
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey && !noFanfare}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice">
<div class="attack-headline">
{#if view.stack.defenderId === view.you && attackingCreature}
<strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
{:else if view.stack.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you!
{:else}
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
{/if}
</div>
{#if view.stack.defenderId === view.you && attackingCreature}
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
{@const illusionOf = view.stack.attackCard.cardId === "illusionary-attack"
? (view.stack.params?.cardId ?? null) : null}
{#if illusionOf}
<div class="attack-card illusion-pair">
<Card card={{ instanceId: "illusion-named", cardId: illusionOf }} onfaq={(id) => (faqCardId = id)} />
<Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} />
</div>
<div class="attack-power">an ILLUSIONARY {cardDef(illusionOf).name} — it bites only if believed</div>
{:else}
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
{/if}
{#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
<div class="attack-power">
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
{view.stack.amplifyFactor > 1 ? ` AMPLIFIED ×${view.stack.amplifyFactor}` : ""}
</div>
{/if}
{:else if view.stack.defenderId === view.you}
<div class="attack-fist">👊 a bare-knuckled punch</div>
{:else if view.stack.counters.length > 0}
<div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
{/if}
{#if view.stack.defenderId === view.you && view.stack.counters.length > 0}
{@const standing = view.stack.counters.filter((c) => c.player === view.you && !c.nullified)}
{@const broken = view.stack.counters.filter((c) => c.player === view.you && c.nullified)}
{#if broken.length > 0 && standing.length === 0}
<div class="attack-standing nullified">
Your {broken.map((c) => cardDef(c.card.cardId).name).join(" and ")}
{broken.length === 1 ? "was" : "were"} nullified — the attack comes on unchecked.
</div>
{:else if standing.length > 0}
<div class="attack-standing">
Your {standing.map((c) => cardDef(c.card.cardId).name).join(" and ")}
stand{standing.length === 1 ? "s" : ""} — only what slips past
{standing.length === 1 ? "it" : "them"} will land. Counter again, or let it resolve.
</div>
{/if}
{/if}
<button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button>
</div>
</div>
{/if}
{#if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
{/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.setup}
<div class="scrim-handoff">
<div class="handoff-card">
<div class="handoff-eyebrow">wizard {local.setup.names.length + 1} of {local.setup.count}</div>
<div class="handoff-name setup-title">What is your name?</div>
<div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const taken = local.setup.colors.includes(c)}
<button
class="standee" class:current={setupColor === c} class:taken
disabled={taken}
style:--ring={PLAYER_COLORS[c]}
onclick={() => (setupColor = c)}
aria-label={`wizard color ${c + 1}${taken ? " (taken)" : ""}`}
>
<img src={tokenArt(`wizard-${c}`, "players")} alt="" />
</button>
{/each}
</div>
<form class="setup-form" onsubmit={(e) => {
e.preventDefault();
const err = local.submitName(setupName, setupColor);
if (err) net.error = err;
else setupName = "";
}}>
<!-- svelte-ignore a11y_autofocus -->
<input class="setup-input" bind:value={setupName} maxlength="20"
placeholder="e.g. Mordecai" autofocus />
<button class="stamp" type="submit" disabled={!setupName.trim()}>
{local.setup.names.length + 1 === local.setup.count ? "Flip the boards" : "Pass the device on"}
</button>
</form>
{#if local.setup.names.length > 0}
<div class="setup-roster">so far: {local.setup.names.join(", ")}</div>
{/if}
<button class="hint-cancel setup-cancel" onclick={() => local.cancelSetup()}>never mind</button>
</div>
</div>
{:else 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>
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
about this game — a labor of love
</button>
<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">
<span class="hotseat-label">or play here, passing the device:</span>
<div class="count-picker" role="group" aria-label="number of wizards">
{#each [2, 3, 4, 5, 6] as n (n)}
<button class="count-btn" class:current={hotseatCount === n}
onclick={() => (hotseatCount = n)}>{n}</button>
{/each}
</div>
<button class="stamp tiny" onclick={() => { setupName = ""; local.beginSetup(hotseatCount, withExpansion); }}>
Gather {hotseatCount} wizards
</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}
{#if noFanfare}
<button class="hint-cancel" onclick={() => setFanfare(true)}>announce attacks</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}
{attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{:else}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{/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)}
{@const chosen = net.roomColors[p]}
<li>
{#if chosen !== undefined}
<img class="roster-standee" src={tokenArt(`wizard-${chosen}`, "players")} alt="" />
{:else}
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
</li>
{/each}
</ul>
<div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
<button
class="standee"
class:current={net.you != null && net.roomColors[net.you] === c}
class:taken={takenBy !== undefined && takenBy !== net.you}
disabled={takenBy !== undefined && takenBy !== net.you}
style:--ring={PLAYER_COLORS[c]}
onclick={() => net.pickColor(c)}
aria-label={`wizard color ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
>
<img src={tokenArt(`wizard-${c}`, "players")} alt="" />
</button>
{/each}
</div>
{#if net.you === net.hostId}
{#if net.players.length < 6}
<span class="bot-row">
<span class="bot-label">⚙ seat a</span>
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
<option value="apprentice">apprentice</option>
<option value="adept">adept</option>
<option value="archmage">archmage</option>
</select>
<button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
<button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
<button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
<button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
</span>
{/if}
<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 && yourMoment}
selectedCreatureId={selectedCreature}
onCellClick={clickCell}
onEdgeClick={clickEdge}
onPlayerClick={clickPlayer}
onCreatureClick={clickCreature}
onWarpClick={clickWarp}
markedCell={tradeFrom ?? pendingTeleport}
markedCells={trapCells.length > 0 ? trapCells : null}
effects={boardFx}
markedSector={pendingSectorOrigin}
ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot}
onCellPeek={(c) => peekAt(c)}
{litCells}
{sightTrace}
/>
</section>
<aside class="paper-rail">
{#if !local.active && net.missedMoves > 0}
<div class="slip catchup">
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
<button class="stamp tiny" onclick={() => net.requestCatchUp()}>▶ Watch what happened</button>
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
</div>
{/if}
{#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}
Under attack — respond by your hand.
{:else}
Your spell is countered — respond by your hand.
{/if}
</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 — pick an attack card and a target, 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}
{#if interruptCards.length > 0}
<div class="slip urgent">
⚡ Your {cardDef(interruptCards[0]!.cardId).name} can seize this moment — tap the glowing card.
</div>
{/if}
{#if view.yourWardArmed}
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
{/if}
{#if youMustShield}
<div class="slip urgent">
Chaos comes for your hand — tap your Full Shield to sit out, or
<button class="stamp tiny" onclick={pass}>let it take you</button>
</div>
{:else if view.chaosPending}
<div class="slip">Chaos gathers — waiting on {view.chaosPending.queue[0]}…</div>
{/if}
{#each idleCreatures as c (c.id)}
{@const moves = c.movesPerTurn - c.movementUsed}
<div class="slip creature-note">
🜲 {c.controllerId === view.you ? "Your" : "The"} {cardDef(c.kind).name} awaits orders —
{#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && !c.attackUsed && !c.justCreated}&nbsp;·&nbsp;{/if}{#if !c.attackUsed && !c.justCreated}attack ready{/if}.
Tap its token to command it.
</div>
{/each}
{#if commandedCreature}
<div class="creature-dock">
<Card
card={{ instanceId: `dock-${commandedCreature.kind}`, cardId: commandedCreature.kind }}
onfaq={(id) => (faqCardId = id)}
/>
<div class="peek-note">{creatureStats(commandedCreature)}</div>
</div>
{/if}
{#if actionsSpent}
<div class="slip">Your spellwork is spent for this turn — all that remains is to draw and end it.</div>
{/if}
{#if view.yourAmbushes.length > 0}
{#each view.yourAmbushes as a (a.id)}
<div class="slip ambush-note">
🗡 {cardDef(a.spell.cardId).name} waits —
{a.trigger.kind === "los" ? "when seen" : a.trigger.kind === "near" ? "when approached" : "when treasure is grabbed"}
{#if isYourTurn}
<button class="hint-cancel" onclick={() => dispatch({ type: "cancelAmbush", ambushId: a.id })}>disarm</button>
{/if}
</div>
{/each}
{/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)" : ""}{#if net.roomBots[p.id]}
<span class="score-temper">⚙ {net.roomBots[p.id]}</span>{/if}
</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>
{@const spellsOn = view.sustained.filter((e) => e.targetId === p.id)}
{#if p.displayed.length > 0 || spellsOn.length > 0}
<div class="table-cards">
{#each p.displayed as c (c.instanceId)}
<button class="table-card" onclick={() => (peekCard = peekCard?.instanceId === c.instanceId ? null : c)}>
{cardDef(c.cardId).name}{#if view.wandCharges[c.instanceId] != null}&nbsp;·&nbsp;{"●".repeat(view.wandCharges[c.instanceId]!)}{/if}
</button>
{/each}
{#each spellsOn as e (e.id)}
<button class="table-card spell-chip" onclick={() => {
if (!realCard(e.cardId)) return;
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
peekCreatureId = null;
}}>
✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}
</button>
{/each}
</div>
{/if}
{/each}
<div class="deck-line">draw pile {view.deckCount} ·
<button class="discard-link" onclick={() => (showDiscards = true)}>discards {view.discardCount}</button>
</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 class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
{/each}
</div>
{#if local.active && local.viewerId}
<div class="say-box">
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
</div>
{/if}
{#if !local.active && net.roomId}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (t) net.sendChat(t);
chatDraft = "";
}}>
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
</form>
{/if}
</aside>
</div>
<div class="table-edge">
{#if ambushVia}
<div class="hint-strip">
<strong class="hint-name">{cardDef(ambushVia.cardId).name} — set an ambush</strong>
{#if !ambushTrigger}
<span>springs when an opponent…</span>
<button class="stamp tiny" onclick={() => (ambushTrigger = "los")}>enters my sight</button>
<button class="stamp tiny" onclick={() => (ambushTrigger = "near")}>comes beside me</button>
<button class="stamp tiny" onclick={() => (ambushTrigger = "treasure")}>grabs a treasure</button>
{:else if !ambushSpell}
<span>— now tap the attack card to commit</span>
{:else}
<span>{cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"}</span>
<button class="stamp tiny" onclick={armAmbush}>Arm the ambush</button>
{/if}
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
</div>
{/if}
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip">
{#if selectedCreature && !selectedDef}
{@const sc = view.creatures.find((c) => c.id === selectedCreature)}
{#if sc}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
<button class="hint-cancel" onclick={() => (selectedCreature = null)}>done</button>
{/if}
{/if}
{#if youMustRespond && view.stack}
<span class="hint-alert">
{#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
{pendingTeleport
? "Escaping by teleport — tap the marked square again to jump."
: "Escaping by teleport — tap a square within four spaces to mark it."}
{:else if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
: view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
? `an illusionary ${cardDef(view.stack.params.cardId).name}`
: view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
tap a counteraction card, or
{:else}
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
{/if}
</span>
<button class="stamp tiny" onclick={pass}>let it resolve</button>
{/if}
{#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 selectedCard && EDGE_CARDS.has(selectedCard.cardId)}
<span>— {EDGE_HINTS[selectedCard.cardId] ?? "click a wall line"}</span>
{/if}
{#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber}
<span>— first use: tap a NUMBER card to set the wand's charges, then click a target</span>
{:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
<span> click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</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?.cardId === "redirection"}
<span>{tradeFrom ? "— now the exit it should connect TO" : "— click the first exit"}</span>
{:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
<span>{tradeFrom ? "— now the second square" : "— click the first square"}</span>
{/if}
{#if selectedCard?.cardId === "teleport" && !youMustRespond}
<span>{pendingTeleport
? "— tap the marked square again to jump, or pick another"
: "— tap a destination (up to four squares, walls ignored)"}</span>
{#if pendingTeleport}
<button class="stamp tiny" onclick={confirmTeleport}>teleport!</button>
{/if}
{/if}
{#if selectedCard?.cardId === "mega-monster"}
<span> double its</span>
<button class="stamp tiny" class:lit={megaBoost === "life"}
onclick={() => (megaBoost = "life")}>hit points</button>
<button class="stamp tiny" class:lit={megaBoost === "movement"}
onclick={() => (megaBoost = "movement")}>movement</button>
<span> then tap the monster</span>
{/if}
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/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
? (relocateGhosts?.length
? "— now tap a dashed landing beside the maze"
: "— that sector has nowhere legal to go; pick another")
: "— click the sector to move"}</span>
{/if}
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
<label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
<datalist id="card-name-options">
{#each nameSuggestions as n (n)}<option value={n}></option>{/each}
</datalist>
{#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 && isMovableObject(selectedCard.cardId) && isYourTurn}
<button class="stamp tiny" onclick={() => {
if (selectedCard) dispatch({ type: "dropObject", instanceId: selectedCard.instanceId });
clearSelection();
}}>Drop it here</button>
{/if}
{#if selectedCard?.cardId === "add"}
<span> ADD rides a second NUMBER: play one number for movement,
then play another, and the Add spends itself</span>
{/if}
{#if selectedCard && isNumberCard(selectedCard.cardId)}
{@const needsAdd = view.turn.numberPlayedForMovement}
{@const haveAdd = view.yourHand.some((c) => c.cardId === "add")}
{#if needsAdd && !haveAdd}
<span> a second movement number needs an ADD card</span>
{:else}
<button class="stamp tiny" onclick={playNumberForMovement}>
Play for +{cardDef(selectedCard.cardId).value} movement{needsAdd ? " (spends your Add)" : ""}
</button>
{/if}
{/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>
{#if pickChoice}
<span class="pick-which">
whose?
{#each treasuresHere as t (t.id)}
<button class="stamp tiny" onclick={() => pickUpNamed(t.id)}>{t.owner}'s</button>
{/each}
<button class="hint-cancel" onclick={() => (pickChoice = false)}>never mind</button>
</span>
{/if}
<button class="stamp" disabled={!carryingTreasure} onclick={drop}>Drop treasure</button>
{#each objectsHere as obj (obj.instanceId)}
<button class="stamp" disabled={view.turn.actionsEnded}
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button>
{/each}
{#if holdingWard}
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
{/if}
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
onclick={() => (punchWallMode = !punchWallMode)}>
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</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>
{#if selectedCard && !inspectorHidden}
<div class="inspector" role="img" aria-label="selected card, enlarged">
<button class="inspector-close" onclick={() => (inspectorHidden = true)} aria-label="hide enlarged card">×</button>
<div class="inspector-card">
<Card card={selectedCard} onfaq={(id) => (faqCardId = id)} />
</div>
</div>
{/if}
{#if view.phase === "finished" && view.revealedHands}
<div class="final-reveal" aria-label="all hands revealed">
<div class="reveal-head">
The hands, face-up
<button class="stamp tiny"
onclick={() => (local.active ? local.buildReplay() : net.requestFullReplay())}>
Watch the whole game</button>
</div>
{#each view.players as p (p.id)}
<div class="reveal-row">
<span class="reveal-name" class:reveal-winner={p.id === view.winner}>
{p.id === view.winner ? "🏆 " : ""}{p.id}
{#if !p.alive}<span class="reveal-note">as they fell</span>{/if}
</span>
<div class="reveal-cards">
{#each view.revealedHands[p.id] ?? [] as card (card.instanceId)}
<Card {card} onclick={() => (peekCard = card)} onfaq={(id) => (faqCardId = id)} />
{/each}
{#if (view.revealedHands[p.id] ?? []).length === 0}
<span class="reveal-empty">empty-handed</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
<Card
{card}
selected={selectedCard?.instanceId === card.instanceId ||
ambushVia?.instanceId === card.instanceId}
attached={attachedNumber?.instanceId === card.instanceId ||
ambushSpell?.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}
glowing={interruptCards.some((c) => c.instanceId === card.instanceId)}
charges={view.wandCharges[card.instanceId] ?? null}
onclick={() => selectCard(card)}
onfaq={(id) => (faqCardId = id)}
/>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
:global(html, body) {
margin: 0;
background: #171a20;
}
:global(*, *::before, *::after) {
box-sizing: border-box;
}
.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; }
.hotseat-row { display: flex; flex-wrap: wrap; 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; }
.setup-title { font-size: 2.2rem; }
.standee-row {
display: flex;
gap: 0.45rem;
justify-content: center;
margin-top: 0.9rem;
flex-wrap: wrap;
}
.standee {
width: 3.1rem;
height: 3.1rem;
padding: 0;
border: 2.5px solid transparent;
border-radius: 6px;
background: #f4eede;
cursor: pointer;
overflow: hidden;
}
.standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
.standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
.standee.taken { opacity: 0.28; cursor: default; filter: grayscale(0.8); }
.setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; }
.setup-input {
background: #f4eede;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.6rem 0.8rem;
font-family: "Archivo Narrow", sans-serif;
font-size: 1.1rem;
color: #43331f;
text-align: center;
}
.setup-roster { margin-top: 0.8rem; font-size: 0.85rem; color: #6b5a41; }
.setup-cancel { margin: 0.6rem auto 0; display: block; }
.hotseat-label { flex-basis: 100%; font-size: 0.85rem; color: #6b5a41; }
.count-picker { display: flex; gap: 0.3rem; }
.count-btn {
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
border: 1.5px solid #6b5a41;
background: none;
color: #43331f;
font-family: "Oswald", sans-serif;
font-size: 0.95rem;
cursor: pointer;
}
.count-btn.current { background: #43331f; color: #e9e1cb; }
.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: min(26rem, 100%);
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 0.4rem;
}
.about-link {
background: none;
border: none;
font-family: "Caveat", cursive;
font-size: 1.05rem;
color: #8a6d3f;
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
margin-bottom: 1.2rem;
}
.about-link:hover { color: #43331f; }
.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; }
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
.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;
flex-wrap: wrap;
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.lit {
background: #43331f;
color: #e9e1cb;
}
.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(100dvh - 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.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.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;
}
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
.score-row.active .score-name { font-weight: 700; }
.score-temper {
font-family: "Caveat", cursive;
font-size: 0.85rem;
color: #8a7a5e;
margin-left: 0.25rem;
}
.table-cards {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin: -0.15rem 0 0.25rem 1.1rem;
}
.table-card {
font-family: "Courier Prime", monospace;
font-size: 0.68rem;
color: #43331f;
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 3px;
padding: 0.05rem 0.35rem;
cursor: pointer;
}
.table-card:hover { background: #efe6cc; }
.table-card.spell-chip { background: #ece2f2; border-color: #8a6fa0; color: #4a3260; }
.table-card.spell-chip:hover { background: #e3d5ee; }
.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; }
.attack-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.72);
display: grid;
place-items: center;
z-index: 45;
padding: 1rem;
}
.attack-notice {
background: #efe8d4;
border: 2px solid #43331f;
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
padding: 1.2rem 1.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
max-width: min(22rem, 92vw);
text-align: center;
}
.attack-headline {
font-family: "Oswald", sans-serif;
font-size: 1.15rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #b3372b;
}
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-card.illusion-pair {
display: flex;
justify-content: center;
gap: 2.6rem;
}
.attack-card.illusion-pair :global(.card) { transform: scale(1.1); }
.attack-card.illusion-pair :global(.card:hover) { transform: scale(1.1); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-standing {
font-family: "Courier Prime", monospace;
font-size: 0.85rem;
color: #3f6b41;
max-width: 17rem;
margin: 0.3rem auto 0;
}
.attack-standing.nullified { color: #b3372b; }
.rolloff {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin: 0.4rem 0 0.6rem;
}
.rolloff-row {
display: flex;
justify-content: space-between;
gap: 1.2rem;
font-size: 1rem;
color: #43331f;
}
.rolloff-winner { font-weight: 700; }
.rolloff-name { font-family: "Oswald", sans-serif; letter-spacing: 0.04em; }
.rolloff-dice { font-family: "Courier Prime", monospace; }
.die-face { margin-left: 0.4rem; }
.rolloff-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; margin-bottom: 0.3rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; }
.table-talk {
background: #efe8d4;
color: #43331f;
border-radius: 3px;
padding: 0.1rem 0.4rem;
margin: 0.15rem 0;
font-style: italic;
}
.say-box { display: flex; gap: 0.35rem; margin-top: 0.4rem; }
.say-input {
flex: 1;
min-width: 0;
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.35rem 0.55rem;
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
}
.big-peek-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.55);
display: grid;
place-items: center;
z-index: 55;
}
.big-peek :global(.card) {
transform: scale(2.1);
cursor: default;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
}
.big-peek :global(.card:hover) { transform: scale(2.1); }
.big-peek { display: flex; flex-direction: column; align-items: center; }
.big-peek-note { margin-top: 0.7rem; max-width: 15rem; font-size: 0.8rem; }
/* The card scales 2.1x out of its layout box; the stage reserves its
TRUE visual footprint so neighbors never sit underneath it. */
.card-stage {
width: calc(8.2rem * 2.1);
height: calc(11.4rem * 2.1);
display: grid;
place-items: center;
}
.big-peek-token {
width: 6.5rem;
height: 6.5rem;
object-fit: contain;
margin-bottom: 0.8rem;
filter: drop-shadow(2px 4px 8px rgba(0, 0, 0, 0.6));
}
.creature-dock {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.creature-dock :global(.card) { cursor: default; }
.creature-dock :global(.card:hover) { transform: none; }
.hand.spent :global(.card) {
opacity: 0.45;
filter: grayscale(0.5);
cursor: default;
}
.hand.spent :global(.card:hover) { transform: none; }
.tier-pick {
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 3px;
font: inherit;
color: #43331f;
padding: 0.1rem 0.25rem;
}
.bot-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.35rem;
max-width: 100%;
font-size: 0.85rem;
color: #6b5a41;
}
.bot-label { white-space: nowrap; }
.discard-link {
background: none;
border: none;
font: inherit;
color: inherit;
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
padding: 0;
}
.discard-link:hover { color: #43331f; }
.discard-book {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
width: min(52rem, 94vw);
max-height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
overflow: hidden;
}
.discard-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 2px solid #43331f;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #43331f;
}
.discard-close { position: static; }
.discard-grid {
overflow-y: auto;
padding: 0.9rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
gap: 0.7rem;
justify-items: center;
}
.discard-empty { font-family: "Caveat", cursive; color: #8a7a5e; font-size: 1.1rem; }
.peek-note {
margin-top: 0.4rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Courier Prime", monospace;
font-size: 0.72rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
text-align: center;
max-width: 11rem;
}
.final-reveal {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
padding: 0.7rem 0.9rem;
margin-bottom: 0.6rem;
}
.reveal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-family: "Oswald", sans-serif;
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #43331f;
border-bottom: 1px solid #b3a687;
padding-bottom: 0.2rem;
margin-bottom: 0.5rem;
}
.reveal-row { display: flex; align-items: flex-start; gap: 0.6rem; margin-bottom: 0.5rem; }
.reveal-name {
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
min-width: 6.5rem;
padding-top: 0.4rem;
}
.reveal-name.reveal-winner { font-weight: 700; }
.reveal-note { display: block; font-family: "Caveat", cursive; font-size: 0.85rem; color: #8a7a5e; }
.reveal-cards { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.reveal-cards :global(.card) { transform: scale(0.82); transform-origin: top left; margin: -0.35rem -0.8rem -1.1rem 0; }
.reveal-empty { font-family: "Caveat", cursive; color: #8a7a5e; padding-top: 0.5rem; }
.victory-notice { border-color: #a5842c; box-shadow: 0 0 0 4px rgba(201, 167, 42, 0.35), 0 18px 50px rgba(0, 0, 0, 0.6); }
.victory-trophy { font-size: 3rem; line-height: 1; }
.victory-name {
font-family: "Oswald", sans-serif;
font-size: 1.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #43331f;
}
.victory-how { font-family: "Caveat", cursive; font-size: 1.25rem; color: #6b5a41; }
.inspector {
position: fixed;
right: 0.9rem;
bottom: 0.9rem;
z-index: 35;
}
.inspector-card {
transform: scale(1.55);
transform-origin: bottom right;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, 0.65));
}
.inspector-card :global(.card) {
cursor: default;
}
.inspector-card :global(.card:hover) {
transform: none;
}
.inspector-close {
position: absolute;
top: -2.05rem;
right: -0.4rem;
z-index: 2;
background: #2b2218;
color: #e9e1cb;
border: 1px solid #6b5a41;
border-radius: 50%;
width: 1.7rem;
height: 1.7rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.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) {
/* One-column phone flow: board, then your controls and hand, THEN the
paperwork — cards must never hide below the chronicle. */
.game { display: contents; }
.inspector { right: 0.5rem; bottom: 0.5rem; }
.inspector-card { transform: scale(1.3); }
.board-zone { order: 1; }
.table-edge { order: 2; margin-top: 0.6rem; }
.paper-rail { order: 3; margin-top: 0.9rem; }
.board-zone :global(svg.board) {
max-height: 56dvh;
}
.chronicle { max-height: 30dvh; flex: initial; }
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
.boxlid { padding: 1rem 0; }
.boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
.boxlid-title { font-size: 2.3rem; }
.claim-input { width: 100%; flex: 1 1 100%; }
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
.mast-title { font-size: 1.05rem; white-space: nowrap; }
.mast-sub { display: none; }
.mast-leave { font-size: 0.72rem; }
}
</style>