// 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.hostname === "localhost" ? "ws://localhost:8787" : `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`); export function humanize(e: GameEvent): string | null { switch (e.type) { case "gameStarted": return `Game started — ${e.players.join(", ")}. ${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 `${cardDef(e.cardId).name} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellExpired": return `${cardDef(e.cardId).name} 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 troll swings (rolled ${e.dieRoll})!` : `The creature strikes!`; case "creatureTouched": return `The creature falls upon ${e.player}!`; case "creatureDamaged": return e.amount > 0 ? `The ${e.creatureId} takes ${e.amount} damage.` : `The attack has no effect on it.`; 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 "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.length} card(s).`; 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 SEEN_KEY = "wizwar-seen"; function loadSeen(): Record { try { return JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); } catch { return {}; } } export interface Seat { name: string; roomId: string; token: string } export interface 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; } 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(null); players = $state([]); hostId = $state(null); started = $state(false); /** Lobby standee choices, by player name. */ roomColors = $state>({}); you = $state(null); view = $state(null); log = $state([]); error = $state(null); /** Every seat this browser holds, across rooms. */ seats = $state(loadSeats()); /** Lobby ledger: one summary per live seat. */ stats = $state | null>(null); games = $state([]); notificationsEnabled = $state( typeof Notification !== "undefined" && Notification.permission === "granted", ); /** A transfer phrase we minted, to show the user. */ transferCode = $state<{ code: string; expiresAt: number } | null>(null); /** Moves you haven't watched yet in the current room. */ missedMoves = $state(0); /** A catch-up reel delivered by the server. */ catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null); private seen: Record = loadSeen(); private currentSeq = 0; private lastYourTurn = new Map(); private pollTimer: ReturnType | null = null; private ws: WebSocket | null = null; private token: string | null = null; private roomIdPending: string | null = null; connect(): void { if (this.ws) return; const ws = new WebSocket(SERVER_URL); this.ws = ws; ws.onopen = () => { 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 ?? {}; 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; const last = this.seen[this.roomId] ?? 0; this.missedMoves = Math.max(0, msg.seq - last); // Watching live counts as seeing; only a fresh arrival has a gap. if (this.missedMoves === 0 || document.visibilityState === "visible") { // A live update while present marks itself seen. if (last >= msg.seq - 1) this.markSeen(); } } break; } case "catchUp": this.catchUp = msg.steps; break; case "events": for (const e of msg.events as GameEvent[]) { const line = humanize(e); if (line) this.log = [...this.log, line]; } 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 "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; 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 }); } /** Ask the server how all our games are doing. */ 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 }); } 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 { 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 }); } /** 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();