Files
wizwar6e/packages/web/src/net.svelte.ts
T
Eric WagonerandClaude Fable 5 75d3d44c6c The Ward is played in the moment (rules rev 31)
'When a player picks up one of your treasures, you may play at that
time (out of turn) this card on him' — the owner read the card and is
right: no arming ahead. When a treasure whose owner holds WARD is
grabbed, the grab hangs (new wardPending phase, outranking all other
input) and the owner alone answers: spring it (3 damage, card spent)
or let them go (card kept, silence). Automatons always spring it on a
thief of their gold. The arming mechanic is refused at rev 31 and its
button hidden; rev 3-30 games keep their armed wards and rev 1-2 keep
the automatic spring, replaying unchanged. The window does reveal that
the owner holds SOMETHING - as it would at any real table when the
room turns to look at them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 20:17:43 -04:00

591 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView } 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 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 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": {
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 "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 "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 "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 "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 "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 "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();