Files
wizwar6e/packages/web/src/net.svelte.ts
T
Eric WagonerandClaude Fable 5.1 3c10e0463f The raven sets expectations; Create and Join say so when the table is not yet reached
The lobby tells a challenger what to expect in the table's own voice:
the keeper answers every raven, sometimes within the minute, sometimes
after a night's sleep; the seat is held either way, and the table is
theirs meanwhile. A Create or Join tried before the socket is open
used to vanish; it now says the table is still being reached.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:34:45 -04:00

1031 lines
48 KiB
TypeScript
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.
// Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine";
import { receiptFor } from "./receipt";
const SERVER_URL =
import.meta.env.VITE_WIZWAR_SERVER ??
(location.port === "5173" // the vite dev server; anything else serves its own socket
? `ws://${location.hostname}:8787`
: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
/** Sustained-effect ids that are engine constructs, not printed cards. */
const SYNTHETIC_SPELL_NAMES: Record<string, string> = { "sticky-web": "Sticky Wand webs" };
/** Card name lookup that survives synthetic ids — render code must never throw. */
export function spellName(cardId: string): string {
const synthetic = SYNTHETIC_SPELL_NAMES[cardId];
if (synthetic) return synthetic;
try {
return cardDef(cardId).name;
} catch {
return cardId;
}
}
/** The compass, for captions and buttons. */
export const SIDE_NAMES: Record<Side, string> = { N: "North", E: "East", S: "South", W: "West" };
export function humanize(e: GameEvent): string | null {
switch (e.type) {
case "gameStarted": {
const rolloff = e.players
.map((p) => `${p} 🎲 ${(e.dieRolls[p] ?? []).join(", ")}`)
.join(" · ");
return `Game started. The roll-off: ${rolloff}${e.firstPlayer} goes first.`;
}
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
case "turnSkipped": return `${e.player} loses a turn.`;
case "extraTurnStarted": return `${e.player} takes an extra turn!`;
case "moved": return null; // too chatty for the log
case "numberPlayedForMovement": return `${e.player} plays a ${e.value} for movement (${e.newAllowance} total).`;
case "punched": return `${e.attacker} punches ${e.target}!`;
case "spellCast": {
const num = e.numberValue ? ` with a ${e.numberValue}` : "";
const amp = e.amplifies ? `, AMPLIFIED${e.amplifies > 1 ? ` ×${2 ** e.amplifies}` : ""},` : "";
const at = e.target ? ` at ${e.target}` : "";
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${amp}${at}.`;
}
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand — the card is theirs now!`;
case "attackResolved":
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
if (e.fullyStopped) return `The attack is completely stopped.`;
return null; // the damaged event tells the story
case "damaged": {
// The shadow's last drink is told by its own line; the bookkeeping
// blow that follows it has nothing to add.
if (e.amount === 0 && e.source === "shadow upkeep") return null;
const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? ` — ${soak}` : ""}) — ${e.lifeAfter} life left.`;
}
case "stunned": return `${e.player} is stunned and loses a turn!`;
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
case "wallCreated": return `A wall appears!`;
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
case "treasureTorn": return e.toFloor
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`;
case "treasureTearAttempted": return `${e.attacker} grabs for the treasure in ${e.defender}'s arms!`;
case "powerstoneBoost": return `The powerstone hums over the number already played — ${e.player} gains a stride (${e.newAllowance} total).`;
case "trapSprung": return e.cardId === "gift-from-below"
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
: `${e.player} walked into an old TRAP! Lose a turn.`;
case "attackMissed": return e.because === "invisible"
? `The attack passes through empty air — ${e.defender} is invisible!`
: `${e.defender} is too small to hit — the attack misses!`;
case "damageImmune": return e.because === "bloodstone"
? `${e.player}'s bloodstone drinks the whole blow — no damage.`
: `${e.player} is stone — the damage has no effect.`;
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind}${e.amount} damage (${e.total}/${e.needed}).`;
case "squareContentDestroyed": return e.kind === "ooze" ? `🔥 The ooze burns away under ${e.attacker}'s fire!` : `🌿 The ${e.kind} is torn apart by ${e.attacker}'s attack!`;
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
case "teleported": return e.by === e.player
? `${e.player} teleports across the maze!`
: `${e.player} is teleported away by ${e.by}!`;
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases — the maze's loyalties shift!`;
case "cardErased": return e.found
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
: `${e.player} wasn't holding that card — the erasure fizzles.`;
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
case "cardsDrawnPrivate": return e.cards.length > 0
? `You draw ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
: null;
case "creationDispelled": return `The ${e.what.replace(/-/g, " ")} unravels — dispelled!`;
case "firewallCreated": return `A wall of fire roars up (${e.turns} turn${e.turns === 1 ? "" : "s"})!`;
case "firewallExpired": return `The wall of fire gutters out.`;
case "firewallBurned": return `${e.player} steps through the flames!`;
case "squareFilled": return `The square fills with ${e.kind === "stone" ? "solid stone" : e.kind === "slime" ? "green slime" : e.kind.replace(/-/g, " ")}!`;
case "waterwallCrashes": return `A wall of water crashes down!`;
case "objectThrown": return `The ${cardDef(e.cardId).name.toLowerCase()} clatters to the floor.`;
case "spellReused": return `${e.player}'s ${cardDef(e.card.cardId).name} leaps back to their hand!`;
case "doorUnlocked": return `${e.player} unlocks a door.`;
case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
case "cardsStolenPrivate": return e.cards.length > 0
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
: null;
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
case "castAroundCorner": return `The spell bends around the corner!`;
case "moveBumped": return e.why === "pit"
? `${e.player} leaps the pit — nothing to land on beyond it — and teeters back.`
: `${e.player} blunders into a wall!`;
case "attackMisdirected": return e.newTarget
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
: `${e.attacker}'s blind attack flies off into the darkness.`;
case "retreatedInHorror": return `${e.player} flees the hideous sight!`;
case "illusionWallCreated": return `A wall appears... or does it?`;
case "illusionTested": return e.result === "seesThrough"
? `${e.player} sees right through the illusion!`
: `${e.player} is convinced the wall is real.`;
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
case "creatureMoved": return null;
case "creatureAttacked":
if (e.target === "wall") return `The ${spellName(e.kind)} punches the wall (rolled ${e.dieRoll ?? "?"})!`;
return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`;
case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`;
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
case "impScorches": return `The fire imp scorches ${e.player}!`;
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`;
case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null;
case "wandExhausted": return `${e.player}'s wand crumbles to dust.`;
case "wallWarpedOpen": return `A section of wall shimmers out of existence!`;
case "wallsWarpedBack": return `The warped wall snaps back into place.`;
case "shoved": return `${e.player} is shoved bodily by ${e.by}!`;
case "webbed": return `${e.player} is tangled in sticky webs!`;
case "cardRetrieved": return `${e.player} plucks a card from the discard pile.`;
case "slippedInOoze": return `${e.player} slips flat on their face in the ooze!`;
case "struggledInOoze": return e.stood ? `${e.player} staggers upright.` : `${e.player} flounders in the ooze.`;
case "steppedOnTacks": return `${e.player} steps on tacks! OW OW OW.`;
case "jumpedPit": return `${e.player} leaps the pit!`;
case "fellInPit": return `${e.player} misjudges the jump and plummets in!`;
case "climbedFromPit": return e.success ? `${e.player} hauls themselves out of the pit.` : `${e.player} scrabbles at the pit walls in vain.`;
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
case "safeCreated": return `A massive safe slams down around the loot.`;
case "itemsTraded": return `Two items blink and trade places.`;
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`;
case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`;
case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`;
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`;
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`;
case "warpStepped": return `${e.player} steps through the dimensional warp!`;
case "exitsRedirected": return `The maze's outer exits twist and reconnect!`;
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : " — and drifts"}!`;
case "tokenScattered": return `${e.what} goes flying!`;
case "ambushSet": return `You commit ${e.spell} to an ambush (${e.via}).`;
case "ambushCancelled": return `You quietly disarm your ambush.`;
case "ambushSprung": return `AMBUSH! ${e.owner}'s hidden ${e.via.replace(/-/g, " ")} springs on ${e.victim}!`;
case "trapRedrawnDuringDeal": return null;
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll}${e.purpose}.`;
case "pushed": return e.player
? `${e.by} shoves ${e.player} down the corridor!`
: `${e.by} shoves the beast down the corridor!`;
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
case "slimeWashed": return `The wave washes the slime away.`;
case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall";
const weapon = e.source === "punch" ? "bare fists" : e.source === "troll" ? "the troll's fist" : cardDef(e.source).name;
return `${e.player} batters the ${what} with ${weapon}${e.total}/${e.needed}.`;
}
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "cardsDiscarded":
return `${e.player} discards ${e.cards.map((c) => spellName(c.cardId)).join(", ")}.`;
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
case "turnEnded": return null;
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "— last wizard standing!"}`;
default: return null;
}
}
/** A chronicle line: its words, the turn it belongs to (counted by the
* same boundary events the server counts), and whether it carries the
* turn's instant-replay eye. */
export interface LogLine {
text: string;
turn: number | null;
notable: boolean;
/** The wizard acting, for the color mark beside the line: whoever
* cast, struck, walked, or countered — otherwise the turn's owner. */
actor?: string;
/** A resolved attack's account, folded under its line. */
receipt?: string[];
}
/** Events whose `player` is the one acting rather than the one acted on. */
const ACTING = new Set([
"moved", "warpStepped", "jumpedPit", "counteractionPlayed", "treasurePickedUp", "treasureDropped",
"numberPlayedForMovement", "cardsDiscarded", "turnStarted", "turnEnded", "doorUnlocked", "madDash",
"climbedFromPit", "spellTrapped",
]);
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
if ("caster" in e && typeof e.caster === "string") return e.caster;
if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
if ("by" in e && typeof e.by === "string") return e.by;
if ("owner" in e && typeof e.owner === "string") return e.owner;
if (ACTING.has(e.type) && "player" in e && typeof e.player === "string") return e.player;
return turnOwner ?? undefined;
}
/** The boundaries the turn count walks — mirrored by the server's
* momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
function loadChatSeen(): Record<string, number> {
try { return JSON.parse(localStorage.getItem(CHAT_SEEN_KEY) ?? "{}"); }
catch { return {}; }
}
const SEEN_KEY = "wizwar-seen";
function loadSeen(): Record<string, number> {
try { return JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); }
catch { return {}; }
}
export interface Seat { name: string; roomId: string; token: string }
export interface FeedbackReportView {
id: string;
at: string;
roomId: string;
seq: number;
round: number | null;
happened: string;
expected: string;
reply?: { at: string; text: string; status: string };
}
export interface GameSummary {
roomId: string;
name: string;
players: string[];
started: boolean;
finished: boolean;
winner: string | null;
activePlayerId: string | null;
yourTurn: boolean;
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null;
lastMoveAt: string | null;
chatCount: number;
/** A finished table that moved on: the rematch room and who called it. */
rematch?: { roomId: string; by: string } | null;
}
export function attentionLabel(a: GameSummary["attention"]): string {
switch (a) {
case "counteract": return "UNDER ATTACK — respond!";
case "discard": return "DISCARD to your hand limit";
case "interrupt": return "your interruption is waiting";
default: return "YOUR TURN";
}
}
function loadSeats(): Seat[] {
try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
catch { return []; }
}
function saveSeats(seats: Seat[]): void {
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
}
class Net {
status = $state<"disconnected" | "connected">("disconnected");
/** When the socket last spoke. A server restart can leave a half-dead
* socket that never fires onclose; the watchdog closes it by hand so
* the reconnect loop (and the masthead banner) actually engage. */
private lastHeard = Date.now();
/** Whose turn the chronicle is in, for the mark on lines with no actor of their own. */
private turnOwner: string | null = null;
private watchdog: ReturnType<typeof setInterval> | null = null;
roomId = $state<string | null>(null);
players = $state<string[]>([]);
hostId = $state<string | null>(null);
started = $state(false);
/** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
you = $state<string | null>(null);
/** Seated in the Peanut Gallery: watching nameless, read-only. */
spectating = $state(false);
/** How many watch from the gallery (0 hides the count). */
audience = $state(0);
/** A command on its way: sent, and the table has not answered yet. */
pending = $state<{ label: string; at: number } | null>(null);
/** The last command the table answered, shown for a moment. */
confirmed = $state<string | null>(null);
/** A command that never left: the socket was down when it was tried. */
unsent = $state<string | null>(null);
/** A command that left, then the socket dropped before the table answered. */
unconfirmed = $state<string | null>(null);
/** A line of table talk on its way, until the table echoes it. */
chatPending = $state<string | null>(null);
/** Table talk that never arrived: handed back to the composer. */
chatUnsent = $state<string | null>(null);
/** Who spoke last at the table: your own words are never unread. */
lastTalkBy = $state<string | null>(null);
/** A finished table's call for a rematch: where it went, and who called. */
rematchCall = $state<{ roomId: string; by: string } | null>(null);
/** A rematch lobby: the last table's wizards not yet seated. */
expected = $state<string[]>([]);
/** The table called the keeper of this site, and by whom. */
challenge = $state<{ by: string; at: string } | null>(null);
/** The keeper's name, as the server knows it. */
keeper = $state("Kestrel");
view = $state<GameView | null>(null);
log = $state<LogLine[]>([]);
error = $state<string | null>(null);
/** Your reports and the wizards' replies, proven by seat tokens. */
feedbackReports = $state<FeedbackReportView[]>([]);
/** Every seat this browser holds, across rooms. */
seats = $state<Seat[]>(loadSeats());
/** Lobby ledger: one summary per live seat. */
stats = $state<Record<string, number | string | null> | null>(null);
chatSeen = $state<Record<string, number>>(loadChatSeen());
/** Messages in the current room this session (history + live). */
chatCount = $state(0);
games = $state<GameSummary[]>([]);
notificationsEnabled = $state(
typeof Notification !== "undefined" && Notification.permission === "granted",
);
/** A transfer phrase we minted, to show the user. */
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
/** Moves you haven't watched yet in the current room. */
missedMoves = $state(0);
/** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate live event batches. */
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** The missed steps, fetched for the summary slip before any reel is opened. */
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** Where the catch-up reel opens: the step a summary line pointed at. */
catchUpStart = $state(0);
private catchUpWanted: "summary" | "reel" = "reel";
/** One turn's reel, summoned from a chronicle line's instant-replay
* eye — steps plus the wizard whose eyes the camera wears. */
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
/** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
private shareReject: ((e: Error) => void) | null = null;
private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
private watching: string | null = null;
private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>();
private pollTimer: ReturnType<typeof setInterval> | null = null;
private ws: WebSocket | null = null;
private token: string | null = null;
private roomIdPending: string | null = null;
connect(): void {
if (this.ws) return;
const ws = new WebSocket(SERVER_URL);
this.ws = ws;
this.lastHeard = Date.now();
if (!this.watchdog) {
this.watchdog = setInterval(() => {
if (!this.ws || this.status !== "connected") return;
this.send({ type: "ping" });
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
}, 15_000);
}
ws.onopen = () => {
this.watching = null; // the first state after (re)connecting may carry a gap
this.status = "connected";
// Mid-game reconnects (the socket dropped, not the page) walk straight
// back to the table; otherwise the lobby ledger is the front door.
if (this.spectating && this.roomId) {
// A dropped gallery socket rejoins the gallery, not a seat.
this.send({ type: "watch", roomId: this.roomId });
} else {
const saved = localStorage.getItem(SEAT_KEY);
if (saved && this.roomId) {
try {
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
if (seat.roomId === this.roomId) {
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
} catch { localStorage.removeItem(SEAT_KEY); }
}
}
this.refreshGames();
this.refreshFeedback();
};
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
// Whatever was in flight is now in doubt: the board will say what
// landed when the connection returns, and the talk goes back in the box.
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
if (this.chatPending) { this.chatUnsent = this.chatPending; this.chatPending = null; }
setTimeout(() => this.connect(), 1500);
};
ws.onmessage = (raw) => {
const msg = JSON.parse(raw.data as string);
this.lastHeard = Date.now();
// While the opening roll-off card is up, the game holds still: the
// clockwork's first turn would otherwise play out behind the card
// in a blur nobody watched. Its moves wait, then arrive at a pace.
if ((this.openingRolls || this.draining) && this.view) {
if (msg.type === "state" || msg.type === "events") { this.held.push(msg); return; }
}
this.handle(msg);
};
}
private held: { type: string }[] = [];
private draining = false;
/** Put the roll-off card away and let the held moves play, a beat apart. */
dismissRolls(): void {
this.openingRolls = null;
if (this.draining) return;
this.draining = true;
const step = () => {
const m = this.held.shift();
if (!m || !this.roomId) { this.held = []; this.draining = false; return; }
this.handle(m);
setTimeout(step, m.type === "events" ? 900 : 0);
};
step();
}
private handle(msg: any): void {
switch (msg.type) {
case "pong":
break;
case "seat": {
this.token = msg.token;
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
if (seatRoom) {
const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
this.rememberSeat(seat);
}
break;
}
case "watching":
this.spectating = true;
this.roomId = msg.roomId;
break;
case "audience":
this.audience = msg.count ?? 0;
break;
case "room":
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
this.audience = msg.audience ?? 0;
this.rematchCall = msg.rematch ?? null;
this.expected = msg.expected ?? [];
this.challenge = msg.challenge ?? null;
if (typeof msg.keeper === "string") this.keeper = msg.keeper;
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
this.rememberSeat(seat);
}
this.players = msg.players;
this.hostId = msg.hostId;
this.started = msg.started;
break;
case "state": {
this.view = msg.view;
// The table answered: the last command landed, and any doubt from
// a dropped socket is settled by the board itself.
if (this.pending) {
const label = this.pending.label;
this.pending = null;
this.confirmed = label;
setTimeout(() => { if (this.confirmed === label) this.confirmed = null; }, 1500);
}
this.unconfirmed = null;
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
this.currentSeq = msg.seq;
// Only the FIRST state after arriving carries a gap worth
// announcing. Later states were watched live: a caught-up
// watcher stays caught up, and an announced gap stays FROZEN
// until watched or skipped. A hidden tab accumulates its gap
// honestly.
if (this.watching === this.roomId && document.visibilityState === "visible") {
if (this.missedMoves === 0) this.markSeen();
} else {
const last = this.seen[this.roomId] ?? 0;
this.missedMoves = Math.max(0, msg.seq - last);
if (this.missedMoves === 0) this.markSeen();
// The missed steps come now, for the facts on the slip; the
// reel waits for a tap.
else if (!this.missedSteps) this.requestCatchUp(true);
}
if (document.visibilityState === "visible") this.watching = this.roomId;
}
break;
}
case "catchUp":
if (this.catchUpWanted === "summary") this.missedSteps = msg.steps;
else this.catchUp = msg.steps;
break;
case "moment":
this.moment = { steps: msg.steps, owner: msg.owner };
break;
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
this.shareResolve = null;
this.shareReject = null;
break;
case "events": {
let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
for (const e of msg.events as GameEvent[]) {
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
if (e.type === "gameStarted" && !msg.replayed) {
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
}
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
if (e.type === "turnStarted") this.turnOwner = e.player;
const line = humanize(e);
if (line) {
// Every turn wears its eye, on the header line that opens
// it — the players judge what is share-worthy. The game-
// winning line carries its own besides.
const notable = this.turnCounter >= 0 &&
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable, actor: actorOf(e, this.turnOwner) }];
}
const receipt = receiptFor(msg.events as GameEvent[], e);
if (receipt) {
this.log = [...this.log, { text: receipt.title, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable: false, actor: actorOf(e, this.turnOwner), receipt: receipt.lines }];
}
}
if (talk > 0) {
this.chatCount += talk;
if (this.roomId) this.markChatSeen();
}
break;
}
case "kicked":
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
this.leaveLocal();
break;
case "roomAbandoned":
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
this.leaveLocal();
break;
case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break;
case "transferClaimed": {
const seat = msg.seat as Seat & { roomId: string };
this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
this.refreshGames();
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break;
}
case "rematch":
// The old table hears where the rematch went.
if (msg.roomId === this.roomId) this.rematchCall = { roomId: msg.to, by: msg.by };
break;
case "rematched":
// The caller's own move: the seat and room for the new table follow.
this.resetChronicle();
this.view = null;
this.started = false;
this.roomId = null;
this.roomIdPending = msg.roomId;
break;
case "chat": {
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
this.lastTalkBy = msg.player;
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
break;
}
case "stats": {
this.stats = msg.stats;
break;
}
case "feedbackList":
this.feedbackReports = msg.reports ?? [];
break;
case "feedbackReceived":
this.refreshFeedback();
break;
case "games": {
this.games = msg.games;
for (const g of msg.games as GameSummary[]) {
const key = `${g.roomId}:${g.name}`;
const was = this.lastYourTurn.get(key) ?? false;
if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn);
}
// Prune ONLY seats the server proved dead: the room exists and
// refused this exact token. A seat merely ABSENT from the reply
// stays — a restarting server, a stale restore, or the wrong
// backend all answer with ignorance, and ignorance is not
// deletion. The wallet is capped by age instead of by trust.
const voided = new Set((msg.voided as string[] | undefined) ?? []);
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
if (kept.length > 50) kept = kept.slice(kept.length - 50);
if (kept.length !== this.seats.length) {
this.seats = kept;
saveSeats(this.seats);
}
break;
}
case "error":
// "Name is taken" means THIS token was tried and refused: the
// credential itself is dead, and holding it helps nobody. But
// "no such room" proves nothing about the seat — a restarting
// server, a stale restore, or the wrong backend all say it.
// The seat stays; the error shows; a later reconnect against
// the right server walks back in.
if (this.roomIdPending && /name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
}
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
this.roomIdPending = null;
}
this.pending = null;
this.error = msg.message;
// The toast fades; the chronicle remembers why nothing happened.
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
}
/** True when the message left; false when the socket was not open to carry it. */
private send(message: unknown): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(message));
return true;
}
/** A toast the table shows for a moment, as it shows the server's refusals. */
flash(message: string): void {
this.error = message;
setTimeout(() => { if (this.error === message) this.error = null; }, 5000);
}
create(name: string): void {
this.you = name;
this.spectating = false;
if (!this.send({ type: "create", name })) this.flash("Still reaching the table — try again in a moment");
}
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
watch(roomId: string): void {
this.you = null;
this.resetChronicle();
this.send({ type: "watch", roomId: roomId.toUpperCase() });
}
join(roomId: string, name: string): void {
this.you = name;
this.spectating = false;
this.roomIdPending = roomId.toUpperCase();
const existing = this.seats.find(
(s) => s.roomId === this.roomIdPending && s.name === name,
);
if (!this.send({ type: "join", roomId, name, token: existing?.token ?? this.token })) {
this.flash("Still reaching the table — try again in a moment");
}
}
/** Sit back down at a remembered seat. */
resume(seat: Seat): void {
this.you = seat.name;
this.spectating = false;
this.token = seat.token;
this.roomIdPending = seat.roomId;
this.resetChronicle();
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
private rememberSeat(seat: Seat): void {
const rest = this.seats.filter((s) => !(s.roomId === seat.roomId && s.name === seat.name));
this.seats = [seat, ...rest];
saveSeats(this.seats);
}
/** Drop a finished (or abandoned) game from the ledger. */
forgetSeat(roomId: string): void {
this.seats = this.seats.filter((s) => s.roomId !== roomId);
saveSeats(this.seats);
this.games = this.games.filter((g) => g.roomId !== roomId);
}
/** A surprise report: the server pins it to the room and move number. */
sendFeedback(happened: string, expected: string): void {
this.send({ type: "feedback", happened, expected });
}
refreshFeedback(): void {
if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) });
}
requestTransferCode(): void {
this.send({ type: "makeTransfer" });
}
claimTransfer(code: string): void {
this.send({ type: "claimTransfer", code });
}
addBot(style?: string, tier?: string): void {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
}
kickSeat(name: string): void {
this.send({ type: "kickSeat", name });
}
abandonRoom(): void {
this.send({ type: "abandonRoom" });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}
/** True when the line left; the composer keeps it otherwise. */
sendChat(text: string): boolean {
if (!this.send({ type: "chat", text })) return false;
this.chatPending = text;
return true;
}
/** Call the keeper of this site to the table: a seat is held, and their phone rings. */
challengeKeeper(): void {
this.send({ type: "challengeKeeper" });
}
/** Call for a rematch from a finished table, or join the one already called. */
callRematch(): void {
this.send({ type: "rematch" });
}
/** Take the seat kept for you at the rematch table. */
acceptRematch(roomId: string): void {
if (!this.you) return;
this.resetChronicle();
this.view = null;
this.started = false;
this.join(roomId, this.you);
}
/** Watching the table counts as reading the talk. */
markChatSeen(): void {
if (!this.roomId) return;
const games = this.games.find((g) => g.roomId === this.roomId);
const count = Math.max(this.chatCount, games?.chatCount ?? 0);
if ((this.chatSeen[this.roomId] ?? 0) >= count) return;
this.chatSeen = { ...this.chatSeen, [this.roomId]: count };
localStorage.setItem(CHAT_SEEN_KEY, JSON.stringify(this.chatSeen));
}
unreadChat(roomId: string, chatCount: number): number {
return Math.max(0, chatCount - (this.chatSeen[roomId] ?? 0));
}
requestStats(): void {
this.send({ type: "stats" });
}
/** Anonymous count-only ping so hotseat tables show in the tally. */
reportHotseat(report: {
id: string; stage: "started" | "finished";
players?: number; commands?: number; minutes?: number; winReason?: string;
}): void {
this.send({ type: "hotseatReport", ...report });
}
/** Ask the server how all our games are doing. */
refreshGames(): void {
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });
}
startGamePolling(): void {
if (this.pollTimer) return;
this.refreshGames();
this.refreshFeedback();
this.pollTimer = setInterval(() => { this.refreshGames(); this.refreshFeedback(); }, 45_000);
}
stopGamePolling(): void {
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
}
async enableNotifications(): Promise<void> {
if (typeof Notification === "undefined") return;
const perm = await Notification.requestPermission();
this.notificationsEnabled = perm === "granted";
}
private notifyTurn(g: GameSummary): void {
if (!this.notificationsEnabled || typeof Notification === "undefined") return;
if (this.roomId === g.roomId && !document.hidden) return; // already looking at it
try {
new Notification(
g.attention === "counteract"
? `Wiz-War — you are under attack in ${g.roomId}!`
: `Wiz-War — your turn in ${g.roomId}`,
{
body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`,
tag: `wizwar-${g.roomId}`,
},
);
} catch { /* blocked at the OS level; the tab title still shows it */ }
}
/** Client-side teardown when the SERVER detached us (kick, abandon).
* The chronicle survives: the detachment appended its own explanatory
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
this.missedSteps = null;
this.catchUp = null;
this.pending = null;
this.unsent = null;
this.unconfirmed = null;
this.chatPending = null;
this.rematchCall = null;
this.expected = [];
this.challenge = null;
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.held = [];
this.draining = false;
this.started = false;
this.players = [];
this.token = null;
this.spectating = false;
this.audience = 0;
}
/** Walk away from the table (or gallery) by choice. */
leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
this.leaveLocal();
this.resetChronicle();
}
start(expansion: boolean): void {
this.send({ type: "start", expansion });
}
pickColor(color: number): void {
this.send({ type: "pickColor", color });
}
/** The whole game from the deal, once it is finished. */
requestFullReplay(): void {
this.send({ type: "catchUp", sinceSeq: 0, full: true });
}
/** Ask for the steps since we last watched: for the summary slip, or for the reel. */
requestCatchUp(summaryOnly = false): void {
if (!this.roomId) return;
this.catchUpWanted = summaryOnly ? "summary" : "reel";
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
}
/** Open the reel of the missed steps, at the step a fact pointed to. */
watchCatchUp(startAt = 0): void {
this.catchUpStart = Math.max(0, startAt);
if (this.missedSteps) this.catchUp = this.missedSteps;
else this.requestCatchUp(false);
}
/** All caught up: remember it and clear the banner. */
markSeen(): void {
if (!this.roomId) return;
this.seen[this.roomId] = this.currentSeq;
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
this.missedMoves = 0;
this.missedSteps = null;
}
closeCatchUp(): void {
this.catchUp = null;
this.markSeen();
}
/** The chronicle and its turn count reset together, always. */
private resetChronicle(): void {
this.log = [];
this.turnCounter = -1;
}
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.momentTurn = turn;
this.send({ type: "moment", turn });
}
/** Mint a public link: the open moment's turn, or -1 for the whole
* finished game. One mint in flight at a time — a newcomer supersedes
* a stranded predecessor rather than leaving it pending forever. */
requestShare(turn: number | null = this.momentTurn): Promise<string> {
return new Promise((resolve, reject) => {
if (turn === null) return reject(new Error("no turn open"));
this.shareReject?.(new Error("superseded"));
this.shareResolve = resolve;
this.shareReject = reject;
this.send({ type: "share", turn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;
this.shareReject = null;
reject(new Error("share timed out"));
}
}, 10_000);
});
}
closeMoment(): void {
this.moment = null;
this.momentTurn = null;
}
/** Send a command, or say plainly that it did not go. */
command(command: Command): boolean {
const label = describeCommand(command);
if (!this.send({ type: "command", command })) {
this.unsent = label;
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent — the table is out of reach`);
return false;
}
this.unsent = null;
this.pending = { label, at: Date.now() };
return true;
}
}
/** A command as the player would name it, for the sending and not-sent notes. */
function describeCommand(c: Command): string {
switch (c.type) {
case "move": return "your step";
case "cast": return "your spell";
case "counteract": return "your counter";
case "pass": return "your pass";
case "punch": case "punchWall": return "your punch";
case "endTurn": return "ending your turn";
case "pickUpTreasure": case "pickUpObject": return "the pickup";
case "dropTreasure": case "dropObject": return "the drop";
case "playNumberForMovement": return "your number";
default: return "your action";
}
}
export const net = new Net();