// 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 = { "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 = { 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 { try { return JSON.parse(localStorage.getItem(CHAT_SEEN_KEY) ?? "{}"); } catch { return {}; } } const SEEN_KEY = "wizwar-seen"; function loadSeen(): Record { 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 | null = null; roomId = $state(null); players = $state([]); hostId = $state(null); started = $state(false); /** Lobby standee choices, by player name. */ roomColors = $state>({}); roomBots = $state>({}); you = $state(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(null); /** A command that never left: the socket was down when it was tried. */ unsent = $state(null); /** A command that left, then the socket dropped before the table answered. */ unconfirmed = $state(null); /** A line of table talk on its way, until the table echoes it. */ chatPending = $state(null); /** Table talk that never arrived: handed back to the composer. */ chatUnsent = $state(null); /** Who spoke last at the table: your own words are never unread. */ lastTalkBy = $state(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([]); /** 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(null); log = $state([]); error = $state(null); /** Your reports and the wizards' replies, proven by seat tokens. */ feedbackReports = $state([]); /** Every seat this browser holds, across rooms. */ seats = $state(loadSeats()); /** Lobby ledger: one summary per live seat. */ stats = $state | null>(null); chatSeen = $state>(loadChatSeen()); /** Messages in the current room this session (history + live). */ chatCount = $state(0); games = $state([]); 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; 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 = 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(); private pollTimer: ReturnType | 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 { 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 { 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();