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
1031 lines
48 KiB
TypeScript
1031 lines
48 KiB
TypeScript
// 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();
|