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(
+ ``.replaceAll("%23", "#"),
+ );
+ const FAVICON_TURN =
+ "data:image/svg+xml," + encodeURIComponent(
+ ``.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. */