diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e56bd15..b5444aa 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,6 +21,7 @@ import { redactFor, runCommand, startGame, + summarize, viewForPlayer, type Room, } from "./rooms"; @@ -130,6 +131,20 @@ wss.on("connection", (socket) => { broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) })); break; } + case "myGames": { + // {seats: [{roomId, name, token}]} -> summaries for valid seats. + const seats = Array.isArray(msg.seats) ? msg.seats : []; + const games = []; + for (const seat of seats) { + const room = getRoom(String(seat.roomId ?? "")); + if (!room) continue; + const name = String(seat.name ?? ""); + if (room.tokens.get(name) !== seat.token) continue; + games.push(summarize(room, name)); + } + send(socket, { type: "games", games }); + break; + } default: send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` }); } diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index e20867e..879f7aa 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -140,6 +140,39 @@ export function runCommand( return { events: result.events }; } +export interface GameSummary { + roomId: string; + name: PlayerId; + players: PlayerId[]; + started: boolean; + finished: boolean; + winner: PlayerId | null; + activePlayerId: PlayerId | null; + yourTurn: boolean; + round: number | null; + lastMoveAt: string | null; +} + +/** A seat-holder's one-line view of a room, for the lobby ledger. */ +export function summarize(room: Room, playerId: PlayerId): GameSummary { + const s = room.state; + const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; + const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null; + const turnHolder = waitingOn ?? active; + return { + roomId: room.id, + name: playerId, + players: [...room.players], + started: s !== null, + finished: s?.phase === "finished", + winner: s?.winner ?? null, + activePlayerId: active, + yourTurn: s?.phase === "playing" && turnHolder === playerId, + round: s?.turn.round ?? null, + lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, + }; +} + export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null { return room.state ? viewFor(room.state, playerId) : null; } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 3964654..40da214 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -6,6 +6,7 @@ import type { CardInstance, Side } from "@wizwar/engine"; net.connect(); + net.startGamePolling(); let name = $state(""); let joinCode = $state(""); @@ -400,6 +401,41 @@ if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight; }); + // Tab title + favicon carry the turn signal even from another tab. + const anyTurnWaiting = $derived( + (view != null && view.phase === "playing" && + (isYourTurn || youMustRespond || youMustDiscard || + view.outOfTurnWindow?.playerId === view.you)) || + net.games.some((g) => g.yourTurn && g.roomId !== net.roomId), + ); + const FAVICON_IDLE = + "data:image/svg+xml," + encodeURIComponent( + `W`.replaceAll("%23", "#"), + ); + const FAVICON_TURN = + "data:image/svg+xml," + encodeURIComponent( + `W`.replaceAll("%23", "#"), + ); + $effect(() => { + document.title = anyTurnWaiting ? "● Your turn — Wiz-War" : "Wiz-War"; + let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null; + if (!link) { + link = document.createElement("link"); + link.rel = "icon"; + document.head.appendChild(link); + } + link.href = anyTurnWaiting ? FAVICON_TURN : FAVICON_IDLE; + }); + + function timeAgo(iso: string | null): string { + if (!iso) return "no moves yet"; + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + if (s < 90) return "moments ago"; + if (s < 3600) return `${Math.round(s / 60)} min ago`; + if (s < 86400) return `${Math.round(s / 3600)} h ago`; + return `${Math.round(s / 86400)} d ago`; + } + const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"]; function playerColor(id: string): string { const idx = view?.players.findIndex((p) => p.id === id) ?? 0; @@ -443,6 +479,42 @@ Join + + {#if net.seats.length > 0} +
+
+ your games + {#if !net.notificationsEnabled} + + {/if} +
+ {#each net.seats as seat (seat.roomId + seat.name)} + {@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)} +
+ + +
+ {/each} +
+ {/if} {:else if !net.started} @@ -776,6 +848,62 @@ .check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; } .waiting { color: #6b5a41; font-style: italic; } + /* the games ledger */ + .ledger { + margin-top: 1.6rem; + border-top: 1.5px solid #6b5a41; + padding-top: 0.5rem; + text-align: left; + } + .ledger-head { + display: flex; + justify-content: space-between; + align-items: baseline; + font-family: "Caveat", cursive; + font-size: 1.15rem; + color: #6b5a41; + margin-bottom: 0.3rem; + } + .ledger-row { + display: flex; + align-items: center; + gap: 0.4rem; + border-radius: 4px; + padding: 0.15rem 0.3rem; + } + .ledger-row.your-turn { background: rgba(46, 125, 50, 0.14); } + .ledger-resume { + flex: 1; + display: flex; + align-items: baseline; + gap: 0.7rem; + background: none; + border: none; + padding: 0.3rem 0.2rem; + cursor: pointer; + text-align: left; + color: #43331f; + font-family: "Archivo Narrow", sans-serif; + font-size: 0.95rem; + } + .ledger-resume:hover .ledger-code { text-decoration: underline; } + .ledger-code { + font-family: "Oswald", sans-serif; + font-weight: 600; + letter-spacing: 0.12em; + } + .ledger-row.your-turn .ledger-info { color: #1d5720; font-weight: 600; } + .ledger-info { color: #6b5a41; } + .ledger-forget { + background: none; + border: none; + color: #a4906c; + font-size: 1.05rem; + cursor: pointer; + padding: 0 0.3rem; + } + .ledger-forget:hover { color: #b3372b; } + .stamp { font-family: "Oswald", sans-serif; font-weight: 500; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index ad516d3..56395cd 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -132,6 +132,29 @@ function humanize(e: GameEvent): string | null { } const SEAT_KEY = "wizwar-seat"; +const SEATS_KEY = "wizwar-seats"; + +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; + round: number | null; + lastMoveAt: string | null; +} + +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"); @@ -143,6 +166,15 @@ class Net { 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. */ + games = $state([]); + notificationsEnabled = $state( + typeof Notification !== "undefined" && Notification.permission === "granted", + ); + private lastYourTurn = new Map(); + private pollTimer: ReturnType | null = null; private ws: WebSocket | null = null; private token: string | null = null; @@ -154,18 +186,18 @@ class Net { this.ws = ws; ws.onopen = () => { this.status = "connected"; - // A remembered seat means a game to walk back to (surviving reloads - // AND server restarts — the server replays the room from disk). + // 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) { + if (saved && this.roomId) { try { const seat = JSON.parse(saved) as { name: string; roomId: string; token: string }; - this.you = seat.name; - this.token = seat.token; - this.roomIdPending = seat.roomId; - this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token }); + 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"; @@ -175,19 +207,23 @@ class Net { ws.onmessage = (raw) => { const msg = JSON.parse(raw.data as string); switch (msg.type) { - case "seat": + case "seat": { this.token = msg.token; - if (this.roomIdPending || this.roomId) { - localStorage.setItem(SEAT_KEY, JSON.stringify({ - name: msg.playerId, roomId: this.roomIdPending ?? this.roomId, 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; - localStorage.setItem(SEAT_KEY, JSON.stringify({ - name: this.you, roomId: msg.roomId, token: this.token, - })); + 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; @@ -201,6 +237,16 @@ class Net { if (line) this.log = [...this.log, line]; } 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); @@ -225,7 +271,63 @@ class Net { join(roomId: string, name: string): void { this.you = name; this.roomIdPending = roomId.toUpperCase(); - this.send({ type: "join", roomId, name, token: this.token }); + 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); + } + + /** 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 { + 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(`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. */