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
@@ -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(
|
||||
`<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"];
|
||||
function playerColor(id: string): string {
|
||||
const idx = view?.players.findIndex((p) => p.id === id) ?? 0;
|
||||
@@ -443,6 +479,42 @@
|
||||
Join
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
{: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;
|
||||
|
||||
Reference in New Issue
Block a user