Phase 2: async play-by-turn — the games ledger and turn signals
The lobby is now the front door to all your games. A "your games" ledger lists every seat this browser holds — room code, whose turn it is (including counteraction/discard/interrupt waits, which count as your turn), the round, and how long since the last move — with one-click resume, a forget control, and a green highlight when a game waits on you. The server answers a token-validated myGames query with per-seat summaries; the client polls every 45 seconds, so turns in other rooms reach you wherever you are. Turn signals travel three ways: the tab title flips to "● Your turn", the favicon grows a green dot, and — opt-in via "notify me on my turn" — a browser notification fires when a turn becomes yours anywhere. Landing in the app now shows the ledger rather than teleporting into the last game; mid-game socket drops still walk straight back to the table. Verified live: two seeded games, ledger showing "gandalf's turn" and YOUR TURN, one-click resume into the correct game with history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7cfffb8ab5
commit
bc8013863b
+118
-16
@@ -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<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. */
|
||||
games = $state<GameSummary[]>([]);
|
||||
notificationsEnabled = $state(
|
||||
typeof Notification !== "undefined" && Notification.permission === "granted",
|
||||
);
|
||||
private lastYourTurn = new Map<string, boolean>();
|
||||
private pollTimer: ReturnType<typeof setInterval> | 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<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(`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. */
|
||||
|
||||
Reference in New Issue
Block a user