Files
wizwar6e/packages/web/src/App.svelte
T
Eric WagonerandClaude Fable 5 a0c65413ef The door can be held open, as both key cards always promised
MASTER KEY and PICK LOCK each read: "You may 'hold the door open' for
others, if you wish" — and the engine always slammed it at end of
turn. Now the cast takes a hold param: the door stays unlocked past
the turn, for anyone, as long as its holder stands adjacent and
alive. A step away, a shove, a teleport, or a killing blow lets it
swing shut — swept after every command, since anything can move a
wizard. New state, new param: no old ledger contains either, so no
rev gate is needed.

The client offers a "hold the door open" checkbox when either card is
selected; a held door shows pale with a green jamb ("held open by a
standing wizard"), and the chronicle records the holding and the
shutting. Pinned: a held door outlives the turn and admits the other
wizard; walking away releases it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:15:57 -04:00

2526 lines
99 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, 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);
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
let fxCancels: (() => void)[] = [];
function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return;
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);
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;
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) {
// 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 (!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 === "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 (!isYourTurn) { 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 || !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 (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 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}
<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}
{#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 && isYourTurn}
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}
/>
</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 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 ? 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 && 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} 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-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>