Files
wizwar6e/packages/web/src/App.svelte
T
Eric WagonerandClaude Fable 5 0ebae03b1c The discard pile lies face-up, and the chronicle names what falls
At the table the discards sit face-up for anyone to leaf through. The
view now carries the pile (deck order stays secret), the "discards N"
count in the rail became a link, and it opens a booklet-styled reader:
every cast-away card newest first, tap any to enlarge. And the
chronicle stops mumbling "discards 1 card(s)" — it names names, so a
wraith's theft reads "bob discards Fireball," exactly what a table
would see as the card hits the pile.

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

2029 lines
77 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 Card from "./Card.svelte";
import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView } 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);
/** Which pending attack the player has already acknowledged (modal dismissed). */
let attackNoticeSeen = $state<string | null>(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);
/** 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 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);
/** 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 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 youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
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 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;
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;
if (youMustRespond || youMustShield) {
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) {
// 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 (!isYourTurn) { 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 === "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;
}
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;
}
}
// 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;
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;
dispatch({
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side },
...(attackVsWall && 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 (!isYourTurn) { peekAt(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 },
...(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) {
const selecting = selectedCreature !== creatureId;
selectedCreature = selecting ? creatureId : null;
if (selecting) {
peekCard = { instanceId: `peek-${creature.kind}`, cardId: creature.kind };
peekCreatureId = creature.id;
} else {
peekCard = null;
peekCreatureId = null;
}
} else {
peekAt(creature.position);
}
}
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),
);
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
const litCells = $derived.by(() => {
if (!view || !selectedDef || !isYourTurn) return null;
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
if (!me) return null;
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>
<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={() => (peekCard = card)} />
{/each}
{#if view.discardPile.length === 0}
<span class="discard-empty">Nothing has been cast away yet.</span>
{/if}
</div>
</div>
</div>
{/if}
{#if peekCard}
<div class="inspector" role="img" aria-label="displayed card, enlarged">
<button class="inspector-close" onclick={() => { peekCard = null; peekCreatureId = null; }} aria-label="hide card">×</button>
<div class="inspector-card"><Card card={peekCard} /></div>
{#if peekNote}<div class="peek-note">{peekNote}</div>{/if}
</div>
{/if}
{#if showHelp}
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
{/if}
{#if view?.phase === "finished" && view.winner && !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 view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey}
<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}
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
{#if view.stack.numberValue != null}
<div class="attack-power">powered by a {view.stack.numberValue}</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}
<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()} />
{/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={`/tokens/wizard-${c}.png`} 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}
</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)}
{: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)}
{@const chosen = net.roomColors[p]}
<li>
{#if chosen !== undefined}
<img class="roster-standee" src={`/tokens/wizard-${chosen}.png`} alt="" />
{:else}
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{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={`/tokens/wizard-${c}.png`} alt="" />
</button>
{/each}
</div>
{#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}
markedCell={tradeFrom ?? pendingSectorFrom}
onCellPeek={(c) => peekAt(c)}
{litCells}
/>
</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 — 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}
{#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 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)" : ""}</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)} · {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>{line}</div>
{/each}
</div>
</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; peekCard = null; peekCreatureId = null; }}>done</button>
{/if}
{/if}
{#if youMustRespond && view.stack}
<span class="hint-alert">
{#if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with
{attackingCreature ? `the ${cardDef(attackingCreature.kind).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 edgeSelectMode}<span>— click a wall line</span>{/if}
{#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 && 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" 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 && 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>
{#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} />
</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</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)} />
{/each}
{#if (view.revealedHands[p.id] ?? []).length === 0}
<span class="reveal-empty">empty-handed</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<div class="hand" 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)}
/>
{/each}
</div>
</div>
{/if}
</div>
<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: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; }
.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-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; }
.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 {
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>