Files
wizwar6e/packages/web/src/net.svelte.ts
T
Eric WagonerandClaude Fable 5 d2b40ec6f8 Credibility pass: the seams get sanded
Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.

Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.

Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).

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

577 lines
26 KiB
TypeScript

// Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine";
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;
}
}
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 at = e.target ? ` at ${e.target}` : "";
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${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 the spell into their hand!`;
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": return `${e.player} takes ${e.amount} damage (${e.source}) — ${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 "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "trapSprung": return `${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.player} is stone — the damage has no effect.`;
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
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 "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 "doorUnlocked": return `${e.player} unlocks a door.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
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.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": 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 "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
case "safeCreated": return `A massive safe slams down around the loot.`;
case "safeOpened": return null;
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 "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 "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
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";
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${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;
}
}
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 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;
}
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");
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);
view = $state<GameView | null>(null);
log = $state<string[]>([]);
error = $state<string | null>(null);
/** 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 = 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);
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;
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.
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();
};
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
setTimeout(() => this.connect(), 1500);
};
ws.onmessage = (raw) => {
const msg = JSON.parse(raw.data as string);
switch (msg.type) {
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 "room":
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
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;
if (typeof msg.seq === "number" && this.roomId) {
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();
}
if (document.visibilityState === "visible") this.watching = this.roomId;
}
break;
}
case "catchUp":
this.catchUp = msg.steps;
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++;
if (e.type === "gameStarted" && !msg.replayed) {
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
}
const line = humanize(e);
if (line) this.log = [...this.log, line];
}
if (talk > 0) {
this.chatCount += talk;
if (this.roomId) this.markChatSeen();
}
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 "chat": {
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
break;
}
case "stats": {
this.stats = msg.stats;
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);
}
break;
}
case "error":
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
this.roomIdPending = null;
}
this.error = msg.message;
// The toast fades; the chronicle remembers why nothing happened.
this.log = [...this.log, `— ${msg.message} —`];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
};
}
private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
}
create(name: string): void {
this.you = name;
this.send({ type: "create", name });
}
join(roomId: string, name: string): void {
this.you = name;
this.roomIdPending = roomId.toUpperCase();
const existing = this.seats.find(
(s) => s.roomId === this.roomIdPending && s.name === name,
);
this.send({ type: "join", roomId, name, token: existing?.token ?? this.token });
}
/** Sit back down at a remembered seat. */
resume(seat: Seat): void {
this.you = seat.name;
this.token = seat.token;
this.roomIdPending = seat.roomId;
this.log = [];
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);
}
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 } : {}) });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}
sendChat(text: string): void {
this.send({ type: "chat", text });
}
/** 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.pollTimer = setInterval(() => this.refreshGames(), 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 */ }
}
/** Forget the remembered seat and return to the lobby. */
leave(): void {
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.log = [];
this.token = null;
}
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 reel of everything since we last watched. */
requestCatchUp(): void {
if (!this.roomId) return;
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
}
/** 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;
}
closeCatchUp(): void {
this.catchUp = null;
this.markSeen();
}
command(command: Command): void {
this.send({ type: "command", command });
}
}
export const net = new Net();