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
@@ -21,6 +21,7 @@ import {
|
|||||||
redactFor,
|
redactFor,
|
||||||
runCommand,
|
runCommand,
|
||||||
startGame,
|
startGame,
|
||||||
|
summarize,
|
||||||
viewForPlayer,
|
viewForPlayer,
|
||||||
type Room,
|
type Room,
|
||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
@@ -130,6 +131,20 @@ wss.on("connection", (socket) => {
|
|||||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
|
||||||
break;
|
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:
|
default:
|
||||||
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
|
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,39 @@ export function runCommand(
|
|||||||
return { events: result.events };
|
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 {
|
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
|
||||||
return room.state ? viewFor(room.state, playerId) : null;
|
return room.state ? viewFor(room.state, playerId) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { CardInstance, Side } from "@wizwar/engine";
|
import type { CardInstance, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
net.connect();
|
net.connect();
|
||||||
|
net.startGamePolling();
|
||||||
|
|
||||||
let name = $state("");
|
let name = $state("");
|
||||||
let joinCode = $state("");
|
let joinCode = $state("");
|
||||||
@@ -400,6 +401,41 @@
|
|||||||
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
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(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%23171a20"/><text x="16" y="23" font-family="Georgia" font-size="19" font-weight="bold" fill="%23e9e1cb" text-anchor="middle">W</text></svg>`.replaceAll("%23", "#"),
|
||||||
|
);
|
||||||
|
const FAVICON_TURN =
|
||||||
|
"data:image/svg+xml," + encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%23171a20"/><text x="16" y="23" font-family="Georgia" font-size="19" font-weight="bold" fill="%23e9e1cb" text-anchor="middle">W</text><circle cx="25" cy="7" r="6" fill="%232e7d32"/></svg>`.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"];
|
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
|
||||||
function playerColor(id: string): string {
|
function playerColor(id: string): string {
|
||||||
const idx = view?.players.findIndex((p) => p.id === id) ?? 0;
|
const idx = view?.players.findIndex((p) => p.id === id) ?? 0;
|
||||||
@@ -443,6 +479,42 @@
|
|||||||
Join
|
Join
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if net.seats.length > 0}
|
||||||
|
<div class="ledger">
|
||||||
|
<div class="ledger-head">
|
||||||
|
<span>your games</span>
|
||||||
|
{#if !net.notificationsEnabled}
|
||||||
|
<button class="hint-cancel" onclick={() => net.enableNotifications()}>
|
||||||
|
notify me on my turn
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#each net.seats as seat (seat.roomId + seat.name)}
|
||||||
|
{@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)}
|
||||||
|
<div class="ledger-row" class:your-turn={g?.yourTurn}>
|
||||||
|
<button class="ledger-resume" onclick={() => net.resume(seat)}>
|
||||||
|
<span class="ledger-code">{seat.roomId}</span>
|
||||||
|
<span class="ledger-info">
|
||||||
|
{#if !g}
|
||||||
|
as {seat.name} — unreachable
|
||||||
|
{:else if g.finished}
|
||||||
|
{g.winner === seat.name ? "you won! 🏆" : `${g.winner} won`}
|
||||||
|
{:else if !g.started}
|
||||||
|
waiting to start · {g.players.join(", ")}
|
||||||
|
{:else if g.yourTurn}
|
||||||
|
YOUR TURN · round {g.round} · {timeAgo(g.lastMoveAt)}
|
||||||
|
{:else}
|
||||||
|
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="ledger-forget" title="forget this game"
|
||||||
|
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if !net.started}
|
{: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; }
|
.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; }
|
.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 {
|
.stamp {
|
||||||
font-family: "Oswald", sans-serif;
|
font-family: "Oswald", sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
+117
-15
@@ -132,6 +132,29 @@ function humanize(e: GameEvent): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SEAT_KEY = "wizwar-seat";
|
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 {
|
class Net {
|
||||||
status = $state<"disconnected" | "connected">("disconnected");
|
status = $state<"disconnected" | "connected">("disconnected");
|
||||||
@@ -143,6 +166,15 @@ class Net {
|
|||||||
view = $state<GameView | null>(null);
|
view = $state<GameView | null>(null);
|
||||||
log = $state<string[]>([]);
|
log = $state<string[]>([]);
|
||||||
error = $state<string | null>(null);
|
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 ws: WebSocket | null = null;
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
@@ -154,18 +186,18 @@ class Net {
|
|||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
this.status = "connected";
|
this.status = "connected";
|
||||||
// A remembered seat means a game to walk back to (surviving reloads
|
// Mid-game reconnects (the socket dropped, not the page) walk straight
|
||||||
// AND server restarts — the server replays the room from disk).
|
// back to the table; otherwise the lobby ledger is the front door.
|
||||||
const saved = localStorage.getItem(SEAT_KEY);
|
const saved = localStorage.getItem(SEAT_KEY);
|
||||||
if (saved && !this.roomId) {
|
if (saved && this.roomId) {
|
||||||
try {
|
try {
|
||||||
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
|
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
|
||||||
this.you = seat.name;
|
if (seat.roomId === this.roomId) {
|
||||||
this.token = seat.token;
|
|
||||||
this.roomIdPending = seat.roomId;
|
|
||||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||||
|
}
|
||||||
} catch { localStorage.removeItem(SEAT_KEY); }
|
} catch { localStorage.removeItem(SEAT_KEY); }
|
||||||
}
|
}
|
||||||
|
this.refreshGames();
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
this.status = "disconnected";
|
this.status = "disconnected";
|
||||||
@@ -175,19 +207,23 @@ class Net {
|
|||||||
ws.onmessage = (raw) => {
|
ws.onmessage = (raw) => {
|
||||||
const msg = JSON.parse(raw.data as string);
|
const msg = JSON.parse(raw.data as string);
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "seat":
|
case "seat": {
|
||||||
this.token = msg.token;
|
this.token = msg.token;
|
||||||
if (this.roomIdPending || this.roomId) {
|
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||||
localStorage.setItem(SEAT_KEY, JSON.stringify({
|
if (seatRoom) {
|
||||||
name: msg.playerId, roomId: this.roomIdPending ?? this.roomId, token: msg.token,
|
const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
|
||||||
}));
|
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||||
|
this.rememberSeat(seat);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "room":
|
case "room":
|
||||||
this.roomId = msg.roomId;
|
this.roomId = msg.roomId;
|
||||||
localStorage.setItem(SEAT_KEY, JSON.stringify({
|
if (this.you && this.token) {
|
||||||
name: this.you, roomId: msg.roomId, token: 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.players = msg.players;
|
||||||
this.hostId = msg.hostId;
|
this.hostId = msg.hostId;
|
||||||
this.started = msg.started;
|
this.started = msg.started;
|
||||||
@@ -201,6 +237,16 @@ class Net {
|
|||||||
if (line) this.log = [...this.log, line];
|
if (line) this.log = [...this.log, line];
|
||||||
}
|
}
|
||||||
break;
|
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":
|
case "error":
|
||||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
@@ -225,7 +271,63 @@ class Net {
|
|||||||
join(roomId: string, name: string): void {
|
join(roomId: string, name: string): void {
|
||||||
this.you = name;
|
this.you = name;
|
||||||
this.roomIdPending = roomId.toUpperCase();
|
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. */
|
/** Forget the remembered seat and return to the lobby. */
|
||||||
|
|||||||
Reference in New Issue
Block a user