Files
wizwar6e/packages/web/src/App.svelte
T
Eric WagonerandClaude Fable 5.1 9d087ccaa1 The front page and the about page point to the other games of the Kestrel's Nest
The list is the kit's family.json, shipped as /family.json and refreshed
at deploy when the kit is beside this checkout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 12:26:49 -04:00

5147 lines
227 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 { motion } from "./motion";
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
import Faq from "./Faq.svelte";
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import { HELP_TABS, type HelpTab } from "./help-state";
import { otherGames, type FamilyGame } from "./family";
import { summarizeMissed } from "./catchup";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import TokenGallery from "./TokenGallery.svelte";
import FpvWorkshop from "./fpv/FpvWorkshop.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, castSightTrace, type GameView, type SightLine, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect();
net.startGamePolling();
let name = $state(prefs.wizardName);
/** The other games of the Kestrel's Nest, for the front page. */
let family = $state<FamilyGame[]>([]);
$effect(() => {
void otherGames("wizwar").then((g) => (family = g));
});
let joinCode = $state("");
let claimPhrase = $state("");
/** A /join/<CODE> recruiting link. The visitor lands looking at the
* room: a held seat walks them back to it, a waiting room offers a
* seat or the gallery, a started game is watched from the gallery. */
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
if (inviteCode) joinCode = inviteCode;
let inviteFollowed = false;
$effect(() => {
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
inviteFollowed = true;
const seat = net.seats.find((s) => s.roomId === inviteCode);
if (seat) net.resume(seat);
else net.watch(inviteCode);
});
/** The address bar holds the table's invite link while you sit at one,
* so recruiting is a copy away. */
$effect(() => {
const path = net.roomId ? `/join/${net.roomId}` : "/";
if (location.pathname !== path) history.replaceState(null, "", path + location.search);
});
const inviteLink = $derived(net.roomId ? `${location.origin}/join/${net.roomId}` : "");
let inviteCopied = $state(false);
function copyInvite() {
navigator.clipboard?.writeText(inviteLink).then(() => {
inviteCopied = true;
setTimeout(() => (inviteCopied = false), 1600);
});
}
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");
const tokenWorkshop = new URLSearchParams(location.search).has("tokens");
const fpvWorkshop = new URLSearchParams(location.search).has("fpv");
let attackNoticeSeen = $state<string | null>(null);
/** The interruption fanfare, dismissed once per window. */
let momentSeen = $state(false);
$effect(() => {
if (!view?.outOfTurnWindow) momentSeen = false;
});
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
/** The live pane's feed: each event batch, numbered, with its view. */
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
/** The pane camera's compass quadrant, worn by the keymap token. */
let fpvFacing = $state<Side | null>(null);
/** The creature the pane is riding, if any: the ribbon moves to it. */
let fpvBody = $state<string | null>(null);
let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null);
/** A Ward sprung on YOU earns the same courtesy. */
/** A sprung WARD. The bite rides a counteraction stack, so the batch
* that springs it carries no damage: `pending` means the three points
* wait on the taker's answer rather than having found no purchase. */
let wardBite = $state<{ owner: string; amount: number; pending: boolean } | null>(null);
/** Cards revealed to YOU alone (a read mind, plunder, spoils): the
* glimpse arrives once, in an event — hold it up until dismissed, or it
* is lost to the scrollback. */
let cardReveal = $state<{ title: string; note: string; cards: CardInstance[] } | null>(null);
/** An easy-to-regret play awaiting its "are you sure?" (see prefs.cautions). */
let caution = $state<{ reasons: string[]; go: () => void } | null>(null);
function withCaution(reasons: string[], go: () => void) {
if (!prefs.cautions || reasons.length === 0) go();
else caution = { reasons, go };
}
/** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded
* squares, with the viewport scrolling. The svg's width is always set
* explicitly (fit × zoom) from measurement — auto-sizing leaves stale
* dimensions behind when the first-person pane closes. */
let boardViewportEl = $state<HTMLDivElement | null>(null);
let boardZoom = $state(1);
/** Back to the whole maze at once. */
function resetZoom() {
boardZoom = 1;
if (boardViewportEl) requestAnimationFrame(() => { boardViewportEl!.scrollLeft = 0; boardViewportEl!.scrollTop = 0; });
}
/** The phone's dock keeps its secondary tools behind "More". */
let moreOpen = $state(false);
function zoomBoard(dir: number) {
const el = boardViewportEl;
const prev = boardZoom;
const next = dir > 0 ? prev * 1.4 : prev / 1.4;
boardZoom = next < 1.05 ? 1 : Math.min(4, Math.round(next * 100) / 100);
if (el && boardZoom !== prev) {
const f = boardZoom / prev;
requestAnimationFrame(() => {
el.scrollLeft = (el.scrollLeft + el.clientWidth / 2) * f - el.clientWidth / 2;
el.scrollTop = (el.scrollTop + el.clientHeight / 2) * f - el.clientHeight / 2;
});
}
}
function fitBoard(el: HTMLDivElement) {
const svg = el.querySelector("svg.board") as SVGSVGElement | null;
const vb = svg?.viewBox.baseVal;
if (!vb || !vb.width || !vb.height) return;
const fitW = Math.min(el.clientWidth, (el.clientHeight * vb.width) / vb.height);
el.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
// The toolbar above the board hugs the board's drawn width, not the column's.
el.closest<HTMLElement>(".board-zone")?.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
}
$effect(() => {
const el = boardViewportEl;
if (!el) return;
const ro = new ResizeObserver(() => fitBoard(el));
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
void view; // the board's viewBox grows with ghost slots and relocations
const el = boardViewportEl;
if (el) requestAnimationFrame(() => fitBoard(el));
});
/** A move with its regret checks: stepping off your home still laden,
* or stepping onto ground that bites. */
function tryMove(direction: Side, over?: boolean, exit?: Side) {
const reasons: string[] = [];
if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
reasons.push(t && t.owner !== view.you
? `you stand on your home with ${t.owner}'s treasure in your arms — drop it first and it scores`
: "you stand on your home with your own treasure in your arms — drop it first and it is safe");
}
if (view && me) {
const misted = view.sustained.some((e) => e.cardId === "mist-body" && e.targetId === view.you);
const steps = over ? 2 : 1;
const dest = { x: me.position.x + (direction === "E" ? steps : direction === "W" ? -steps : 0),
y: me.position.y + (direction === "S" ? steps : direction === "N" ? -steps : 0) };
const ground = view.squareContents[cellKey(dest)]?.kind;
if (!misted && ground) {
const bite: Record<string, string> = {
thornbush: "the thorns end your turn, cost you the NEXT one, and prick for 1",
tacks: "the tacks bite for 3",
pit: "the pit: roll a 1 and you fall in, hurt and stuck",
ooze: "the ooze burns 1 — and may drop you flat, treasure and all",
};
if (bite[ground]) reasons.push(bite[ground]);
}
}
withCaution(reasons, () => dispatch({ type: "move", direction, ...(over ? { over: true } : {}), ...(exit ? { exit } : {}) }));
}
function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return;
// The events arrive before the state that follows them: the view here
// is the board as the caster saw it, dust and all.
for (const e of events) {
if (e.type !== "spellCast") continue;
const line = castSightTrace(view, e, events);
if (!line) continue;
castTrace = line;
if (castTraceTimer) clearTimeout(castTraceTimer);
castTraceTimer = setTimeout(() => { if (castTrace === line) castTrace = null; }, 4500);
}
for (const e of events) {
if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
if (e.type === "handRevealedPrivate" && e.visibleTo === view.you) {
cardReveal = {
title: `${e.player}'s mind, laid bare`,
note: "Commit them to memory — the glimpse does not linger.",
cards: e.cards,
};
}
if (e.type === "cardsStolenPrivate" && e.visibleTo === view.you) {
const pub = events.find((s) => s.type === "cardsStolen" && s.to === view!.you);
cardReveal = {
title: pub?.type === "cardsStolen" ? `Plucked from ${pub.from}'s thoughts` : "Plucked from their thoughts",
note: "Yours now — already in your hand.",
cards: e.cards,
};
}
if (e.type === "handTakenPrivate" && e.visibleTo === view.you) {
const pub = events.find((s) => s.type === "handTaken" && s.to === view!.you);
cardReveal = {
title: pub?.type === "handTaken" ? `The spoils of ${pub.from}` : "The spoils",
note: "The fallen wizard's hand — already in yours.",
cards: e.cards,
};
}
if (e.type === "wardSprung" && e.victim === view.you) {
const dmg = events.find((d) => d.type === "damaged" && d.player === e.victim && d.source === "warded treasure");
const pending = view.stack?.defenderId === view.you && view.stack.sourceName === "warded treasure";
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0, pending };
}
}
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
if (!motion.effects) 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 = [];
};
});
$effect(() => {
if (motion.effects) return;
fxCancels.forEach((cancel) => cancel());
fxCancels = [];
boardFx = [];
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() {
net.dismissRolls();
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);
/** Ledger chest tapped: the treasure full-size, with whose and who-holds. */
let peekTreasure = $state<{ src: string; note: string } | null>(null);
/** Cardless blows that a card nonetheless made: the stack names the
* blow, and this names the card to show for it. */
const BLOW_CARD: Record<string, string> = { "warded treasure": "ward" };
/** 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<typeof prefs.botTier>(prefs.botTier);
let botStyle = $state<"hunter" | "berserker" | "worrier" | "mystery">("hunter");
let addingBot = $state(false);
/** The wizard picker stands open until you have a wizard; after that, on request. */
let colorPickerToggled = $state<boolean | null>(null);
const colorPickerOpen = $derived(colorPickerToggled ?? (net.you != null && net.roomColors[net.you] === undefined));
const BOT_BLURBS = {
hunter: "A hunter goes for the treasure and carries it home.",
berserker: "A berserker goes for blood, and worries about treasure later.",
worrier: "A worrier keeps to the shadows and guards what is theirs.",
mystery: "Mystery deals one of the three at random; you learn which when the game ends.",
} as const;
/** 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);
/** The help's opening tab; null reopens it where the reader left off. */
let helpTab = $state<HelpTab | null>(null);
let helpAnchor = $state<string | null>(null);
let helpCard = $state<string | null>(null);
// A link into the reference — /?help=rules/rulebook-line-of-sight or
// /?card=force-field — opens the desk to that page.
{
const q = new URLSearchParams(location.search);
const card = q.get("card");
const help = q.get("help");
const tabs: readonly HelpTab[] = HELP_TABS;
if (card) { helpCard = card; showHelp = true; }
else if (help) {
const [t, ...rest] = help.split("/");
if (tabs.includes(t as HelpTab)) { helpTab = t as HelpTab; helpAnchor = rest.join("/") || null; showHelp = true; }
}
}
/** The address bar follows the reader through the help, so any page is a link. */
function helpNavigate(tab: HelpTab, anchor: string | null, card: string | null) {
const u = new URL(location.href);
u.searchParams.delete("help"); u.searchParams.delete("card");
if (card) u.searchParams.set("card", card);
else u.searchParams.set("help", anchor ? `${tab}/${anchor}` : tab);
// The slash stays a slash: /?help=rules/the-cards reads as a path should.
u.search = u.searchParams.toString().replace(/%2F/g, "/");
history.replaceState(null, "", u);
}
function helpClose() {
showHelp = false; helpAnchor = null; helpCard = null; helpTab = null;
const u = new URL(location.href);
u.searchParams.delete("help"); u.searchParams.delete("card");
history.replaceState(null, "", u);
}
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);
/** The face-up hands at game's end fold away on request; a new game unfolds them. */
let revealFolded = $state(false);
$effect(() => { if (view?.phase !== "finished") revealFolded = 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 aimed at a monster: which of its two numbers to double. */
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
function castMega(boost: "life" | "movement") {
const m = megaPrompt;
if (!m) return;
megaPrompt = null;
dispatch(withMods({ type: "cast", instanceId: m.instanceId, target: { kind: "creature", creatureId: m.creatureId }, params: { boost } }));
clearSelection();
}
/** teleport: the marked destination awaiting its confirming second tap. */
let pendingTeleport = $state<{ x: number; y: number } | null>(null);
/** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set());
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
let discardMode = $state(false);
const view = $derived(local.active ? local.view : net.view);
const isYourTurn = $derived(
view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack,
);
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
/** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment:
* casting and targeting open up as if the turn were yours. */
const yourMoment = $derived(
isYourTurn || (view != null && view.outOfTurnWindow?.playerId === view.you),
);
const attackNoticeKey = $derived(
youMustRespond && view?.stack
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}`
: null,
);
const attackingCreature = $derived(
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
);
const myShieldstoneDisplayed = $derived(
view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "shieldstone") ?? false,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
/** Picking up an object or sliding into slime 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, vacant, and leaving
* every sector adjacent to at least one other (negative landings are
* fine; the maze renormalizes). */
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);
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();
}
/** 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 — or Display it once and walk through every lock",
"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 door, or a stone-filled square",
"thumb-of-god": "aim the die at a square in sight — it may drift on impact",
"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", "power-attack"]);
/** Casting panel: on wide screens the paper-rail's chronicle yields to
* the spell being built (or answered), controls and all — table talk
* returns the moment the cast resolves. Phones keep the under-board
* strips; a rail below the fold is no place to aim from. */
const wideQuery = new MediaQuery("(min-width: 901px)", true);
const wideScreen = $derived(wideQuery.current);
/** A wide, short screen — a laptop in landscape — stands the hand beside
* the board unless the player has said otherwise: a tall maze under a
* bottom hand leaves a postage stamp, and a first turn deserves better.
* (The narrower "short window" rule in the stylesheet only shrinks the
* cards; this one needs room for a card column, hence the wider floor.) */
const shortWideQuery = new MediaQuery("(min-width: 1180px) and (max-height: 820px)", false);
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWideQuery.current);
const castPanelOn = $derived(wideScreen && !local.active &&
(selectedCard != null || (youMustRespond && !!view?.stack)));
/** The hand in the player's own order: drag a card onto another to take
* its place. The order lives only in this tab, for this sitting — the
* server's hand array is authority for contents, this for arrangement. */
let handOrder = $state<string[]>([]);
let draggingId = $state<string | null>(null);
const orderedHand = $derived.by(() => {
const hand = view?.yourHand ?? [];
const rank = new Map(handOrder.map((id, i) => [id, i]));
return [...hand].sort((a, b) =>
(rank.get(a.instanceId) ?? handOrder.length) - (rank.get(b.instanceId) ?? handOrder.length));
});
function dropCardOn(targetId: string) {
if (!draggingId || draggingId === targetId) return;
const ids = orderedHand.map((c) => c.instanceId);
const from = ids.indexOf(draggingId);
const to = ids.indexOf(targetId);
if (from === -1 || to === -1) return;
ids.splice(to, 0, ...ids.splice(from, 1));
handOrder = ids;
draggingId = null;
}
/** Hand-at-left card scale: the two columns grow to fill their column's
* width, capped so the full hand still fits without a scrollbar. */
let handEl = $state<HTMLDivElement | null>(null);
function fitHandCards(el: HTMLDivElement) {
if (!handLeftOn) { el.style.removeProperty("--card-zoom"); return; }
// Card.svelte is the source of truth for the card's box: 8.2rem wide,
// 11.4rem tall. 5rem covers the masthead and the column's padding.
const rem = 16;
const cardW = 8.2 * rem, gap = 0.45 * rem, pad = 0.6 * rem;
const zoomW = (el.clientWidth - pad - gap) / (2 * cardW);
const rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2));
const cardH = (11.4 / 8.2) * cardW;
const maxH = window.innerHeight - 5 * rem;
const zoomH = (maxH - pad) / (rows * (cardH + gap));
el.style.setProperty("--card-zoom", String(Math.max(0.8, Math.min(zoomW, zoomH, 1.8)).toFixed(3)));
}
$effect(() => {
const el = handEl;
if (!el) return;
const ro = new ResizeObserver(() => fitHandCards(el));
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
void view?.yourHand.length;
void handLeftOn;
const el = handEl;
if (el) requestAnimationFrame(() => fitHandCards(el));
});
/** POWER ATTACK: life points burned into the attached attack — always
* fewer than the caster has left. */
let paPoints = $state(1);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
const attackVsWall = $derived(
selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
);
const trollInHand = $derived(
selectedCreature != null && selectedCard == null &&
view?.creatures.some((c) => c.id === selectedCreature && c.kind === "troll" && c.controllerId === view!.you) === true,
);
const edgeSelectMode = $derived(
(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)) || attackVsWall || punchWallMode || trollInHand,
);
const cellSelectMode = $derived(
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
);
/** The dock's leading line while a card is being aimed: the decision in
* a few words, beside the board where the tap happens. The panel beside
* the board keeps the card and the full account. */
const dockLead = $derived.by(() => {
if (!selectedCard || !selectedDef || youMustRespond) return null;
const id = selectedCard.cardId;
if (id === "teleport") return pendingTeleport
? "Tap the marked square again to jump · or pick another"
: "Choose a destination · lit squares are within 4 · ends your movement";
if (id === "mental-force") return "Choose the wizard, then where they go · within 3 walked squares";
if (EDGE_CARDS.has(id)) return "Tap a wall line · lit lines are legal";
if (cellSelectMode) return "Choose a square · lit squares are legal targets";
if (selectedDef.cardType === "attack") return selectedDef.adjacent
? "Choose a target · in your square or the one beside you"
: "Choose a target · lit squares are in your sight";
if (selectedDef.cardType === "number") return "A NUMBER · play it for movement, or tap a spell to power it";
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
return `${selectedDef.name} · its panel offers the choices`;
});
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() {
paPoints = 1;
punchWallMode = false;
swapMeetTarget = null;
swapMine = null;
ambushVia = null;
ambushTrigger = null;
ambushSpell = null;
ambushCell = null;
trapCells = [];
tradeFrom = null;
selectedCreature = null;
selectedCard = null;
holdDoor = false;
pickChoice = false;
pendingTeleport = null;
attachedNumber = null;
attachedMods = [];
wbDamage = 0;
pendingCellFor = null;
pendingSectorFrom = null;
nameInput = "";
}
/** Fill the modifier fields of a cast command from the attachments. */
function applyMods(cmd: Parameters<typeof net.command>[0] & { type: "cast" }) {
for (const m of attachedMods) {
if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId);
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
else if (m.cardId === "power-attack") {
cmd.powerAttackInstanceId = m.instanceId;
cmd.powerAttackPoints = paPoints;
}
}
}
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
const castingDouble = $derived(
selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: null,
);
/** Attachment plumbing for a cast command: the chosen mods, the attached
* NUMBER (an explicit numberInstanceIds wins), and the double it casts through. */
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
if (attachedNumber && !cmd.numberInstanceIds) {
cmd.numberInstanceIds = [attachedNumber.instanceId];
}
return cmd;
}
/** 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;
}
// Fully reflecting a SWAP MEET: "lets the other player decide which
// objects, if any, will be swapped" — the choice rides the counter.
if (youMustRespond && card.cardId === "full-reflection" &&
view.stack?.attackCard?.cardId === "swap-meet") {
reflectSwapCard = card;
reflectSwapMine = null;
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;
if (!isYourTurn && !yourMoment) {
// Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending).
if (!view.stack && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
dispatch({ type: "cast", instanceId: card.instanceId });
}
return;
}
// On your own turn, Interrupt / Opportunity Fire arm an ambush instead.
if (card.cardId === "interrupt" || card.cardId === "opportunity-fire") {
clearSelection();
ambushVia = card;
return;
}
if (ambushVia) {
if (isNumberCard(card.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
return;
}
if (cardDef(card.cardId).cardType === "attack") {
ambushSpell = ambushSpell?.instanceId === card.instanceId ? null : card;
return;
}
return;
}
if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
wbDamage = numberTotal;
return;
}
if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) {
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
: [...attachedMods, card];
return;
}
if (selectedCard?.instanceId === card.instanceId) {
clearSelection();
return;
}
selectedCard = card;
attachedNumber = null;
// Nothing casts on first click: every play is confirmed from the hint
// bar or by choosing a target. Misclicks cost nothing.
}
/** Untargeted spells confirmed from the hint bar (with optional number). */
const CONFIRM_CAST = new Set([
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
"gift-from-above", "chaos", "interrupt", "opportunity-fire",
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
]);
/** Cards cast on yourself via the Cast button. DISEASE joined at rev 7
* ("You're the carrier!"); older games still aim it at a victim. */
function confirmCastable(cardId: string): boolean {
return CONFIRM_CAST.has(cardId) ||
(cardId === "disease" && (view?.deckRev ?? 1) >= 7);
}
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();
}
/** Cell-needing ambush spells choose their destination when laid. */
const ambushNeedsCell = $derived(
ambushSpell != null &&
(ambushSpell.cardId === "teleport-opponent" || ambushSpell.cardId === "mental-force"));
let ambushCell = $state<{ x: number; y: number } | null>(null);
function armAmbush() {
if (!ambushVia || !ambushTrigger || !ambushSpell) return;
if (ambushNeedsCell && !ambushCell) return;
dispatch({
type: "setAmbush",
instanceId: ambushVia.instanceId,
trigger: { kind: ambushTrigger },
spellInstanceId: ambushSpell.instanceId,
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
...(ambushCell ? { cell: ambushCell } : {}),
});
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;
// Laying an ambush with a cell-needing spell: this click is the trap's
// destination, chosen in advance ("wherever you say").
if (ambushSpell && ambushNeedsCell && !ambushCell) {
ambushCell = cell;
return;
}
if (youMustRespond && selectedCard?.cardId === "teleport") {
// Mark first, jump on the second tap: an escape spent on a misclick
// is an escape wasted.
if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
pendingTeleport = cell;
return;
}
dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
clearSelection();
return;
}
if (!yourMoment) { peekAt(cell); return; }
if (pendingCellFor && selectedCard) {
// Stage 2 of teleport-opponent: destination chosen.
dispatch(withMods({
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;
}
// A SAFE takes the cast itself: a key turns its combination, and an
// attack batters the box (fifteen points bursts it). Bushes and the
// ooze take attacks the same way; five points clear the square.
const filled = view.squareContents[cellKey(cell)]?.kind;
if (selectedCard && filled === "safe" &&
(selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key" ||
cardDef(selectedCard.cardId).cardType === "attack")) {
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
clearSelection();
return;
}
if (selectedCard && (filled === "thornbush" || filled === "rosebush" || filled === "ooze") &&
cardDef(selectedCard.cardId).cardType === "attack") {
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
clearSelection();
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(withMods({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }));
clearSelection();
}
return;
}
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
if (!tradeFrom) { tradeFrom = cell; return; }
dispatch(withMods({
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(withMods({
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)) {
// The Democratic Monster claws WHOEVER's square it enters — its
// own mover included. Walking it onto yourself takes two taps.
const mover = view.players.find((p) => p.id === view.you);
if (creature.kind === "democratic-monster" && !creature.attackUsed && mover &&
cellKey(cell) === cellKey(mover.position) && dmWarnCell !== cellKey(cell)) {
dmWarnCell = cellKey(cell);
return;
}
dmWarnCell = null;
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;
}
// Standing on a DIMENSIONAL WARP token: clicking the paired token
// steps it through.
const dw = view.dimWarps.find((d) =>
(cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) ||
(cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell)));
if (dw) {
dispatch({ type: "creatureWarpStep", creatureId: selectedCreature });
return;
}
}
selectedCreature = null;
return;
}
const me = view.players.find((p) => p.id === view.you)!;
// "Spells cast at the slime get stuck there": an attack tapped onto a
// slime waits in the gel for whoever enters.
if (selectedCard && view.squareContents[cellKey(cell)]?.kind === "slime" &&
cardDef(selectedCard.cardId).cardType === "attack") {
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
clearSelection();
return;
}
// A card in hand is aimed, not walked with: a tap on a bare square
// while holding one is a miss, never a stride into whatever is there.
if (selectedCard) {
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, a bush, or a slime — put the card down to walk`);
return;
}
// 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)) {
tryMove(side);
return;
}
}
// Adjacent but blocked? Send the move anyway — unlocked doors, mist-body
// and pass-through-wall are resolved server-side. A shimmering illusion
// in the way asks for the belief test first instead.
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)) {
const k = edgeKey(me.position, side);
if (view.illusionEdges[k] === "untested") {
illusionPrompt = illusionEdgeParts(k);
return;
}
tryMove(side);
return;
}
}
// A pit beside you, and a click on a square beside IT: cross by that
// side — the rim's ledge runs the whole way round.
for (const side of SIDES) {
const pit = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
for (const out of SIDES) {
if (out === opposite(side)) continue;
const foot = stepTarget(view.board, pit, out);
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); 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")) {
tryMove(side, 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}`;
}
/** The square a peek is looking at, for notes the card alone cannot carry. */
let peekCell = $state<{ x: number; y: number } | null>(null);
const peekNote = $derived.by(() => {
if (!view) return null;
if (peekCreatureId) {
const c = view.creatures.find((c) => c.id === peekCreatureId);
return c ? creatureStats(c) : null;
}
if (peekCell && view.squareContents[cellKey(peekCell)]?.kind === "slime") {
const waiting = view.slimeTraps?.[cellKey(peekCell)] ?? [];
if (waiting.length > 0) {
return `Waiting in the gel for the next wizard in: ${waiting.map((t) => `${cardDef(t.cardId).name} (${t.casterId})`).join(", ")}`;
}
}
return 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;
peekCell = { ...cell };
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) {
tryMove(side);
} else {
clickCell(cell);
}
}
function clickEdge(cell: { x: number; y: number }, side: Side) {
if (punchWallMode) {
dispatch({ type: "punchWall", cell, side });
punchWallMode = false;
return;
}
// A commanded troll punches the wall line beside it.
if (selectedCreature && !selectedCard) {
dispatch({ type: "creatureAttackWall", creatureId: selectedCreature, cell, side });
return;
}
if (!selectedCard || !edgeSelectMode) return;
// The attached number rides along for every edge cast: wand charges,
// wall-of-fire durations, wall attacks alike.
dispatch(withMods({
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side },
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
? { params: { hold: true } } : {}),
}));
clearSelection();
}
function clickCreature(creatureId: string) {
if (!view) return;
const creature = view.creatures.find((c) => c.id === creatureId);
if (!creature) return;
if (!yourMoment) { peekAt(creature.position); return; }
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
// Cell-target spells aimed at a creature (DISPEL CREATION on a wraith)
// land on its square — the engine finds the creature there.
clickCell(creature.position);
return;
}
if (selectedCard && selectedCard.cardId === "mega-monster") {
// "Doubles the existing life-points OR movement rate": the choice is
// asked, not defaulted — the cast waits on the answer.
megaPrompt = { instanceId: selectedCard.instanceId, creatureId };
return;
}
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
dispatch(withMods({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "creature", creatureId },
}));
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);
}
}
/** A click in the first-person pane, resolved by the renderer to the
* board's own vocabulary — then handled by the very same functions the
* top-down board uses, so the pane and the map can never disagree. */
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
if (t.kind === "player") clickPlayer(t.id);
else if (t.kind === "creature") clickCreature(t.id);
else if (t.kind === "edge") clickEdge(t.cell, t.side);
else clickCell(t.cell);
}
function clickPlayer(playerId: string) {
if (!view || !yourMoment) return;
if (selectedCreature && !(castingDouble && selectedCard)) {
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" ||
selectedCard.cardId === "mental-force") {
pendingCellFor = playerId;
return; // next: click the destination cell
}
if (selectedCard.cardId === "swap-meet") {
swapMeetTarget = playerId;
return; // next: pick your item, then theirs
}
const cmd: Parameters<typeof net.command>[0] & { type: "cast" } = {
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "player", playerId },
};
withMods(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();
const reasons: string[] = [];
if (view && me) {
const left = view.turn.movementAllowance - view.turn.movementUsed;
// Unspent legs are no regret when the walk ended ON someone's gold —
// stopping there was the point — nor when a pickup or drop already
// forfeited them: those points cannot be spent, only mourned.
if (left > 0 && !view.turn.actionsEnded &&
!treasuresHere.some((t) => t.owner !== view.you)) {
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
}
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
const room = limit - view.yourHand.length;
if (drawCount > room) {
reasons.push(room <= 0
? "your hand is full — the draw will bring nothing"
: `your hand only has room for ${room} of the ${drawCount}-card draw`);
}
if (me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
reasons.push(t && t.owner !== view.you
? `${t.owner}'s treasure is still in your arms — drop it here and it scores`
: "your own treasure is still in your arms — drop it here and it is safe");
}
// Treasure underfoot un-grabbed — unless the auto-grab preference (or
// this caution's own "do it anyway") is about to handle it, or the
// grab would be refused (laden, weakened, actions spent, glue, safe,
// or your own home where a stolen chest is already scoring).
const here = cellKey(me.position);
const autoWillGrab = prefs.autoGrab && treasuresHere.length === 1;
const weakened = view.sustained.some((e) => e.cardId === "weakness" && e.targetId === view.you);
const safed = view.squareContents[here]?.kind === "safe" &&
view.squareContents[here]!.createdBy !== view.you && !view.openSafes.includes(here);
const grabRefused = !!me.carriedTreasureId || view.turn.actionsEnded || weakened ||
!!view.gluedCells[here] || safed || here === cellKey(me.home);
if (treasuresHere.length > 0 && !autoWillGrab && !grabRefused) {
reasons.push(`a treasure lies at your feet, un-grabbed`);
}
// Auto-grab about to pocket your OWN chest: carrying it does not
// score and bares it to capture, so that walk-off asks first.
if (autoWillGrab && !grabRefused && treasuresHere[0]!.owner === view.you) {
reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along");
}
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
const drawRoom = Math.min(2, room);
if (drawCount < drawRoom) {
reasons.push(`drawing only ${drawCount} when ${drawRoom} would fit`);
}
}
withCaution(reasons, () => {
// Preference: a lone grabbable treasure underfoot is picked up on the
// way out (pickup ends actions; ending the turn is still legal after).
if (prefs.autoGrab && view && me && !me.carriedTreasureId &&
!view.turn.actionsEnded && treasuresHere.length === 1 &&
cellKey(me.position) !== cellKey(me.home)) {
dispatch({ type: "pickUpTreasure" });
}
dispatch({ type: "endTurn", draw: drawCount });
});
}
// SWAP MEET: pick one of your carried items, then one of theirs you can
// see (their displayed items and any treasure in their arms — hidden
// hand cards are not on the table).
let swapMeetTarget = $state<string | null>(null);
let swapMine = $state<string | null>(null);
// Reflecting a SWAP MEET: the reflector picks the trade — or none at all.
let reflectSwapCard = $state<CardInstance | null>(null);
let reflectSwapMine = $state<string | null>(null);
const reflectMyTradables = $derived(reflectSwapCard ? tradablesOf(null, null) : []);
const reflectTheirTradables = $derived(
reflectSwapCard && view?.stack ? tradablesOf(view.stack.attackerId, reflectSwapMine) : [],
);
function castReflectSwap(pair: string) {
if (!reflectSwapCard) return;
dispatch({
type: "counteract", instanceId: reflectSwapCard.instanceId,
params: { cardId: pair },
});
reflectSwapCard = null;
reflectSwapMine = null;
}
function tradableHere(cardId: string): boolean {
return view != null && isMovableObject(cardId);
}
/** What one side can put on a trade table: hand (yours) or displayed
* cards (theirs), plus a carried treasure. Their treasure is claimable
* only when yours is going the other way or your arms are free —
* `pairedPick` is what you have already staked. */
function tradablesOf(themId: string | null, pairedPick: string | null): { key: string; label: string }[] {
if (!view) return [];
const opts = new Map<string, string>();
const treasureEntry = (treasureId: string) => {
const t = view!.treasures.find((t) => t.id === treasureId);
opts.set("treasure", `${t?.owner ?? "someone"}'s treasure (carried)`);
};
if (themId === null) {
for (const c of view.yourHand) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (me?.carriedTreasureId) treasureEntry(me.carriedTreasureId);
} else {
const them = view.players.find((p) => p.id === themId);
if (!them) return [];
for (const c of them.displayed) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (them.carriedTreasureId && (pairedPick === "treasure" || !me?.carriedTreasureId)) {
treasureEntry(them.carriedTreasureId);
}
}
return [...opts].map(([key, label]) => ({ key, label }));
}
const myTradables = $derived(tradablesOf(null, null));
const theirTradables = $derived.by(() => {
if (!swapMeetTarget) return [];
return tradablesOf(swapMeetTarget, swapMine);
});
function castSwapMeet(theirs: string) {
if (!selectedCard || !swapMeetTarget || !swapMine) return;
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "player", playerId: swapMeetTarget },
params: { cardId: `${swapMine};${theirs}` },
});
clearSelection();
}
// Press-and-hold on a wall/door/fire segment (touch has no hover).
let peekEdge = $state<string | null>(null);
/** An ongoing spell, opened from its chip: its card, with a plain account beneath. */
type Sustained = GameView["sustained"][number];
let peekEffect = $state<Sustained | null>(null);
/** What each ongoing spell does, in a sentence. */
const EFFECT_GLOSS: Record<string, string> = {
slow: "Moves one square a turn, no numbers or speed spells, and attacks only every other turn.",
"no-spell": "Cannot cast any spell; a magic stone's power still works, and cards may be discarded.",
medusa: "Cannot move or cast, counteractions included — and takes no damage from anything.",
"lock-in-place": "Cannot move or be moved in any way, teleportation included; spells may still be cast.",
blind: "Movement, thrown objects, attacks, and spells go in a direction the die decides; self-cast spells work.",
invisible: "An attacker must roll the die for a random direction to see whether the blow finds them.",
shrink: "Half as likely to be hit by any attack, and walks only two squares a turn.",
adrenaline: "May make two attacks in one turn.",
"big-man": "Fills the corridor: nobody moves, casts, or punches past them, or enters their square; they can push what stands in their way.",
"mist-body": "Passes through anything but stone; neither attacks nor can be attacked; walls of fire still burn.",
disease: "Carries the plague: sharing a square with another wizard costs each of them three points.",
empathy: "Any attack against them lands on the attacker as well.",
fear: "Nobody comes within three squares, walls or no walls; those in range must move away.",
strength: "Physical damage they deal is doubled, and they may tear a treasure from a wizard sharing their square.",
buddy: "Will not attack the wizard who cast it, until attacked first.",
lifesaver: "Not eliminated for losing both treasures to enemy homes.",
"force-field": "Stops the spell it answered, then stands: the opponent may not enter this square, nor cast on or past this wizard, on any side.",
"around-the-corner": "May cast line-of-sight spells around one corner.",
};
function effectNote(e: Sustained): string {
const gloss = EFFECT_GLOSS[e.cardId] ?? (realCard(e.cardId) ? cardDef(e.cardId).text ?? "" : "");
const who = e.casterId === e.targetId ? `${e.casterId} cast it on themself.` : `Cast by ${e.casterId} on ${e.targetId}.`;
const ends =
e.cardId === "force-field" ? "Stands until the end of the opponent's turn."
: e.cardId === "buddy" ? "Holds until the pact is broken by an attack."
: isPermanentDuration(e.remainingTurns) ? "Permanent — until dispelled, or the game ends."
: e.remainingTurns === 1 ? `Ends at the start of ${e.casterId}'s next turn.`
: `Ends at the start of ${e.casterId}'s turn, ${e.remainingTurns} of their turns from now.`;
return `${gloss} ${who} ${ends}`;
}
/** Treasures a wizard has carried home: stolen chests sitting on their home square. */
function treasuresHomeOf(p: { id: string; home: { x: number; y: number } }): number {
return view ? view.treasures.filter((t) => t.owner !== p.id && t.position &&
t.position.x === p.home.x && t.position.y === p.home.y).length : 0;
}
/** What the chronicle shows: everything, the game's own lines, or the talk. */
let logFilter = $state<"all" | "game" | "talk">("all");
const isTalk = (text: string) => text.startsWith("\u{1F4AC}") || text.startsWith("\u{1F3B2}");
function showLine(line: { text: string }): boolean {
return logFilter === "all" ? true : logFilter === "talk" ? isTalk(line.text) : !isTalk(line.text);
}
/** Talk that arrived while the chronicle was covered or filtered away. */
let seenTalk = $state(0);
$effect(() => {
const count = net.chatCount;
if ((!castPanelOn && logFilter !== "game") || net.lastTalkBy === net.you) seenTalk = count;
});
const unreadTalk = $derived(Math.max(0, net.chatCount - seenTalk));
const lastTalk = $derived.by(() => {
for (let i = net.log.length - 1; i >= 0; i--) {
const t = net.log[i]!.text;
if (t.startsWith("\u{1F4AC}")) return t.slice(2).trim();
}
return null;
});
/** The table is out of reach: play waits, and nothing here can be sent. */
const offline = $derived(!local.active && net.roomId != null && net.status !== "connected");
// Talk that never arrived goes back into the composer, with a word.
$effect(() => {
const lost = net.chatUnsent;
if (!lost) return;
if (!chatDraft.trim()) chatDraft = lost;
net.flash("Your message was not sent — it is back in the box");
net.chatUnsent = null;
});
/** The preferences slip (client-side taste, saved on this device). */
let prefsOpen = $state(false);
/** The bug-report slip: what happened vs. what was expected. The room
* code and move number ride along server-side, pinning the moment in
* the ledger for replay. */
let feedbackOpen = $state(false);
let fbHappened = $state("");
let fbExpected = $state("");
let fbSent = $state(false);
/** Drafts of answers under your reports, by report id. */
let reportAnswers = $state<Record<string, string>>({});
/** A screenshot for the desk, chosen with the report. */
let fbImage = $state<File | null>(null);
let fbPreview = $state<string | null>(null);
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (fbPreview) URL.revokeObjectURL(fbPreview);
if (file && file.size > 2_500_000) { net.flash("That picture is over 2.5 MB — a smaller one, please"); fbImage = null; fbPreview = null; return; }
fbImage = file;
fbPreview = file ? URL.createObjectURL(file) : null;
}
function submitFeedback() {
net.sendFeedback(fbHappened.trim(), fbExpected.trim(), fbImage);
fbSent = true;
setTimeout(() => {
feedbackOpen = false;
fbSent = false;
fbHappened = "";
fbExpected = "";
if (fbPreview) URL.revokeObjectURL(fbPreview);
fbImage = null;
fbPreview = null;
}, 1600);
}
/** First tap of marching the Democratic Monster onto YOURSELF: warn once. */
let dmWarnCell = $state<string | null>(null);
// In a lobby before the deal: claim the preferred color when it is free
// and this seat has not chosen. One quiet attempt per availability change.
$effect(() => {
if (view || !net.roomId || !net.you || prefs.color == null) return;
if (net.roomColors[net.you] !== undefined) return;
const takenBy = Object.entries(net.roomColors).find(([, v]) => v === prefs.color)?.[0];
if (takenBy === undefined) net.pickColor(prefs.color);
});
function setPref<K extends keyof typeof prefs>(k: K, v: (typeof prefs)[K]) {
prefs[k] = v;
savePrefs();
}
// A shimmering illusion wall, tapped or bumped: offer the belief test.
let illusionPrompt = $state<{ cell: { x: number; y: number }; side: Side } | null>(null);
function illusionEdgeParts(key: string): { cell: { x: number; y: number }; side: Side } {
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return { cell: { x, y }, side: kind === "V" ? "E" : "S" };
}
function rollIllusion() {
if (!illusionPrompt) return;
dispatch({ type: "testIllusion", cell: illusionPrompt.cell, side: illusionPrompt.side });
illusionPrompt = null;
}
// The council of three: what each temperament would do from your seat.
// Its advice follows the board while the panel is open — a card just
// played is no longer recommended — and rests while it is not your turn.
let councilOpen = $state(false);
let hintsModule = $state<typeof import("./hints") | null>(null);
const councilHints = $derived(
councilOpen && isYourTurn && view && hintsModule ? hintsModule.tableHints(view) : null,
);
async function askCouncil() {
if (!view) return;
hintsModule ??= await import("./hints");
councilOpen = true;
}
/** "Play now": one tap from the landing to a game against a clockwork
* wizard. The room is created, a bot seated, the boards flipped, each
* step as the server confirms the last; the same chain re-seats the
* bots of a finished game for "play again". */
let playNowPending = $state<{ stage: "create" | "seating" | "starting"; bots: { style?: string; tier: string }[]; seated: number } | null>(null);
function playNow() {
setPref("wizardName", name.trim());
playNowPending = { stage: "create", bots: [{ tier: prefs.botTier }], seated: 0 };
net.create(name.trim());
}
function playAgain() {
// The bots of the table just finished, as the roster names them ("adept hunter").
const bots = Object.values(net.roomBots).map((label) => {
const [tier, style] = String(label).split(" ");
return { tier: tier ?? prefs.botTier, style: style && style !== "mystery" ? style : undefined };
});
const me = net.you ?? name.trim();
net.leave();
playNowPending = { stage: "create", bots: bots.length ? bots : [{ tier: prefs.botTier }], seated: 0 };
setTimeout(() => net.create(me), 300);
}
$effect(() => {
const p = playNowPending;
if (!p || !net.roomId || net.you !== net.hostId) return;
if (net.started) { playNowPending = null; return; }
const botsNow = Object.keys(net.roomBots).length;
if (p.stage !== "starting" && botsNow < p.bots.length && botsNow === p.seated) {
const b = p.bots[botsNow]!;
net.addBot(b.style, b.tier);
playNowPending = { ...p, stage: "seating", seated: botsNow + 1 };
} else if (p.stage !== "starting" && botsNow >= p.bots.length && net.players.length >= 2) {
net.start(withExpansion);
playNowPending = { ...p, stage: "starting" };
}
});
/** The whole tale, as a link to hand around. */
let taleState = $state<"idle" | "minting" | "copied" | "failed">("idle");
async function shareTale() {
if (taleState === "minting") return;
taleState = "minting";
try {
const url = await net.requestShare(-1);
await navigator.clipboard.writeText(url);
taleState = "copied";
} catch {
taleState = "failed";
}
setTimeout(() => (taleState = "idle"), 4000);
}
/** A finished game counts toward this browser's experience: after the
* first, the automatons step up from apprentice unless the tier was
* picked by hand. */
let finishedRoom: string | null = null;
$effect(() => {
if (view?.phase !== "finished" || !view.winner || local.active) return;
const room = net.roomId;
if (!room || finishedRoom === room) return;
finishedRoom = room;
setPref("finishedGames", prefs.finishedGames + 1);
if (!prefs.tierChosen && prefs.botTier === "apprentice") setPref("botTier", "adept");
});
/** On a first turn the council speaks unasked, once — a newcomer will
* not know to ask, and a minute's silence is how they leave. */
let counciled = false;
/** The unasked advice is for that one turn: it leaves when the turn does. */
let councilUnasked = $state(false);
$effect(() => {
if (!view || !isYourTurn || prefs.coached || view.turn.round !== 1 || counciled || councilOpen) return;
counciled = true;
councilUnasked = true;
void askCouncil();
});
$effect(() => {
// Read the turn before anything else: an early return that never
// touches it would leave this effect with nothing to wake it.
const stillFirstTurn = isYourTurn && view?.turn.round === 1;
if (!councilUnasked || stillFirstTurn) return;
councilOpen = false;
councilUnasked = false;
});
/** Arrow keys and WASD walk on the board, as they do in the first-person
* pane — that pane takes the keys itself while it is up. */
function onBoardKey(e: KeyboardEvent) {
if (!view || !isYourTurn || prefs.liveFp || net.spectating || showHelp || caution || youMustRespond) return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
const k = e.key.toLowerCase();
const side: Side | null =
k === "arrowup" || k === "w" ? "N" : k === "arrowdown" || k === "s" ? "S" :
k === "arrowleft" || k === "a" ? "W" : k === "arrowright" || k === "d" ? "E" : null;
if (!side) return;
e.preventDefault();
tryMove(side);
}
let pickChoice = $state(false);
function pickUp() {
if (treasuresHere.length > 1) { pickChoice = true; return; }
dispatch({ type: "pickUpTreasure" });
}
function pickUpNamed(treasureId: string) {
pickChoice = false;
dispatch({ type: "pickUpTreasure", treasureId });
}
function drop() { dispatch({ type: "dropTreasure" }); }
function pass() { dispatch({ type: "pass" }); }
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
/** Enemy treasures resting on your home square: two wins the game. */
const treasuresHome = $derived(
view && me ? view.treasures.filter((t) => t.owner !== me!.id && t.position &&
t.position.x === me!.home.x && t.position.y === me!.home.y).length : 0,
);
const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []);
/** Two treasures can share a square: one dispatches, more ask whose. */
const treasuresHere = $derived(
view && me
? view.treasures.filter((t) => t.position && t.position.x === me.position.x &&
t.position.y === me.position.y && !t.carriedBy)
: [],
);
const carryingTreasure = $derived(me?.carriedTreasureId != null);
const treasureHere = $derived(treasuresHere.length > 0);
/** Co-located carriers STRENGTH lets you wrestle (the tear is an attack). */
const tearTargets = $derived(
view && me && view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)
? view.players.filter((p) => p.alive && p.id !== view.you && p.carriedTreasureId &&
cellKey(p.position) === cellKey(me!.position))
: [],
);
/** The attack on the stack draws its sight line; a cast that resolved
* at once (a creation, a curse) draws the line it was accepted on for a
* few seconds, so the table can see how the aim was legal. */
/** The hour where the keeper lives, in words, kept current by the minute. */
let minuteTick = $state(0);
$effect(() => {
const id = setInterval(() => { minuteTick++; }, 60_000);
return () => clearInterval(id);
});
const keeperHour = $derived.by(() => {
void minuteTick;
if (!net.keeperZone) return null;
try {
const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone: net.keeperZone }).format(new Date())) % 24;
const word = ["twelve", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven"][hour % 12];
const part = hour < 5 ? "in the small hours" : hour < 12 ? "in the morning" : hour < 18 ? "in the afternoon" : "in the evening";
return `${word} ${part}`;
} catch { return null; }
});
let castTrace = $state.raw<SightLine | null>(null);
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
/** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
/** The step light: with no card in hand, your square and the squares a
* stride reaches are lit and the rest of the maze falls into shadow. */
const stepLight = $derived(
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
);
const stepCells = $derived.by(() => {
if (!stepLight || !view || !me) return [];
return SIDES.map((side) => stepTarget(view!.board, me!.position, side))
.filter((t) => t.kind !== "blocked")
.map((t) => cellKey(t.to));
});
/** The first stride of a first game gets one line above the board —
* off the maze, so the walls around the wizard stay in plain sight. */
const stepCaption = $derived(stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0);
const litCells = $derived.by(() => {
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
if (!view || !selectedDef || !yourMoment) return null;
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
if (!me) return null;
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId, bent, castingDouble?.position);
}
if (selectedDef.adjacent === true) {
const { x, y } = castingDouble?.position ?? 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;
return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
});
/** 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);
/** The chronicle follows the newest line only while the reader is at
* the bottom; scrolled back into history, it holds still. */
let chronicleStick = true;
function onChronicleScroll() {
if (!chronicleEl) return;
chronicleStick =
chronicleEl.scrollHeight - chronicleEl.scrollTop - chronicleEl.clientHeight < 60;
}
$effect(() => {
void net.log.length;
void local.log.length;
if (chronicleEl && chronicleStick) 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>
{#snippet sayBox(extra: string, withDie: boolean)}
<form class="say-box {extra}" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (!t) return;
if (net.sendChat(t)) chatDraft = "";
else net.flash("Not sent — the table is out of reach; your words wait in the box");
}}>
{#if net.chatPending}<span class="sending">sending…</span>{/if}
<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>
{#if withDie}
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
{/if}
</form>
{/snippet}
{#snippet gallerySayBox(extra: string)}
<form class="say-box gallery-say {extra}" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
const who = name.trim();
if (!t || !who) return;
setPref("wizardName", who);
if (net.sendChat(t, who)) chatDraft = "";
else net.flash("Not sent — the table is out of reach; your words wait in the box");
}}>
{#if net.chatPending}<span class="sending">sending…</span>{/if}
<input class="say-name" bind:value={name} maxlength="20" placeholder="your name" aria-label="your name in the gallery" />
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something from the gallery…" aria-label="gallery talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim() || !name.trim()}>say</button>
</form>
{/snippet}
<svelte:window onkeydown={onBoardKey} />
{#if fpvWorkshop || tokenWorkshop || fxWorkshop}
<!-- The workshops are back rooms: the masthead still says where the door is. -->
<header class="masthead workshop-mast">
<a class="mast-title mast-home" href="/">Wiz-War</a>
<span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span>
<a class="mast-leave" href="/">← back to the table</a>
<a class="mast-leave" href="/clips">the clips</a>
<a class="mast-leave mast-right" href="/?tokens">tokens</a>
<a class="mast-leave" href="/?fx">flourishes</a>
<a class="mast-leave" href="/?fpv">eyes</a>
</header>
{#if fpvWorkshop}
<FpvWorkshop />
{:else if tokenWorkshop}
<TokenGallery />
{:else}
<FxGallery />
{/if}
{:else}
<div class="table">
<header class="masthead">
<span class="mast-title">Wiz-War</span>
<span class="mast-sub">sixth edition</span>
<span class="mast-version" title="deployed">{__BUILD_STAMP__}</span>
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}>⚙ preferences</button>
{#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}
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}>
{#if net.spectating}watching{:else}<span class="mast-invite">invite friends · </span>room{/if} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.you === net.hostId && net.roomId && (net.audience > 0 || net.galleryTalk)}
<button class="mast-audience as-button" class:talking={net.galleryTalk}
title={net.galleryTalk ? "the Peanut Gallery may talk — click to hush it" : "the Peanut Gallery listens — click to let it talk"}
onclick={() => net.setGalleryTalk(!net.galleryTalk)}>👁 {net.audience}{net.galleryTalk ? " 💬" : ""}</button>
{:else if net.audience > 0}
<span class="mast-audience" title={net.galleryTalk ? "the Peanut Gallery, allowed to talk" : "the Peanut Gallery"}>👁 {net.audience}{net.galleryTalk ? " 💬" : ""}</span>
{/if}
{#if !net.spectating}
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
{/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
<button class="mast-leave" title="something behaved unexpectedly? tell the wizards"
onclick={() => (feedbackOpen = true)}>🐞 report</button>
{/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = null; showHelp = true; }}>
help &amp; rules
</button>
<span class="mast-status" class:offline={net.status !== "connected"}>
{net.status === "connected" ? "" : "reconnecting…"}
</span>
</header>
{#if offline}
<div class="slip offline-slip" role="alert">
<strong>Connection lost — reconnecting.</strong> The table waits; your cards stay in hand.
{#if net.unsent}<span class="offline-note">{net.unsent[0]!.toUpperCase()}{net.unsent.slice(1)} was not sent. Try again once the table is back.</span>{/if}
{#if net.unconfirmed}<span class="offline-note">{net.unconfirmed[0]!.toUpperCase()}{net.unconfirmed.slice(1)} may not have reached the table; the board will say when the connection returns.</span>{/if}
</div>
{/if}
{#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 || peekTreasure || peekEdge || peekEffect}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => { peekCard = null; peekCreatureId = null; peekToken = null; discardPeek = null; peekTreasure = null; peekEdge = null; peekEffect = null; }} onkeydown={() => {}}>
<div class="big-peek">
{#if peekEffect}
{#if peekCard}
<div class="card-stage">
<Card card={peekCard} onfaq={(id) => (faqCardId = id)} />
</div>
{/if}
<div class="peek-note big-peek-note effect-note">
<strong>{spellName(peekEffect.cardId)}</strong> — {effectNote(peekEffect)}
</div>
{:else if peekEdge}
<div class="peek-note big-peek-note">{peekEdge}</div>
{:else if peekTreasure}
<img class="big-peek-token" src={peekTreasure.src} alt="" />
<div class="peek-note big-peek-note">{peekTreasure.note}</div>
{:else}
{#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}
{/if}
</div>
</div>
{/if}
{#if illusionPrompt}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (illusionPrompt = null)} onkeydown={() => {}}>
<div class="attack-notice" role="dialog" aria-label="a shimmering wall" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">That wall shimmers strangely…</div>
<div class="attack-power">
Someone conjured an ILLUSION WALL here. Whether YOUR eyes are fooled
is a 50/50 roll — once. Believe it, and it is stone to you until
dispelled; see through it, and you may walk it like open floor.
Other wizards' eyes rule for themselves.
</div>
<div class="attack-actions">
{#if isYourTurn}
<button class="stamp primary" onclick={rollIllusion}>Roll to test your eyes</button>
{:else}
<span class="attack-power">— you can test it on your turn —</span>
{/if}
<button class="hint-cancel" onclick={() => (illusionPrompt = null)}>leave it be</button>
</div>
</div>
</div>
{/if}
{#if view?.wardPending}
{#if view.wardPending.ownerId === view.you}
<div class="scrim attack-scrim" role="alertdialog" aria-label="your treasure is taken">
<div class="attack-notice">
<div class="attack-title">{view.wardPending.takerId} grabs your treasure!</div>
<div class="attack-power">
"When a player picks up one of your treasures, you may play at
that time (out of turn) this card on him." Three points, and the
Ward is spent.
</div>
<div class="attack-actions">
<button class="stamp primary" onclick={() => dispatch({ type: "wardChoice", play: true })}>Spring the Ward!</button>
<button class="hint-cancel" onclick={() => dispatch({ type: "wardChoice", play: false })}>let them go</button>
</div>
</div>
</div>
{/if}
{/if}
{#if megaPrompt && view}
{@const beast = view.creatures.find((c) => c.id === megaPrompt!.creatureId)}
<div class="scrim attack-scrim" role="alertdialog" aria-label="mega-monster">
<div class="attack-notice">
<div class="attack-title">Mega-Monster: the {beast ? cardDef(beast.kind).name : "monster"} grows</div>
<div class="attack-power">
"Doubles the existing life-points OR movement rate of any one monster." Which?
{#if beast}
Now {Number.isFinite(beast.maxDamage) ? `${beast.maxDamage - beast.damage} of ${beast.maxDamage} hits` : "unkillable"}
and {beast.movesPerTurn} move{beast.movesPerTurn === 1 ? "" : "s"} a turn.
{/if}
</div>
<div class="attack-actions">
<button class="stamp primary" onclick={() => castMega("life")}>Double its life-points</button>
<button class="stamp primary" onclick={() => castMega("movement")}>Double its movement</button>
<button class="hint-cancel" onclick={() => (megaPrompt = null)}>never mind</button>
</div>
</div>
</div>
{/if}
{#if view?.pushPending && view.pushPending.pusheeId === view.you}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are pushed">
<div class="attack-notice">
<div class="attack-title">{view.pushPending.giantId} bears down on you!</div>
<div class="attack-power">
The giant fills the corridor and shoves you ahead of him. The way
forks here — which way do you go?
</div>
<div class="attack-actions">
{#each view.pushPending.exits as d (d)}
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}>
{SIDE_NAMES[d]}
</button>
{/each}
</div>
</div>
</div>
{/if}
{#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you}
{@const sdPoints = view.slowDeathPending.points}
{@const sdAbsorb = view.yourHand.find((c) => c.cardId === "absorb")}
{@const sdBlunt = view.yourHand.find((c) => c.cardId === "blunt")}
<div class="scrim attack-scrim" role="alertdialog" aria-label="slow death bites">
<div class="attack-notice">
<div class="attack-title">Slow Death bites for {sdPoints}!</div>
<div class="attack-power">
The draw's bites land as one blow — counter the total: Absorb
soaks up to three points, Blunt halves it (fractions round up).
</div>
<div class="attack-actions">
{#if sdAbsorb}
<button class="stamp primary"
onclick={() => dispatch({ type: "slowDeathChoice", counterInstanceId: sdAbsorb.instanceId })}>
Absorb — take {Math.max(0, sdPoints - 3)} instead</button>
{/if}
{#if sdBlunt}
<button class="stamp" disabled={Math.ceil(sdPoints / 2) >= sdPoints}
onclick={() => dispatch({ type: "slowDeathChoice", counterInstanceId: sdBlunt.instanceId })}>
Blunt — take {Math.ceil(sdPoints / 2)} instead</button>
{/if}
<button class="hint-cancel" onclick={() => dispatch({ type: "slowDeathChoice" })}>
take {sdPoints === 1 ? "the point" : `all ${sdPoints}`}</button>
</div>
</div>
</div>
{/if}
{#if feedbackOpen && net.roomId}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
<div class="attack-notice prefs-slip" role="dialog" aria-label="report a surprise" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">Something unexpected?</div>
{#if fbSent}
<p class="fb-thanks">Recorded beside the room's ledger — thank you. The wizards will study the moment.</p>
{:else}
<label class="fb-field">
<span>What happened?</span>
<textarea bind:value={fbHappened} rows="3" maxlength="2000"
placeholder="e.g. my fireball hit but no damage was taken"></textarea>
</label>
<label class="fb-field">
<span>What did you expect?</span>
<textarea bind:value={fbExpected} rows="3" maxlength="2000"
placeholder="e.g. 5 points of damage"></textarea>
</label>
<label class="fb-field fb-picture">
<span>A screenshot, if you have one</span>
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if fbPreview}<img class="fb-preview" src={fbPreview} alt="the screenshot you chose" />{/if}
</label>
<p class="fb-note">The room code and move number ride along automatically; a picture is worth a thousand of them.</p>
<div class="attack-actions">
<button class="stamp primary" disabled={!fbHappened.trim()} onclick={submitFeedback}>Send it</button>
<button class="hint-cancel" onclick={() => (feedbackOpen = false)}>never mind</button>
</div>
{/if}
</div>
</div>
{/if}
{#if prefsOpen}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
<div class="attack-notice prefs-slip" role="dialog" aria-label="preferences" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">Preferences</div>
<div class="pref-row">
<span>Token art</span>
<button class="stamp tiny" class:lit={prefs.art === "photo"}
onclick={() => setPref("art", "photo")}>cardboard</button>
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
onclick={() => setPref("art", "drawn")}>alternate</button>
</div>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.autoGrab}
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects — off as well whenever your system asks for reduced motion)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.liveFp}
onchange={(e) => setPref("liveFp", e.currentTarget.checked)} />
<span>Through your eyes: a first-person pane above the board during play</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.instantReplay}
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={handLeftOn}
onchange={(e) => { setPref("handLeft", e.currentTarget.checked); setPref("handLeftChosen", true); }} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens; a wide, short window does this on its own until you choose)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.stepLightAlways}
onchange={(e) => setPref("stepLightAlways", e.currentTarget.checked)} />
<span>Step light on every turn: the squares you can walk to glow and the rest of the maze dims (a first game lights its first turn on its own)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.cautions}
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
</label>
<div class="pref-row">
<span>Automatons default to</span>
{#each ["apprentice", "adept", "archmage"] as const as t (t)}
<button class="stamp tiny" class:lit={prefs.botTier === t}
onclick={() => { setPref("botTier", t); botTier = t; }}>{t}</button>
{/each}
</div>
<label class="pref-row">
<span>Wizard name</span>
<input class="setup-input pref-name" maxlength="20" value={prefs.wizardName}
placeholder="e.g. Mordecai"
onchange={(e) => { setPref("wizardName", e.currentTarget.value.trim()); name = prefs.wizardName; }} />
</label>
<div class="pref-row">
<span>Preferred color</span>
{#each [0, 1, 2, 3, 4, 5] as c (c)}
<button class="pref-swatch" class:current={prefs.color === c}
style:background={PLAYER_COLORS[c]}
aria-label={`prefer wizard color ${c + 1}`}
onclick={() => setPref("color", prefs.color === c ? null : c)}></button>
{/each}
</div>
<div class="pref-note">Saved on this device. Rules are never affected.</div>
<div class="attack-actions">
<button class="hint-cancel" onclick={() => (prefsOpen = false)}>done</button>
</div>
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
{#if showHelp}
<Help initialTab={helpTab} initialAnchor={helpAnchor} initialCard={helpCard} stats={net.stats}
onstats={() => net.requestStats()} onnavigate={helpNavigate} onclose={helpClose} />
{/if}
{#if view?.phase === "finished" && view.winner && victoryCurtain && !victorySeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
<div class="attack-notice victory-notice">
<div class="victory-trophy">🏆</div>
<div class="victory-name">{view.winner}</div>
<div class="victory-how">
{#if view.winReason === "treasures"}
carried two stolen treasures home — the maze is theirs
{:else if view.winReason === "lastStanding"}
the last wizard standing
{:else}
wins the game
{/if}
</div>
<button class="stamp big" onclick={() => (victorySeen = true)}>Behold the final board</button>
</div>
</div>
{/if}
{#if trapNotice}
<div class="scrim attack-scrim" role="alertdialog" aria-label="a trap card">
<div class="attack-notice">
<div class="attack-headline">A cruel draw!</div>
<div class="attack-card"><Card card={{ instanceId: "trap-notice", cardId: trapNotice }} /></div>
<div class="attack-power">
{trapNotice === "gift-from-below"
? "It bites for 3 points, then the deck deals you another."
: "The trap snaps shut — you lose a turn."}
</div>
<button class="stamp big" onclick={() => (trapNotice = null)}>Curse the deck</button>
</div>
</div>
{/if}
{#if caution}
<div class="scrim attack-scrim" role="alertdialog" aria-label="are you sure?">
<div class="attack-notice">
<div class="attack-headline">Hold on —</div>
<div class="attack-power">
{#each caution.reasons as r (r)}
<div class="caution-reason">{r}</div>
{/each}
</div>
<div class="attack-actions">
<button class="stamp big" onclick={() => { const go = caution!.go; caution = null; go(); }}>Do it anyway</button>
<button class="hint-cancel" onclick={() => (caution = null)}>never mind</button>
</div>
</div>
</div>
{/if}
{#if cardReveal}
<div class="scrim attack-scrim" role="alertdialog" aria-label="cards revealed to you">
<div class="attack-notice mind-read">
<div class="attack-headline">{cardReveal.title}</div>
<div class="mind-cards">
{#each cardReveal.cards as card (card.instanceId)}
<Card {card} onfaq={(id) => (faqCardId = id)} />
{/each}
{#if cardReveal.cards.length === 0}
<div class="attack-power">Nothing — their thoughts are empty-handed.</div>
{/if}
</div>
<div class="attack-power">
{cardReveal.cards.length > 0
? `${cardReveal.cards.length} card${cardReveal.cards.length === 1 ? "" : "s"} — ${cardReveal.note}`
: ""}
</div>
<button class="stamp big" onclick={() => (cardReveal = null)}>Enough</button>
</div>
</div>
{/if}
{#if wardBite}
<div class="scrim attack-scrim" role="alertdialog" aria-label="a warded treasure">
<div class="attack-notice">
<div class="attack-headline">The treasure bites back!</div>
<div class="attack-card"><Card card={{ instanceId: "ward-notice", cardId: "ward" }} /></div>
<div class="attack-power">
{wardBite.pending
? `${wardBite.owner}'s treasure was warded — it bites for 3 unless you answer it. Your counteractions come next.`
: wardBite.amount > 0
? `${wardBite.owner}'s treasure was warded — it bites you for ${wardBite.amount} point${wardBite.amount === 1 ? "" : "s"}.`
: `${wardBite.owner}'s treasure was warded — but its bite finds no purchase.`}
</div>
<button class="stamp big" onclick={() => (wardBite = null)}>{wardBite.pending ? "Face it" : "Grit your teeth"}</button>
</div>
</div>
{/if}
{#if view && view.outOfTurnWindow?.playerId === view.you && !momentSeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="your interruption">
<div class="attack-notice">
<div class="attack-headline">The maze holds its breath</div>
<div class="attack-card"><Card card={{ instanceId: "moment", cardId: view.outOfTurnWindow.kind }} /></div>
<div class="attack-power">
Time parts for your {cardDef(view.outOfTurnWindow.kind).name} —
pick an attack card and a target, or let the moment pass.
</div>
<button class="stamp big" onclick={() => (momentSeen = true)}>Seize it</button>
</div>
</div>
{/if}
{#if openingRolls}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the roll for first wizard">
<div class="attack-notice">
<div class="attack-headline">The dice decide who casts first</div>
<div class="rolloff">
{#each openingRolls.players as p (p)}
<div class="rolloff-row" class:rolloff-winner={p === openingRolls.first}>
<span class="rolloff-name">{p === openingRolls.first ? "🏆 " : ""}{p}</span>
<span class="rolloff-dice">
{#each openingRolls.rolls[p] ?? [] as r, i (i)}<span class="die-face">🎲{r}</span>{/each}
</span>
</div>
{/each}
</div>
{#if Object.values(openingRolls.rolls).some((r) => r.length > 1)}
<div class="rolloff-note">Tied wizards rolled again.</div>
{/if}
<button class="stamp big" onclick={dismissRolls}>{openingRolls.first} leads — begin!</button>
</div>
</div>
{/if}
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey && !noFanfare}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice">
<div class="attack-headline">
{#if view.stack.defenderId === view.you && attackingCreature}
<strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
{:else if view.stack.defenderId === view.you && view.stack.reflectedBase}
Your own spell comes screaming back!
{:else if view.stack.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you!
{:else}
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
{/if}
</div>
{#if view.stack.defenderId === view.you && attackingCreature}
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
{@const illusionOf = view.stack.attackCard.cardId === "illusionary-attack"
? (view.stack.params?.cardId ?? null) : null}
{#if illusionOf}
<div class="attack-card illusion-pair">
<Card card={{ instanceId: "illusion-named", cardId: illusionOf }} onfaq={(id) => (faqCardId = id)} />
<Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} />
</div>
<div class="attack-power">an ILLUSIONARY {cardDef(illusionOf).name} — it bites only if believed</div>
{:else}
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
{/if}
{#if view.stack.reflectedBase}
<div class="attack-power">
REFLECTED — {view.stack.reflectedBase.damage} damage inbound unless you answer it
</div>
{:else if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
<div class="attack-power">
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
{view.stack.amplifyFactor > 1 ? ` — AMPLIFIED ×${view.stack.amplifyFactor}` : ""}
</div>
{/if}
{:else if view.stack.defenderId === view.you && view.stack.sourceName}
<!-- A cardless bite with a name — the WARD springing on a grabbed
treasure. The card that did it is shown; nobody threw it. -->
{#if BLOW_CARD[view.stack.sourceName]}
<div class="attack-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: BLOW_CARD[view.stack.sourceName]! }} onfaq={(id) => (faqCardId = id)} /></div>
{/if}
<div class="attack-power">
{view.stack.sourceName.toUpperCase()} — {view.stack.params?.damage ?? 0} damage inbound unless you answer it
</div>
{:else if view.stack.defenderId === view.you}
<div class="attack-fist">
<svg viewBox="-55 -50 110 100" width="30" height="28" style="vertical-align: middle; margin-right: 4px" aria-hidden="true">
{#each FISTS.in as cel}
<path d={cel.d} fill={cel.fill ?? "none"} fill-rule="evenodd" stroke={cel.stroke ?? "#2b2218"} stroke-width={cel.width ?? 2.5} stroke-linejoin="round" stroke-linecap="round" />
{/each}
</svg>
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.moment && net.moment.steps.length > 0}
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} startAt={net.catchUpStart}
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
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="lobby-head">Your color</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 && !openingRolls}
<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 landing">
<div class="lid-head">
<div class="boxlid-title">Wiz-War</div>
<div class="boxlid-pitch">Steal treasure. Blast through walls. Outwit your friends.</div>
<div class="boxlid-tag">A turn-based wizard board game for two to six, free in your browser, with
friends or clockwork opponents. Carry home two treasures that aren't yours, or be the last wizard standing.</div>
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
about this game — a labor of love
</button>
</div>
<div class="lid-play">
<label class="field">
<span>Your wizard's name</span>
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
</label>
<button class="stamp primary play-now" disabled={!name.trim() || playNowPending !== null} onclick={playNow}>
{playNowPending ? "Flipping the boards…" : "Play now against a clockwork wizard"}
</button>
<div class="boxlid-actions">
<button class="stamp" disabled={!name.trim()} onclick={() => { setPref("wizardName", name.trim()); 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={() => { setPref("wizardName", name.trim()); net.join(joinCode, name); }}>
Join
</button>
</div>
<div class="gallery-row">
<button class="hint-cancel" disabled={!joinCode.trim()} onclick={() => net.watch(joinCode)}>
👁 …or just watch that game from the Peanut Gallery
</button>
</div>
</div>
<div class="lid-look">
<a class="hero" href="/clips" aria-label="watch clips from the labyrinth">
<img src="/hero.jpg" alt="Through a wizard's eyes: an ambush watches its corridor through a wormhole" />
</a>
<a class="about-link clips-link" href="/clips">watch clips from the labyrinth</a>
{#if family.length}
<p class="family-line">Also at the Kestrel's Nest:
{#each family as g, i (g.slug)}{i > 0 ? ", " : " "}<a href={g.url} title={g.pitch}>{g.name}</a>{/each}</p>
{/if}
</div>
<details class="more-ways" open={local.hasSave()}>
<summary>More ways to play — pass one device around, or claim a transferred seat</summary>
<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>
</details>
{#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`}{#if g.rematch}&nbsp;· {g.rematch.by} called a rematch{/if}
{: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>
{#if g?.rematch && g.rematch.by !== seat.name}
<button class="stamp tiny" title="take your seat at the rematch"
onclick={() => { net.you = seat.name; net.spectating = false; net.join(g.rematch!.roomId, seat.name); }}>rematch</button>
{/if}
<button class="ledger-forget" title="forget this game"
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
</div>
{/each}
{#if net.feedbackReports.length > 0}
<div class="reports-head">your reports to the wizards</div>
{#each net.feedbackReports as r (r.id)}
{@const thread = r.thread ?? []}
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<div class="report-text">“{r.happened}”{#if r.image}<span class="report-pic" title="with your screenshot"> 📷</span>{/if}</div>
{#each thread as line, j (j)}
{#if line.from === "player"}
<div class="report-reply yours">you — {line.text}</div>
{:else}
<div class="report-reply"><span class="report-status">{line.status}</span> — {line.text}</div>
{/if}
{/each}
{#if !thread.length}
<div class="report-reply pending">the wizards are studying the moment…</div>
{:else if thread[thread.length - 1]!.from === "player"}
<div class="report-reply pending">your word is with the wizards…</div>
{/if}
{#if thread.some((l) => l.from === "desk")}
<form class="report-answer" onsubmit={(e) => {
e.preventDefault();
const t = (reportAnswers[r.id] ?? "").trim();
if (t && net.answerReport(r, t)) reportAnswers = { ...reportAnswers, [r.id]: "" };
}}>
<input bind:value={reportAnswers[r.id]} maxlength="2000" placeholder="answer the wizards…" aria-label="answer the wizards" />
<button class="stamp tiny" type="submit" disabled={!(reportAnswers[r.id] ?? "").trim()}>send</button>
</form>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
</section>
{:else if !local.active && !net.started}
<section class="boxlid">
<div class="boxlid-inner">
<div class="boxlid-title small">Gather your wizards</div>
<div class="lobby-sub">Room <strong class="room-code">{net.roomId}</strong> · {net.players.length} of 6 seats filled</div>
{#if net.spectating}
{#if net.players.length < 6}
<!-- A friend's link lands here: the seat is the invitation, the gallery the afterthought. -->
<div class="lobby-head">Join {net.hostId ?? "the"}'s game</div>
<div class="boxlid-tag">Choose your wizard's name and take a seat; the boards flip when the host starts.</div>
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp primary" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
Take a seat
</button>
</div>
<p class="waiting">👁 Just watching? You're in the Peanut Gallery until you sit.</p>
{:else}
<p class="waiting">👁 The table is full — you watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{/if}
{/if}
<ul class="table-roster" aria-label="the table">
{#each net.players as p (p)}
{@const chosen = net.roomColors[p]}
{@const isYou = p === net.you}
<li class="seat" class:you={isYou}>
{#if chosen !== undefined}
<img class="seat-portrait" src={tokenArt(`wizard-${chosen}`, "players")} alt="" />
{:else}
<span class="seat-portrait empty"></span>
{/if}
<span class="seat-text">
<span class="seat-name">{p}{isYou ? " (you)" : ""}</span>
<span class="seat-role">
{#if net.roomBots[p]}⚙ {net.roomBots[p]}{:else if chosen === undefined}{p === net.hostId ? "host · " : ""}choosing a wizard…{:else if p === net.hostId}host{:else}wizard{/if}
</span>
</span>
{#if isYou && !net.spectating}
<button class="stamp tiny" onclick={() => (colorPickerToggled = !colorPickerOpen)} aria-expanded={colorPickerOpen}>
{colorPickerOpen ? "done" : "change wizard"}</button>
{/if}
{#if net.you === net.hostId && p !== net.hostId}
<button class="stamp tiny kick" title="clear this seat" aria-label={`clear ${p}'s seat`}
onclick={() => net.kickSeat(p)}>✕</button>
{/if}
</li>
{#if isYou && !net.spectating && colorPickerOpen}
<li class="color-pick" 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.roomColors[p] === c}
class:taken={takenBy !== undefined && takenBy !== net.you}
disabled={takenBy !== undefined && takenBy !== net.you}
style:--ring={PLAYER_COLORS[c]}
onclick={() => { net.pickColor(c); colorPickerToggled = null; }}
aria-label={`wizard ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
>
<img src={tokenArt(`wizard-${c}`, "players")} alt="" />
</button>
{/each}
</li>
{/if}
{/each}
{#each net.expected.filter((n) => !net.players.includes(n)) as n (n)}
<li class="seat open ghost">
<span class="seat-portrait empty"></span>
<span class="seat-text">
<span class="seat-name">{n}</span>
<span class="seat-role">{net.challenge && n === net.keeper ? `called to the table by ${net.challenge.by} — awaiting an answer` : "from the last table — not yet seated"}</span>
</span>
</li>
{/each}
{#if net.players.length < 6}
<li class="seat open">
<span class="seat-portrait empty"></span>
<span class="seat-text">
<span class="seat-name">An empty seat</span>
<span class="seat-role">{net.players.length < 2 ? "one more wizard and the boards can flip" : "room for another"}</span>
</span>
</li>
{/if}
</ul>
{#if !net.spectating}
<div class="fill-row">
<button class="stamp" onclick={copyInvite}>{inviteCopied ? "Invite link copied" : "Copy invite link"}</button>
{#if net.you === net.hostId && net.players.length < 6}
<button class="stamp" class:current={addingBot} onclick={() => (addingBot = !addingBot)} aria-expanded={addingBot}>
{addingBot ? "Hide computer options" : "Add a computer wizard"}</button>
{/if}
</div>
<details class="invite-fallback">
<summary>the link itself, for pasting by hand</summary>
<code class="invite-link">{inviteLink}</code>
</details>
{#if net.keeper && !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
<div class="challenge-row">
<button class="stamp quiet-lid" onclick={() => net.challengeKeeper()}
title="a raven flies to {net.keeper}, who keeps this table">
⚔ Challenge {net.keeper}, the keeper</button>
<div class="challenge-note">{net.keeper} keeps this table and answers every raven — sometimes within the minute,
sometimes after a night's sleep. {keeperHour ? `It is ${keeperHour} where ${net.keeper} lives.` : ""} The seat is held either way.</div>
</div>
{:else if net.challenge}
<div class="challenge-note">The raven has flown to {net.keeper}, called by {net.challenge.by}.
{keeperHour ? `It is ${keeperHour} there.` : ""} A seat is held for them:
if they can come now, they will take it; if they are at supper, or asleep, or off in the world, they will
leave a word below when they can. Meanwhile the table is yours — seat a clockwork wizard, invite a friend,
or open another game in a new tab and play on; this room keeps its place on your ledger until you come
back, as long as nobody abandons it.</div>
{/if}
<!-- Talk before the boards flip: to say when, or to leave a word. -->
{@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
<div class="lobby-talk">
{#each lobbyTalk as line, i (i)}
<div class="lobby-line">{line.text}</div>
{/each}
{@render sayBox("lobby-say", false)}
</div>
{#if net.you === net.hostId && addingBot && net.players.length < 6}
<div class="bot-panel">
<div class="bot-field">
<span class="bot-field-label">Difficulty</span>
<span class="seg" role="group" aria-label="automaton difficulty">
{#each ["apprentice", "adept", "archmage"] as t (t)}
<button class:current={botTier === t}
onclick={() => { botTier = t as typeof botTier; setPref("botTier", botTier); setPref("tierChosen", true); }}>{t}</button>
{/each}
</span>
</div>
<div class="bot-field">
<span class="bot-field-label">Personality</span>
<span class="seg" role="group" aria-label="automaton personality">
{#each ["hunter", "berserker", "worrier", "mystery"] as st (st)}
<button class:current={botStyle === st} onclick={() => (botStyle = st as typeof botStyle)}>{st}</button>
{/each}
</span>
</div>
<div class="bot-blurb">{BOT_BLURBS[botStyle]}</div>
<button class="stamp primary" onclick={() => net.addBot(botStyle === "mystery" ? undefined : botStyle, botTier)}>
Add computer wizard</button>
</div>
{/if}
{:else}
{@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
<div class="lobby-talk">
{#each galleryTalkLines as line, i (i)}
<div class="lobby-line">{line.text}</div>
{/each}
{#if net.galleryTalk}
{@render gallerySayBox("lobby-say")}
{:else}
<p class="waiting">👁 The gallery listens; the host has not opened its mouth.</p>
{/if}
</div>
{/if}
{#if net.you === net.hostId}
<label class="check option">
<input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands
</label>
<label class="check option">
<input type="checkbox" checked={net.galleryTalk} onchange={(e) => net.setGalleryTalk(e.currentTarget.checked)} />
Let the Peanut Gallery talk at the table
</label>
<div class="ready">
{#if net.players.length < 2}
<div class="ready-note">Waiting for one more wizard</div>
{/if}
<button
class="stamp big begin"
disabled={net.players.length < 2 || net.players.length > 6}
onclick={() => net.start(withExpansion)}
>
{net.players.length < 2 ? "Start game" : `Start game · ${net.players.length} wizards`}
</button>
<button class="hint-cancel abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
</div>
{:else}
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
{/if}
</div>
</section>
{:else if view}
<div class="game" class:hand-left={handLeftOn} class:offline>
{#snippet tableGuidance()}
{#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}
{#if sc.kind === "alter-ego"}
<span>Your <strong>double</strong> stands ready — select a card and it casts from the double's square, by the double's sight.</span>
{:else}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack{sc.kind === "troll" ? ", or a wall line beside it to punch" : ""}.</span>
{/if}
{#if dmWarnCell}
<span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span>
{/if}
<button class="hint-cancel" onclick={() => (selectedCreature = null)}>done</button>
{/if}
{/if}
{#if youMustRespond && view.stack}
<span class="hint-alert">
{#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
{pendingTeleport
? "Escaping by teleport — tap the marked square again to jump."
: "Escaping by teleport — tap a square within four spaces to mark it."}
{:else if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
: view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
? `an illusionary ${cardDef(view.stack.params.cardId).name}`
: view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name
: view.stack.tearTreasure ? "a grab at your treasure"
: view.stack.sourceName ? `a ${view.stack.sourceName}` : "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>
<button class="stamp tiny" onclick={askCouncil}>🎓 hints</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 pendingCellFor}
<span>— now click where {pendingCellFor} goes{selectedCard?.cardId === "mental-force"
? " (within three walked spaces of them — no sight needed)" : ""}</span>
{/if}
{#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 sets the charges: tap a NUMBER card first, or cast bare for a single charge</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 attachedMods.some((m) => m.cardId === "power-attack")}
<label class="inline">burn
<select class="tier-pick" bind:value={paPoints} aria-label="life points to burn">
{#each paBurnable as n (n)}
<option value={n}>{n}</option>
{/each}
</select>
life for +{paPoints} damage</label>
{/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>— tap the monster, then choose whether to double its life-points or its movement</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 === "master-key" && isYourTurn &&
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
{/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 === "swap-meet"}
{#if !swapMeetTarget}
<span>— click the other trader</span>
{:else if myTradables.length === 0}
<span>— you carry no items to trade</span>
{:else if !swapMine}
<span>— trade away your:</span>
{#each myTradables as c (c.key)}
<button class="stamp tiny" onclick={() => (swapMine = c.key)}>{c.label}</button>
{/each}
{:else if theirTradables.length === 0}
<span>— they show no item you could claim</span>
{:else}
<span>— and take their:</span>
{#each theirTradables as c (c.key)}
<button class="stamp tiny" onclick={() => castSwapMeet(c.key)}>{c.label}</button>
{/each}
{/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 && confirmCastable(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 && isYourTurn && !youMustRespond}
<button class="hint-cancel discard-one" title="throw this card away instead of playing it" onclick={() => {
if (selectedCard) dispatch({ type: "discard", instanceIds: [selectedCard.instanceId] });
clearSelection();
}}>discard it instead</button>
{/if}
{#if selectedCard?.cardId === "add"}
<span>— ADD rides a second NUMBER: play one number for movement,
then play another, and the Add spends itself</span>
{/if}
{#if selectedCard && isNumberCard(selectedCard.cardId)}
{@const needsAdd = view.turn.numberPlayedForMovement}
{@const haveAdd = view.yourHand.some((c) => c.cardId === "add")}
{#if needsAdd && !haveAdd}
<span>— a second movement number needs an ADD card</span>
{:else}
<button class="stamp tiny" onclick={playNumberForMovement}>
Play for +{cardDef(selectedCard.cardId).value} movement{needsAdd ? " (spends your Add)" : ""}
</button>
{/if}
{/if}
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
{/if}
</div>
{/if}
{/snippet}
<section class="board-zone">
<!-- View controls above the board, in one row: the way of looking
at the left, the zoom at the right. -->
<div class="board-tools">
<div class="tools-left">
{#if view.you && !net.spectating}
<button class="tool" class:on={prefs.liveFp} aria-pressed={prefs.liveFp}
onclick={() => setPref("liveFp", !prefs.liveFp)}
title="a first-person pane above the board, through your wizard's eyes">
👁 First-person
</button>
{/if}
</div>
<div class="tools-right" role="group" aria-label="board zoom">
<button class="tool" title="zoom out" onclick={() => zoomBoard(-1)} disabled={boardZoom <= 1}>−</button>
<span class="zoom-level" aria-live="polite">{Math.round(boardZoom * 100)}%</span>
<button class="tool" title="zoom in" onclick={() => zoomBoard(1)} disabled={boardZoom >= 4}>+</button>
<button class="tool" title="the whole maze at once" onclick={resetZoom} disabled={boardZoom === 1}>Fit</button>
</div>
</div>
{#if stepCaption && me}
<div class="step-caption">
<span class="step-chip" style:background={playerColor(me.id)}></span>
This is you — tap a lit square beside your wizard to walk
</div>
{/if}
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
onstride={(side) => tryMove(side)} canStride={yourMoment}
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
aimBeings={selectedCard != null && !CELL_CARDS.has(selectedCard.cardId) && !EDGE_CARDS.has(selectedCard.cardId)}
onpickup={(w) => dispatch(w.kind === "treasure"
? { type: "pickUpTreasure", treasureId: w.id }
: { type: "pickUpObject", instanceId: w.id })}
onCreatureMove={(creatureId, direction) => dispatch({ type: "moveCreature", creatureId, direction })}
onCreatureAttack={(creatureId, targetId) => dispatch({ type: "creatureAttack", creatureId, targetId })}
onbody={(id) => (fpvBody = id)}
onWarpStep={(creatureId) => dispatch(creatureId
? { type: "creatureWarpStep", creatureId }
: { type: "warpStep" })}
edgeSelect={edgeSelectMode && yourMoment} />
{/if}
<div class="board-frame">
<div class="board-viewport" bind:this={boardViewportEl} style:--zoom={boardZoom}>
<Board
{view}
edgeSelectMode={edgeSelectMode && yourMoment}
selectedCreatureId={selectedCreature}
onCellClick={clickCell}
onEdgeClick={clickEdge}
onPlayerClick={clickPlayer}
onCreatureClick={clickCreature}
onWarpClick={clickWarp}
markedCell={tradeFrom ?? pendingTeleport}
markedCells={trapCells.length > 0 ? trapCells : null}
effects={boardFx}
markedSector={pendingSectorOrigin}
ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot}
onCellPeek={(c) => peekAt(c)}
onIllusionClick={(cell, side) => (illusionPrompt = { cell, side })}
onEdgePeek={(tip) => (peekEdge = tip)}
{litCells}
spotCells={stepCells}
{sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
/>
</div>
</div>
</div>
</section>
<aside class="paper-rail">
{#if !local.active && net.missedMoves > 0}
<div class="slip catchup">
<div class="catchup-head">
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
<button class="stamp tiny" onclick={() => net.watchCatchUp(0)}>▶ Watch what happened</button>
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
</div>
{#if net.missedSteps && view.you}
<ul class="catchup-facts">
{#each summarizeMissed(net.missedSteps, view.you, view) as f, i (i)}
<li>
{#if f.step >= 0}
<button class="fact-jump" onclick={() => net.watchCatchUp(f.step)} title="watch this moment">▶ {f.text}</button>
{:else}
<span>{f.text}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if view.phase === "finished"}
<div class="slip winner">🏆 {view.winner} wins!
{#if !local.active}
{@const otherHumans = net.players.some((p) => p !== net.you && !net.roomBots[p])}
{#if net.rematchCall}
{#if net.rematchCall.by === net.you}
<button class="stamp tiny" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>back to the rematch</button>
{:else}
<span class="rematch-call">{net.rematchCall.by} calls for a rematch —</span>
<button class="stamp tiny primary" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>take your seat</button>
{/if}
{:else if otherHumans}
<button class="stamp tiny" onclick={() => net.callRematch()} title="a new table with the same wizards and the same clockwork">rematch with this table</button>
{:else if net.you === net.hostId}
<button class="stamp tiny" onclick={playAgain}>play again</button>
{/if}
<button class="stamp tiny" onclick={shareTale}>
{taleState === "idle" ? "share the tale" : taleState === "minting" ? "…" : taleState === "copied" ? "link copied" : "share failed"}
</button>
{/if}
</div>
{:else if youMustRespond}
<div class="slip urgent">
{#if reflectSwapCard}
Your reflection turns the trade over to you —
{#if !reflectSwapMine}
give your:
{#each reflectMyTradables as c (c.key)}
<button class="stamp tiny" onclick={() => (reflectSwapMine = c.key)}>{c.label}</button>
{/each}
{:else}
and take their:
{#each reflectTheirTradables as c (c.key)}
<button class="stamp tiny" onclick={() => castReflectSwap(`${reflectSwapMine};${c.key}`)}>{c.label}</button>
{/each}
{/if}
<button class="hint-cancel" onclick={() => castReflectSwap("none")}>swap nothing</button>
{:else if view.stack?.defenderId === view.you}
Under attack — respond by your hand.
{#if view.stack.attackCard}
<span class="respond-card"><Card card={view.stack.attackCard} /></span>
{/if}
{:else}
Your spell is countered — respond by your hand.
{#if view.stack?.counters.length}
{@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)}
{#if latest}
<span class="respond-card"><Card card={latest.card} /></span>
{/if}
{/if}
{/if}
</div>
{:else if view.stack}
<div class="slip">Waiting on {view.stack.waitingOn}…</div>
{:else if view.pushPending && view.pushPending.pusheeId !== view.you}
<div class="slip">Waiting on {view.pushPending.pusheeId} to choose which way they are pushed…</div>
{:else if view.outOfTurnWindow?.playerId === view.you}
<div class="slip urgent">Your interruption — pick an attack card and a target, or <button class="stamp tiny" onclick={pass}>waste it</button></div>
{:else if isYourTurn}
<div class="slip yours">
Your turn — round {view.turn.round}.
{#if prefs.finishedGames === 0 && view.turn.round <= 2}
<div class="goal">Win by carrying two treasures that aren't yours to your home, or by being the last wizard standing.</div>
{/if}
{#if view.turn.actionsEnded}
{#if me?.carriedTreasureId}
your actions for this turn are over, and the treasure comes with you —
<strong>End turn</strong> now, then carry it home to score.
{:else}
your actions for this turn are over — <strong>End turn</strong> when ready.
{/if}
{:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
{/if}
</div>
{#if !prefs.coached && !view.turn.actionsEnded}
<!-- A stranger's first turn: what a turn is, before they stare at
seven cards and a maze. Dismissed once, remembered forever. -->
<div class="slip coach">
<strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping
the square beside your wizard, one step at a time; play a NUMBER card first to walk farther.
Select a card, then choose its target — a wizard, a square, or a wall, as the hint bar says.
When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
<button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button>
</div>
{/if}
{#if me?.carriedTreasureId && !view.turn.actionsEnded}
{@const chest = view.treasures.find((t) => t.id === me!.carriedTreasureId)}
{@const away = dreadDistance(view.board, me!.position, me!.home)}
<div class="slip">
💰 Carrying {chest && chest.owner !== view.you ? `${chest.owner}'s treasure` : "your own treasure"} —
{#if chest && chest.owner !== view.you}
your home is {away} square{away === 1 ? "" : "s"} away; drop it there to score. Two scored wins the game.
{:else}
it scores nothing in your arms; drop it on your home to keep it safe.
{/if}
</div>
{/if}
{: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 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 councilHints}
<div class="slip council">
<div class="council-head">The council considers your position…
<button class="hint-cancel" onclick={() => { councilOpen = false; councilUnasked = false; }}>enough</button>
</div>
{#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
<div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
{:else}
{#each councilHints as h (h.name)}
<div class="council-row">
<span class="council-name">{h.glyph} {h.name}</span>
<span class="council-mood">({h.mood})</span>
{h.advice}
</div>
{/each}
{/if}
</div>
{/if}
{#if isYourTurn && view.players.some((p) => p.alive && p.id !== view.you &&
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && me &&
dreadDistance(view.board, p.position, me.position) <= 3)}
<div class="slip urgent">
😱 An unnatural dread grips you — FEAR requires you to move away
this turn if you can, even if your only escape is a card
(Teleport, Pass Through Wall) or a dangerous square. Only a true
dead end excuses you. (The maze trusts you to honor it.)
</div>
{/if}
{#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
<div class="slip urgent">
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
of your own treasures. Spend your movement marching toward it;
you may cast only counteractions, spells that speed the trip,
and Drop Object to shake your own treasure from a thief's
arms. It lifts when you stand on your treasure. (The marching
is yours to honor.)
</div>
{/if}
{#if actionsSpent}
{#if view.turn.drawForbidden}
<div class="slip urgent">Your own bolt has left you reeling — the rest of this turn is lost, and no cards come with it.</div>
{:else}
<div class="slip">Your spellwork is spent for this turn — all that remains is to draw and end it.</div>
{/if}
{/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)}
{@const home = treasuresHomeOf(p)}
<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 home > 0}&nbsp;· <span class="score-home" class:threat={home === 1 && !!p.carriedTreasureId} title={home === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {home} home</span>{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`}&nbsp;·
<button class="score-chest-btn" aria-label="see the carried treasure"
onclick={() => (peekTreasure = { src: chestSrc, note: `${ct?.owner ?? "someone"}'s treasure — in ${p.id}'s arms` })}>
<img class="score-chest" alt="" src={chestSrc} /></button>{/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" title="what this spell does, and when it ends" onclick={() => {
peekEffect = e;
peekCard = realCard(e.cardId) ? { instanceId: `peek-${e.id}`, cardId: e.cardId } : null;
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>
<!-- The chronicle below stays MOUNTED while the panel covers it
(class-hidden, not unrendered) so its scroll position and
stick-to-newest state survive the casting. -->
{#if castPanelOn && view}
<div class="cast-panel" class:respond={youMustRespond} aria-label={youMustRespond ? "counteraction" : "casting"}>
{#if youMustRespond && view.stack}
<div class="cast-panel-head">⚔ incoming</div>
{#if view.stack.attackCard}
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
{:else if view.stack.sourceName}
{#if BLOW_CARD[view.stack.sourceName]}
<div class="cast-panel-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: BLOW_CARD[view.stack.sourceName]! }} onfaq={(id) => (faqCardId = id)} /></div>
{/if}
<div class="cast-panel-note">{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}</div>
{:else}
<div class="cast-panel-note">a physical blow — no card behind it</div>
{/if}
{#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
<div class="cast-panel-note">
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
{view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""}
</div>
{/if}
{#if view.stack.counters.length > 0}
<div class="cast-panel-sub">counters so far</div>
<div class="cast-panel-minis">
{#each view.stack.counters as c (c.card.instanceId)}
<span class="cast-mini" class:nullified={c.nullified}>
<Card card={c.card} /></span>
{/each}
</div>
{/if}
{:else if selectedCard}
{@render tableGuidance()}
<div class="cast-panel-card"><Card card={selectedCard} onfaq={(id) => (faqCardId = id)} /></div>
{#if attachedNumber || attachedMods.length > 0}
<div class="cast-panel-sub">riding along</div>
<div class="cast-panel-minis">
{#if attachedNumber}
<span class="cast-mini"><Card card={attachedNumber} /></span>
{/if}
{#each attachedMods as m (m.instanceId)}
<span class="cast-mini"><Card card={m} /></span>
{/each}
</div>
{#if attachedNumber}
<div class="cast-panel-note">number total {numberTotal}</div>
{/if}
{/if}
{/if}
{#if youMustRespond}{@render tableGuidance()}{/if}
</div>
{/if}
<div class="log-filters" class:tucked={castPanelOn} role="group" aria-label="what the chronicle shows">
{#each [["all", "All"], ["game", "Game"], ["talk", "Table talk"]] as [k, label] (k)}
<button class:current={logFilter === k} onclick={() => (logFilter = k as typeof logFilter)}>
{label}{#if k === "talk" && unreadTalk > 0}<span class="talk-badge">{unreadTalk}</span>{/if}
</button>
{/each}
</div>
{#if castPanelOn && !local.active && unreadTalk > 0 && lastTalk}
<div class="talk-peek">💬 {lastTalk}</div>
{/if}
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
onscroll={onChronicleScroll}>
{#snippet receipt(line: { text: string; receipt?: string[] })}
{#if line.receipt}
<details class="receipt">
<summary>⚖ {line.text}</summary>
<ul>{#each line.receipt as r, j (j)}<li>{r}</li>{/each}</ul>
</details>
{/if}
{/snippet}
{#if local.active}
{#each local.log.filter(showLine) as line, i (i)}
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
</div>
{/each}
{:else}
{#each net.log.filter(showLine) as line, i (i)}
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
<span class="log-mark" class:blank={!line.actor} style:background={line.actor ? playerColor(line.actor) : "transparent"}></span>{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
<button class="moment-eye" title="relive this turn"
onclick={() => net.requestMoment(line.turn!)}>👁</button>
{/if}
</div>
{/each}
{/if}
</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 && !net.spectating}
{@render sayBox("", true)}
{:else if net.spectating && net.galleryTalk}
{@render gallerySayBox("")}
{/if}
</aside>
<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 if ambushNeedsCell && !ambushCell}
<span>{cardDef(ambushSpell.cardId).name} — click where the trap will send them</span>
{:else}
<span>{cardDef(ambushSpell.cardId).name}{ambushCell ? ` → (${ambushCell.x}, ${ambushCell.y})` : ""}{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 !castPanelOn}
{@render tableGuidance()}
{/if}
<!-- The dock: decisions below the board. What the square offers at
the left, the tools in the middle, the turn's end at the right —
and, while a card is aimed, the decision in one line on top. -->
<div class="dock" class:targeting={dockLead != null}>
{#if dockLead && isYourTurn}
<div class="dock-lead">
<span>{dockLead}</span>
{#if selectedCard?.cardId === "teleport" && pendingTeleport}
<button class="stamp tiny primary" onclick={confirmTeleport}>teleport!</button>
{/if}
{#if selectedCard && isNumberCard(selectedCard.cardId)}
{@const needsAdd = view.turn.numberPlayedForMovement}
{@const haveAdd = view.yourHand.some((c) => c.cardId === "add")}
{#if !(needsAdd && !haveAdd)}
<button class="stamp tiny primary" onclick={playNumberForMovement}>
Play for +{cardDef(selectedCard.cardId).value} movement{needsAdd ? " (spends your Add)" : ""}
</button>
{/if}
{/if}
<button class="hint-cancel dock-cancel" onclick={clearSelection}>cancel</button>
</div>
{/if}
{#if isYourTurn}
<div class="dock-row">
<div class="dock-context">
{#if onWarpToken}
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
{/if}
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
onclick={pickUp}>Pick up treasure</button>
{#if pickChoice}
<span class="pick-which">
— whose?
{#each treasuresHere as t (t.id)}
<button class="stamp tiny" onclick={() => pickUpNamed(t.id)}>{t.owner}'s</button>
{/each}
<button class="hint-cancel" onclick={() => (pickChoice = false)}>never mind</button>
</span>
{/if}
<button class="stamp" disabled={!carryingTreasure} onclick={drop}>Drop treasure</button>
{#each objectsHere as obj (obj.instanceId)}
<button class="stamp" disabled={view.turn.actionsEnded}
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button>
{/each}
{#each tearTargets as t (t.id)}
<button class="stamp" disabled={view.turn.attackUsed || view.turn.round === 1}
onclick={() => dispatch({ type: "tearTreasure", targetId: t.id })}>
Tear the treasure from {t.id}'s arms</button>
{/each}
</div>
<button class="stamp tiny more-btn" onclick={() => (moreOpen = !moreOpen)} aria-expanded={moreOpen}>
{moreOpen ? "Less" : "More…"}</button>
<div class="dock-tools" class:open={moreOpen}>
<button class="stamp quiet" 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 quiet" onclick={askCouncil}>🎓 Hints</button>
<button class="stamp quiet" onclick={startDiscardMode}>Discard cards…</button>
</div>
<div class="dock-turn">
<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>
{#if net.pending}
<span class="sending" aria-live="polite">sending {net.pending.label}…</span>
{:else if net.confirmed}
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
{/if}
<button class="stamp primary" onclick={endTurn} disabled={offline}>End turn</button>
</div>
</div>
{/if}
</div>
{#if selectedCard && !inspectorHidden && !castPanelOn}
<div class="inspector">
<button class="inspector-close" onclick={() => (inspectorHidden = true)} aria-label="hide enlarged card">×</button>
<button class="inspector-card" title="read it full size"
onclick={() => (peekCard = selectedCard)}>
<Card card={selectedCard} onfaq={(id) => (faqCardId = id)} />
</button>
</div>
{/if}
{#if view.phase === "finished" && view.revealedHands}
<div class="final-reveal" class:folded={revealFolded} aria-label="all hands revealed">
<div class="reveal-head">
The hands, face-up
<span class="reveal-tools">
<button class="stamp tiny"
onclick={() => (local.active ? local.buildReplay() : net.requestFullReplay())}>
⟲ Watch the whole game</button>
<button class="stamp tiny" onclick={() => (revealFolded = !revealFolded)}
aria-expanded={!revealFolded}>
{revealFolded ? "▸ Show the hands" : "▾ Put them away"}</button>
</span>
</div>
{#each revealFolded ? [] : 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" bind:this={handEl} role="list" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery — hands stay secret, even from you.</span>
{/if}
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
<div class="hand-slot"
draggable="true"
role="listitem"
ondragstart={(e) => { draggingId = card.instanceId; e.dataTransfer?.setData("text/plain", card.instanceId); }}
ondragover={(e) => e.preventDefault()}
ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }}
ondragend={() => (draggingId = null)}>
<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)}
/>
</div>
{/each}
</div>
</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;
}
.kick { margin-left: 0.4rem; color: #a04545; }
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
.mast-version {
font-family: "Courier Prime", monospace;
font-size: 0.65rem;
letter-spacing: 0.06em;
color: #6b6454;
}
.mast-room {
margin-left: auto;
font-size: 0.85rem;
color: #a49c86;
background: none;
border: none;
font-family: inherit;
padding: 0;
cursor: pointer;
}
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.fb-field { display: block; margin: 0.5rem 0; }
.fb-field span { display: block; margin-bottom: 0.2rem; color: #6b5a41; }
.fb-field textarea {
width: 100%;
box-sizing: border-box;
font-family: inherit;
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
border: 1px solid #8a7a5e;
border-radius: 4px;
background: #f3ecd8;
color: #43331f;
resize: vertical;
}
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
.report-reply.yours { color: #43331f; font-style: italic; }
.report-pic { color: #8a6d3f; }
.report-answer { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
.report-answer input { flex: 1; min-width: 0; background: #f4eede; border: 1px solid #b3a687; border-radius: 4px; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.85rem; color: #43331f; }
.fb-picture input { font-size: 0.85rem; color: #43331f; }
.fb-preview { display: block; max-width: 100%; max-height: 9rem; margin-top: 0.4rem; border: 1px solid #b3a687; border-radius: 3px; }
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
.respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; }
.chronicle.tucked { display: none; }
/* The casting panel wears the table's dark felt so the guidance strips
* keep their own colors; the cards bring their own paper. */
.cast-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
padding: 0.6rem;
border: 1px dashed rgba(233, 225, 203, 0.35);
border-radius: 4px;
}
.cast-panel.respond { border-color: #e8a03c; }
.cast-panel-head {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #e8a03c;
text-align: center;
}
.cast-panel-card { zoom: 1.5; align-self: center; }
.cast-panel-sub { font-size: 0.78rem; color: #a49c86; text-align: center; }
.cast-panel-note { font-size: 0.85rem; color: #d8d2c0; font-style: italic; text-align: center; }
.cast-panel-minis { display: flex; flex-wrap: wrap; gap: 0.3rem; justify-content: center; }
.cast-mini { zoom: 0.55; }
.cast-mini.nullified { opacity: 0.45; filter: grayscale(0.8); }
.hand-slot { display: flex; cursor: grab; }
.hand-slot:active { cursor: grabbing; }
.reports-head {
margin-top: 0.9rem;
font-family: "Caveat", cursive;
font-size: 1.25rem;
color: #6b5a41;
}
.report-row {
display: flex;
gap: 0.6rem;
align-items: baseline;
padding: 0.35rem 0;
border-top: 1px dotted #b7ad92;
text-align: left;
}
.report-body { min-width: 0; }
.report-text { font-size: 0.9rem; color: #43331f; }
.report-reply { font-size: 0.85rem; color: #6b5a41; margin-top: 0.15rem; }
.report-reply.pending { font-style: italic; color: #a49c86; }
.report-status {
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.06em;
color: #1a9c46;
border: 1px solid #1a9c46;
border-radius: 3px;
padding: 0 0.3rem;
}
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
.mast-audience.as-button { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; font-size: 0.85rem; }
.mast-audience.talking { color: #6b5a41; }
.gallery-say .say-name { width: 7rem; flex: 0 0 auto; }
.mind-read { max-width: min(92vw, 46rem); }
.caution-reason { padding: 0.15rem 0; }
.caution-reason + .caution-reason { border-top: 1px dotted #4a4536; }
.mind-cards {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
max-height: 60vh;
overflow-y: auto;
padding: 0.4rem 0;
}
.gallery-note { font-size: 0.85rem; color: #8d8672; font-style: italic; padding: 0.4rem 0; }
.gallery-row { margin-top: 0.35rem; text-align: center; }
.gallery-row button:disabled { opacity: 0.4; cursor: default; }
.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; }
.workshop-mast { margin: 0 1rem 0; }
.mast-home { text-decoration: none; }
.mast-home:hover { color: #fff; }
.mast-help-solo, .mast-right { 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;
position: relative;
}
.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.current::after {
content: "✓";
position: absolute;
right: 0.1rem;
bottom: 0.05rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: var(--ring, #43331f);
color: #fff;
font-size: 0.7rem;
line-height: 1rem;
text-align: center;
font-weight: 700;
}
.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; }
/* The toast floats over the page: a refusal that came and went must
* never push the table down and back. */
.toast {
position: fixed;
top: 3.6rem;
left: 50%;
transform: translateX(-50%);
z-index: 70;
max-width: min(40rem, calc(100% - 2rem));
background: #6d2119;
color: #f2e6d8;
border: 1px solid #96382c;
padding: 0.5rem 0.85rem;
border-radius: 4px;
font-size: 0.95rem;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.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-pitch {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: 0.02em;
color: #3a2f1f;
margin: 0.2rem 0 0.1rem;
}
/* The board's toolbar: the eyes toggle and the zoom, one short row. */
.board-tools {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin: 0 auto 0.35rem;
min-height: 1.9rem;
width: 100%;
max-width: max(var(--fit-w, 100%), 24rem);
}
.tools-left, .tools-right { display: flex; align-items: center; gap: 0.3rem; }
.tool {
height: 1.7rem;
min-width: 1.7rem;
padding: 0 0.55rem;
border: 1px solid #8a7a5e;
border-radius: 4px;
background: rgba(233, 225, 203, 0.9);
color: #43331f;
font-family: "Oswald", sans-serif;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
cursor: pointer;
}
.tool:disabled { opacity: 0.35; cursor: default; }
.tool.on { background: #43331f; color: #e9e1cb; border-color: #43331f; }
.zoom-level { font-family: "Courier Prime", monospace; font-size: 0.75rem; color: #c9bd9f; min-width: 2.6rem; text-align: center; }
.step-caption {
align-self: flex-start;
max-width: calc(100% - 11rem);
margin: 0 0 0.4rem 0.4rem;
padding: 0.3rem 0.8rem;
border-radius: 999px;
background: rgba(28, 22, 12, 0.92);
border: 1.5px solid #f2cf5b;
color: #f7ecc8;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.45rem;
}
.step-chip {
display: inline-block;
width: 0.8em;
height: 0.8em;
border-radius: 2px;
border: 1.5px solid #f7ecc8;
}
.boxlid-tag {
font-size: 0.95rem;
color: #6b5a41;
margin: 0.5rem 0 0.4rem;
}
.hero { display: block; margin: 0.6rem auto 0; max-width: 24rem; border-radius: 6px; overflow: hidden;
border: 1.5px solid #43331f; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); }
/* The landing: the name and the ways in come before the clip on a
* phone, and stand beside it on a desktop, so the play button is never
* below the fold. */
.lid-look { margin-top: 1.2rem; }
@media (min-width: 901px) {
.boxlid-inner.landing {
max-width: min(54rem, 100%);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: "head head" "play look" "more more";
column-gap: 2.2rem;
padding-top: 1.8rem;
}
.lid-head { grid-area: head; }
.lid-play { grid-area: play; }
.lid-look { grid-area: look; margin-top: 0; }
.boxlid-inner.landing .more-ways { grid-area: more; }
.boxlid-inner.landing .hero { margin-top: 0; }
}
.hero img { display: block; width: 100%; }
.play-now { display: block; margin: 0.2rem auto 0.8rem; font-size: 0.95rem; padding: 0.6rem 1.2rem; }
.more-ways { margin-top: 1.2rem; }
.more-ways summary { cursor: pointer; font-family: "Caveat", cursive; font-size: 1.05rem; color: #8a6d3f; }
.more-ways summary:hover { color: #43331f; }
.lobby-head {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.72rem;
color: #8a6d3f;
margin: 1.1rem 0 0;
}
.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; }
.family-line { margin: 0.5rem 0 0; font-size: 0.85rem; color: #7b6a51; }
.family-line a { color: #5a4630; }
.clips-link { display: inline-block; margin: 0 0 0.9rem; }
.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; }
/* The lobby is a table being assembled: the roster is the picture, the
* ways to fill a seat sit beneath it, the start waits at the bottom. */
.lobby-sub { color: #6b5a41; font-size: 0.95rem; margin-top: 0.3rem; }
.room-code { font-family: "Courier Prime", monospace; letter-spacing: 0.12em; color: #43331f; }
.table-roster { list-style: none; padding: 0; margin: 1rem 0 0.8rem; text-align: left; }
.seat { display: flex; align-items: center; gap: 0.7rem; padding: 0.4rem 0.5rem; border-radius: 5px; }
.seat.you { background: rgba(255, 252, 240, 0.55); border: 1px solid #d8cdae; }
.seat.open { opacity: 0.7; }
.seat-portrait { width: 2.6rem; height: 2.6rem; border-radius: 5px; object-fit: cover; flex: 0 0 auto; }
.seat-portrait.empty { border: 1.5px dashed #b3a687; background: rgba(0, 0, 0, 0.03); box-sizing: border-box; }
.seat-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.seat-name { font-size: 1.05rem; font-weight: 700; }
.seat-role { font-size: 0.82rem; color: #6b5a41; }
.color-pick {
display: grid;
grid-template-columns: repeat(3, 3.1rem);
gap: 0.45rem;
justify-content: center;
padding: 0.5rem 0 0.7rem;
}
.fill-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
.challenge-row { margin-top: 0.6rem; }
.stamp.quiet-lid { background: #f4eede; box-shadow: none; }
.challenge-note { margin-top: 0.6rem; font-size: 0.88rem; color: #6b5a41; font-style: italic; }
.lobby-talk { margin-top: 0.7rem; text-align: left; }
.lobby-line { font-family: "Courier Prime", monospace; font-size: 0.82rem; color: #43331f; padding: 0.1rem 0; }
.lobby-say .say-input { background: #f4eede; color: #43331f; border: 1px solid #b3a687; }
.fill-row .stamp.current { background: #e4dbc2; }
.invite-fallback { margin: 0.4rem 0 0; font-size: 0.8rem; }
.invite-fallback summary { cursor: pointer; color: #8a6d3f; font-style: italic; }
.invite-fallback .invite-link { display: inline-block; margin-top: 0.3rem; word-break: break-all; }
.bot-panel {
margin-top: 0.8rem;
padding: 0.7rem 0.8rem;
border: 1px solid #b3a687;
border-radius: 5px;
background: rgba(255, 252, 240, 0.45);
display: flex;
flex-direction: column;
gap: 0.55rem;
align-items: center;
}
.bot-field { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.3rem 0.6rem; }
.bot-field-label { font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a6d3f; min-width: 5.2rem; text-align: right; }
.seg { display: inline-flex; flex-wrap: wrap; gap: 0.25rem; }
.seg button {
font-family: "Oswald", sans-serif;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: none;
border: 1.5px solid #b3a687;
border-radius: 3px;
color: #6b5a41;
padding: 0.25rem 0.55rem;
cursor: pointer;
}
.seg button.current { border-color: #43331f; color: #43331f; background: #e4dbc2; }
.bot-blurb { font-size: 0.9rem; color: #6b5a41; font-style: italic; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.check.option { margin: 1rem 0 0.6rem; font-size: 0.88rem; color: #6b5a41; }
.ready { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.ready-note { font-size: 0.88rem; color: #8a6d3f; font-style: italic; }
.ready .abandon { margin-top: 0.4rem; font-size: 0.8rem; opacity: 0.75; }
.waiting { color: #6b5a41; font-style: italic; }
.invite-link {
font-size: 0.8rem;
color: #6b5a41;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
padding: 0.15rem 0.45rem;
user-select: all;
}
.gallery-join {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.gallery-join input {
width: 11rem;
}
.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); }
.prefs-slip { text-align: left; }
.pref-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.55rem 0.1rem;
font-size: 0.85rem;
color: #43331f;
text-align: left;
cursor: pointer;
}
/* Rules divide the checkbox stanza only — never framing it, never
around the control rows. */
.pref-check + .pref-check { border-top: 1.5px solid #6b5a41; }
/* Checkboxes wear the table's own ink: a stamp-bordered box, checked
solid with a parchment tick — the native widget never matches the
parchment. */
.pref-row input[type="checkbox"] {
appearance: none;
flex: none;
width: 1.05rem;
height: 1.05rem;
border: 1.5px solid #43331f;
border-radius: 3px;
background: #f6efdd;
cursor: pointer;
display: grid;
place-content: center;
margin: 0;
}
.pref-row input[type="checkbox"]:checked {
background: #43331f;
}
.pref-row input[type="checkbox"]:checked::before {
content: "✓";
color: #f6efdd;
font-size: 0.8rem;
line-height: 1;
font-weight: 700;
}
.pref-row span { flex: 1; }
/* Control rows read as a two-column table: the label holds the left
column, its controls right-align into the second. */
.pref-row > span:first-child { flex: 0 1 auto; }
.pref-row > span:first-child + .stamp,
.pref-row > span:first-child + input,
.pref-row > span:first-child + .pref-swatch { margin-left: auto; }
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
.pref-name { max-width: 11rem; }
.pref-swatch {
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
border: 2px solid #43331f;
cursor: pointer;
opacity: 0.55;
}
.pref-swatch.current { opacity: 1; outline: 2px solid #d8b23a; outline-offset: 1px; }
.slip.council { background: #ece4f1; border-color: #7a6f9a; }
.council-head {
font-weight: 700;
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.25rem;
}
.council-row { margin: 0.2rem 0; }
.council-name { font-weight: 700; }
.council-mood { color: #8a7a5e; font-size: 0.72rem; }
.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 { position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0; }
/* Hand at the left: the cards stand in their own column beside the
* board while the hint strips and action buttons stay beneath it —
* the strip dissolves into the grid so its children place themselves.
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
@media (min-width: 901px) {
/* Bottom hand: the table fills the window — board and rail on top,
* the rail scrolling in place, the hand and its buttons pinned in
* view beneath. The board takes whatever height the row allows. */
.game {
flex: 0 0 auto;
height: calc(100dvh - 5.3rem);
grid-template-rows: minmax(0, 1fr) auto;
}
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
.board-zone { min-height: 0; }
.table-stack { flex: 1 1 0; min-height: 0; }
.board-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
/* Under the first-person pane the board is a strip that may shrink
further, never overflowing into the dock beneath. */
.fpv-primary .board-viewport { flex: 0 1 30dvh; min-height: 5rem; }
.fpv-primary .board-frame { min-height: 0; }
/* The pane keeps its aspect but never more height than the row can
spare above the board strip and the dock: the strip's 5rem floor,
the dock, the hand, and the toolbar come to 34.5rem. */
.fpv-primary :global(.live-fp) { flex: 0 1 auto; min-height: 0; }
.fpv-primary :global(.fpv-canvas) { width: auto; max-width: 100%; max-height: calc(100dvh - 34.5rem); margin: 0 auto; }
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }
.game.hand-left {
grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px);
grid-template-rows: minmax(0, 1fr) repeat(5, auto);
row-gap: 0.4rem;
}
.game.hand-left .paper-rail { grid-row: 1 / span 6; }
.game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); }
.game.hand-left .table-edge { display: contents; }
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
.game.hand-left .table-edge > .hand {
grid-column: 1;
/* One implicit row per table-edge sibling; the strip deals at
* most six, and the hand must span them all to hold the column. */
grid-row: 1 / span 6;
display: grid;
grid-template-columns: repeat(2, calc(8.2rem * var(--card-zoom, 1)));
justify-content: center;
align-content: start;
align-self: start;
max-height: 100%;
overflow-y: auto;
padding: 0.3rem 0.3rem 0.9rem;
}
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
.game.hand-left .paper-rail { grid-column: 3; }
}
/* The first-person pane wears exactly the board's width, right above
* it: the stack shrink-wraps to the board, and width:0 + min-width
* keeps the pane out of the intrinsic sizing so the BOARD alone
* decides how wide the column is. */
.table-stack {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: 100%;
min-width: 0;
}
.table-stack :global(.live-fp) { width: 0; min-width: 100%; }
.table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; }
/* FPV play: the 3D view is the primary window into the game; the
* top-down board shrinks to a keymap beneath it. */
.table-stack.fpv-primary {
max-width: 64rem;
}
.board-frame { position: relative; min-width: 0; }
/* The board's window: it owns the fitted size, the svg wears an
* explicit width (fit × zoom), and anything past 1× scrolls. */
.board-viewport {
height: calc(100dvh - 21.5rem);
width: 100%;
overflow: auto;
display: flex;
}
.fpv-primary .board-viewport { height: 30dvh; }
.board-viewport :global(svg.board) {
width: calc(var(--fit-w, 100%) * var(--zoom, 1));
flex: none;
max-width: none;
max-height: none;
height: auto;
margin: auto;
}
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
/* While a card is aimed or an attack answered, its panel leads the
* column: on a short screen the banner, the coaching, and the
* scoresheet would otherwise push it below the fold of a scrolling rail. */
.paper-rail > .cast-panel { order: -1; flex: 0 0 auto; overflow: visible; }
.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 .goal { margin-top: 0.3rem; font-size: 0.86rem; opacity: 0.85; }
.slip.coach { border-left: 4px solid #c9a72a; }
.slip.coach .stamp { margin-left: 0.5rem; }
.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; }
.catchup-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.catchup-facts { list-style: none; margin: 0.45rem 0 0; padding: 0; font-size: 0.9rem; }
.catchup-facts li { margin: 0.15rem 0; }
.fact-jump { background: none; border: 0; padding: 0; font: inherit; color: #43331f; cursor: pointer; text-align: left; }
.fact-jump:hover { text-decoration: underline; }
.log-filters { display: flex; gap: 0.25rem; margin-bottom: 0.3rem; }
.log-filters.tucked { display: none; }
.log-filters button {
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(233, 225, 203, 0.35);
border-radius: 3px;
color: #c9bd9f;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.log-filters button.current { border-color: #e9e1cb; color: #e9e1cb; background: rgba(233, 225, 203, 0.14); }
.talk-badge { margin-left: 0.3rem; background: #b3372b; color: #fff; border-radius: 999px; padding: 0 0.35rem; font-size: 0.6rem; }
.talk-peek { font-family: "Courier Prime", monospace; font-size: 0.82rem; color: #e9e1cb; background: rgba(233, 225, 203, 0.12); border-left: 3px solid #b3372b; padding: 0.3rem 0.5rem; border-radius: 3px; }
/* The table out of reach: one plain notice, and the controls beneath it go quiet. */
.slip.offline-slip {
border-left: 4px solid #b3372b;
background: #f3e3d8;
margin: 0.4rem auto 0;
max-width: 60rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.6rem;
align-items: baseline;
}
.offline-note { font-style: italic; color: #6b3a2f; }
.game.offline .hand, .game.offline .board-zone, .game.offline .dock .stamp { pointer-events: none; opacity: 0.55; }
.sending {
font-family: "Courier Prime", monospace;
font-size: 0.78rem;
color: #6b5a41;
font-style: italic;
/* Floats above its row: a note that came and went must never move the board. */
position: absolute;
right: 0;
top: -1.2rem;
white-space: nowrap;
pointer-events: none;
}
.say-box .sending { position: static; }
.sending.ok { color: #3f6b2f; font-style: normal; }
.rematch-call { font-size: 0.9rem; }
.seat.ghost .seat-name { font-style: italic; font-weight: 400; }
.score-home { white-space: nowrap; }
.score-home.threat { color: #b3372b; font-weight: 700; }
.effect-note { max-width: 24rem; text-align: left; line-height: 1.4; }
.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;
min-height: 1.55rem;
}
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
.score-row.active {
font-weight: 700;
background: rgba(58, 47, 31, 0.08);
border-left: 3px solid #3a2f1f;
margin-left: -0.45rem;
padding-left: 0.3rem;
border-radius: 2px;
}
.score-temper {
font-family: "Caveat", cursive;
font-size: 0.85rem;
color: #8a7a5e;
margin-left: 0.25rem;
/* Wraps below the name as one piece rather than truncating the row. */
display: inline-block;
white-space: nowrap;
}
.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; }
.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; }
.score-chest-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
vertical-align: -0.7em;
}
.score-chest {
height: 2.1em;
display: block;
filter: drop-shadow(0.5px 0.5px 0.5px rgba(0, 0, 0, 0.35));
}
.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; }
/* A resolved attack's account, folded under a one-line summary. */
.receipt { display: inline; }
.receipt summary { display: inline; cursor: pointer; color: #6b5a41; font-style: italic; }
.receipt summary:hover { color: #43331f; }
.receipt ul { margin: 0.15rem 0 0.3rem 1.1rem; padding: 0; list-style: none; }
.receipt li { font-size: 0.86rem; color: #43331f; margin: 0.05rem 0; }
.receipt li::before { content: "› "; color: #8a6d3f; }
/* the acting wizard's color, so one player's lines can be picked out at a glance */
.log-mark {
display: inline-block;
width: 0.55em;
height: 0.55em;
margin-right: 0.45em;
border-radius: 2px;
border: 1px solid rgba(58, 47, 31, 0.35);
vertical-align: 0.05em;
}
.log-mark.blank { border-color: transparent; }
.moment-eye {
background: none;
border: none;
cursor: pointer;
font-size: 0.7rem;
padding: 0 0.15rem;
opacity: 0.55;
}
.moment-eye:hover { opacity: 1; }
.table-edge {
grid-column: 1 / -1;
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;
}
/* The dock: one row, three homes. The square's offers at the left, the
* tools in the middle, the turn's end pinned at the right whatever wraps. */
.dock { position: relative; display: flex; flex-direction: column; gap: 0.45rem; min-height: 2.4rem; }
.dock-row { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
.dock-context, .dock-tools { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; }
.dock-tools { padding-left: 0.55rem; border-left: 1px solid rgba(233, 225, 203, 0.25); }
.dock-turn { position: relative; margin-left: auto; display: flex; align-items: center; gap: 0.55rem; }
.stamp.quiet { background: rgba(233, 225, 203, 0.14); color: #e9e1cb; border-color: rgba(233, 225, 203, 0.55); box-shadow: none; }
.stamp.quiet:hover:not(:disabled) { background: rgba(233, 225, 203, 0.26); }
.more-btn { display: none; }
.dock-lead {
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.4rem 0.7rem;
font-size: 0.92rem;
color: #e9e1cb;
}
.dock-cancel { margin-left: auto; }
.discard-one { margin-left: auto; opacity: 0.8; }
.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;
/* A notice taller than the screen — preferences on a phone — scrolls
* inside its frame rather than running off the bottom uncatchable. */
max-height: calc(100dvh - 2rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.attack-headline {
font-family: "Oswald", sans-serif;
font-size: 1.15rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #b3372b;
}
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-card.illusion-pair {
display: flex;
justify-content: center;
gap: 2.6rem;
}
.attack-card.illusion-pair :global(.card) { transform: scale(1.1); }
.attack-card.illusion-pair :global(.card:hover) { transform: scale(1.1); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
/* Modal text sits on cream: never let it inherit the table's pale ink. */
.attack-title {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 1rem;
color: #2b2218;
}
.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;
}
.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;
}
/* The reveal scrolls inside its own frame: the table's bottom row grows
* to fit it, and four hands of cards would otherwise take the board's row. */
.final-reveal {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
padding: 0.7rem 0.9rem;
margin-bottom: 0.6rem;
max-height: 38dvh;
overflow-y: auto;
}
.final-reveal.folded { max-height: none; overflow: visible; }
.final-reveal.folded .reveal-head { border-bottom: 0; padding-bottom: 0; margin-bottom: 0; }
.reveal-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
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-tools { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.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));
background: none;
border: none;
padding: 0;
cursor: zoom-in;
}
.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;
}
/* A short desktop window: smaller cards under the board so the maze keeps its height. */
@media (min-width: 901px) and (max-height: 820px) {
.game:not(.hand-left) .hand :global(.card) { zoom: 0.78; }
}
@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; }
/* The phone's dock stays compact: the offers and the turn's end in
view, the tools behind More. */
.more-btn { display: inline-block; }
.dock-tools { display: none; border-left: 0; padding-left: 0; flex-basis: 100%; }
.dock-tools.open { display: flex; }
.paper-rail { order: 3; margin-top: 0.9rem; }
.board-viewport {
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-invite { display: none; }
.mast-leave { font-size: 0.72rem; }
}
</style>