b70c04b8

Author: Michael Lynch <git@mtlynch.io>

Fix session takeover signaling and slim the reconnect plumbing

The replaced-tab flow never worked over the wire: stopping a channel
with a non-shutdown reason reaches phoenix.js as a bare "phx_error"
with an empty payload, so the client classified takeovers as
"reconnecting" and auto-rejoined, letting two tabs on one session
steal it from each other forever. The server now pushes an explicit
"session_replaced" event before stopping with {:shutdown, :replaced},
and the client reacts to that event alone. Covered by a channel test,
a network unit test, and a two-tab e2e test.

Robustness and performance:
- Suspended sessions are pruned after a retention period
  (session_retention_ms, default one hour); previously state.sessions
  grew by one entry per page load forever.
- A connections index maps connection_ref -> session_id, replacing the
  per-message linear scan over all sessions (including 20 Hz
  report_state casts and per-tick delivery).
- Session tokens now expire after 30 days instead of never.
- Token verification moved from socket connect to channel join so a
  stale token (rotated secret, expired) surfaces as a join error the
  client can see; app.ts then discards the stored token and reloads
  once instead of wedging in a silent reconnect loop.

Simplifications:
- Removed world_instance_id/revision from every payload and event;
  nothing consumed them. Removed the unused onRejoin/onStateChange
  handler hooks.
- Inventory counts now flow only through inventory_changed totals;
  pickup events carry ids for sprite removal but no counts, and the
  event is only sent when totals actually changed.
- World events deliver synchronously instead of bouncing through a
  send-to-self hop, restoring event-before-snapshot ordering.
- One fenced-push helper in Network drops stale replies for all call
  sites, replacing four hand-rolled connectionCycle guards.
- disconnect_grace_ms has one default (State.disconnect_grace_ms/0);
  the config entry and struct default duplicating it are gone.
- Texture creators are idempotent, so scene restarts call them
  unconditionally instead of guarding at every call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Total

5m04s

Time to Start Worker time Duration Time to finish Idle
Config 4s 14s 14s 18s 4s
Eval 17s 29s 29s 47s 0s
Build 45s 8m59s 4m24s 5m09s 3s
Suite 4s 9m43s 5m04s 5m09s 8s

Timeline

Config Eval Build
0s1m2m3m4m5m