V2 Phase 5+6: connectivity, presence, navigation & friction

Phase 5 — live-session & multi-device hardening:
- connectivityStore + SyncStatusIndicator in the shell: shows "Offline" when the
  browser is offline and the cloud autosave state (saving / saved / failed) when
  signed in; useCloudAutosave now reports its status. Owns online/offline events.
- server presence heartbeat: protocol-level ping/pong per socket terminates a
  vanished connection (~30s), so a player who closes their laptop drops out of
  the GM's roster instead of lingering. No client changes needed.

Phase 6 — onboarding & friction:
- navigation: surfaced the previously orphaned routes in the rail — a new
  "World" group (Notes, NPCs, Quests, Calendar) and Homebrew + Assistant under
  Reference. (Empty-state hints and map rename already existed.)
- combat keyboard control: n/→ next turn, p/← previous turn, guarded so it never
  fires while typing; button tooltips document it.

Test hygiene: scoped now-ambiguous nav links in e2e to the Primary landmark, and
fixed pre-existing stale emoji selectors in the realtime suite (📡 Host → Host,
📨 Just for you → Just for you) left over from the Living Codex emoji purge.

Gate green: tsc + 277 unit + 34 e2e + 2 realtime. App + server build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 08:51:03 +02:00
parent 3d2c68a1a1
commit dc0e8f701c
13 changed files with 141 additions and 16 deletions
+14 -1
View File
@@ -23,6 +23,10 @@ const bearer = (req: FastifyRequest): string | undefined => {
// per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 };
// Protocol-level keepalive: ping each socket on an interval and terminate one
// that misses a pong, so a vanished player (closed laptop, dead network) is
// detected and drops out of the GM's roster within ~one interval.
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT });
@@ -155,6 +159,15 @@ export function buildServer() {
let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('message', (raw: Buffer) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
let parsed;
@@ -177,7 +190,7 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break;
}
});
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
});
});