# Connection and sync — never pretend to be online

## overview.md
```yaml
id: connection-and-sync
title: Connection and sync — never pretend to be online
area: client
summary: A client–server that tells the truth about its connection — freezes rather than fakes when a channel dies, recovers visibly, answers every intent, never shuts an Area down under a player — and Areas that declare whether they need a connection at all.
repo: land-of-lor (game/world client + Area server, game/director) and this workspace (board, deliverables, site/play)
depends_on: ["world-map-and-portals","ground-types"]
```

## What this is

Kris played v10 and v11 on the phone (APK) and in a browser, and the two views of the Village
diverged: the other body stopped moving in one client and never appeared in the other, and the
APK walked straight through portal rings — which are entirely server-driven. The server had the
APK's character barely moved. The transport was fine (a native headless client from the laptop,
same TLS + Caddy path, gets every move acked, portal hop included). What was wrong was **how the
client behaves when its channel dies** — and, in Kris's words, the deeper thing: *we do want the
game to be able to be played offline, that is important, but we don't ever want to fake that we
are online when we are not — which is kind of what the current behaviour looks like.*

This epic is the answer to both halves. It comes out of a full review of the client, the Area
server, the director and the platform (three passes, 2026-08-18) whose findings are the task
list; the plan that produced it is summarised in `tasks/001` and each task's own file.

## The diagnosis, in short

`game/world/client/client_main.gd`: `expecting_game_close` is a sticky file-global, armed on
every reconnect and portal hop and only ever consumed by a disconnect on a peer that has already
been replaced and is never polled again — so the *next real* drop is swallowed with no rejoin, no
reset, no message. The client keeps predicting into the void, walks through portals, sees nobody.
Any portal arrival arms it; a phone's radio hiccup or background pause springs it. The 016
liveness watchdog then made it near-deterministic: its receive timestamp is never seeded on
connect, so on any real network it kills a healthy connection first, forcing the reconnect that
arms the flag. Every automated test runs on localhost, where the first packet lands in the same
frame and hides both.

Around that root cause the review found the same species everywhere: silent no-op sends and
misleading logs; kick reasons (`TAKEN_OVER`, `BAD_GRANT`) sent and closed in one tick, which this
Godot client cannot read (the very race 012 fixed for `portal_result`); intents overwritten
per tick with no ack or reject; a peer left unbound answering `NOT_HELLO` forever with nothing
prompting a re-hello; a portal transfer with no timeout; a snapshot pipeline that wedges
silently; an Area process that exits without a word to its players; and — on the director — an
Area that can be shut down under a live player because presence is counted on the *director*
socket, not the game one (the 016 heartbeat made that more likely, not less). Fifteen
constructible failure states, most with no self-heal, all indistinguishable in production logs
from "the phone's network died".

## Principles

- **Truth over motion.** The avatar moves only on a channel that is demonstrably alive — recent
  frames and recent acks. A stalled channel freezes the avatar and says so.
- **One owner of connection state per channel**, an explicit, tested state machine, no sticky
  globals; every transition logged with a reason.
- **Silence and non-acknowledgement are both signals**, on both sides.
- **Every intent gets an answer; every kick carries its reason and arrives before the close.**
- **The Area process is the authority on who is present.** An Area shuts down when its game
  peers are gone, never because a director socket died.
- **Every failure a player can hit has a sentence on screen**, shown by code that doesn't hide
  it in the same breath.
- **Areas declare their connectivity** — `single_player` / `multiplayer` / `either` (default
  `multiplayer`; the home `either`) — and the client honours it: no play in a `multiplayer` Area
  without a live channel; the home playable offline (walk-only, for now), its doors to
  `multiplayer` Areas closed with a bump.
- **Faults are reproducible on the laptop** before they're chased on the phone; and the phone
  reports its own diagnostics.

## Decisions (with Kris, 2026-08-18)

- Offline home is **walk-only** — nothing persisted or synced; offline persistence is a later
  epic once there is something to persist.
- A `multiplayer` Area may run on a stalled connection **~15 s** (avatar frozen from the first
  second, reconnects running) before play stops with an error until the connection is back.
- Values: `single_player`, `multiplayer`, `either`.

## Non-goals

- Offline persistence or merging offline changes.
- Anti-cheat beyond what server authority already gives.
- Interest management (radius) — it stays off; the review notes it would need the same truth.

## Relevant docs

- `epics/world-map-and-portals/tasks/010–016` — the seven earlier bugs of this class.
- `land-of-lor/game/README.md` — "Known limits", the deploy sections.
- `land-of-lor/docs/handoff/connection-and-sync.md` — written by 008 with the full diagnosis.

## status.md
```yaml
updated: 2026-08-19
parked: false
tasks: {"backlog":1,"needs-input":0,"ready":1,"doing":0,"review":0,"done":6}
open_questions: []
tag: agent-ready
next: 006 — local play, the home when there is no realm — is ready (005 signed off, so its blocker is gone); 008 last.
```

# Task summary

8 tasks, created 2026-08-18 from the review that followed Kris's report. 001 first and alone
(it fixes the reported bug and the two hazards v11 introduced); 002 ∥ 003; 004 and 005 after 002;
006 after 005; 007 alongside from 002; 008 last.

- **001 — stop the bleeding** — `done`. Built and live 2026-08-18 (director 7a07744, web, APK v12).
- **002 — the client connection state machine** — `done`. Built and live 2026-08-18 (APK v13).
- **003 — server truth** — `done`. Built and live 2026-08-18.
- **004 — observability: the indicator and the report** — `done`. The HUD indicator and its
  panel, the client ring buffer, `client_report` to the director, `npm run client-reports`, and
  the Area server's per-peer counters. Built and live 2026-08-19.
- **005 — Area connectivity: single_player / multiplayer / either** — `done`. The column and its
  migration, the world-content validator, the public map, `hello_ack`, the in-game map's marks,
  and the editor's Connection control. Built and live 2026-08-19.
- **006 — local play: the home when there is no realm** — `ready`. 005 is signed off.
- **007 — fault-injection harness and the soak** — `done`. The fault proxy, eleven proxy-backed
  tests against the real client and server, `npm run soak`, the phone checklist — and the portal
  refcount bug the soak found, fixed. Built and live 2026-08-19.
- **008 — ship, verify, record** — `backlog`. Delivers deliverable 011.

## Open questions

None.

## Next

**006 — local play: the home when there is no realm.** 004, 005 and 007 were built in parallel
worktrees on 2026-08-19, merged into master (director suite 225/225, Godot 268/268 on the merged
tree), deployed to the droplet (`1cd5d9c`; live verifiers 10/10, 10/10, 15/15, 2/2), shipped as
the `/play` web build and APK v17, and **signed off by Kris the same day after testing v17 on the
phone** — airplane mode behaved as specified (the client says it cannot reach the realm and stops
rather than pretending). 008 last. 001–003: signed off 2026-08-18.

Noted in passing at that sign-off, not yet a task: when a `multiplayer` Area gives up, the client
currently drops to the home screen — Kris would rather it land somewhere more useful eventually
(a play-offline button), which is 006's territory to shape.

## tasks/ (8)

### connection-and-sync/001 — Stop the bleeding — the sticky close flag, the unseeded watchdog, readable kicks, and no Area shut down under a player
```yaml
id: connection-and-sync/001
title: Stop the bleeding — the sticky close flag, the unseeded watchdog, readable kicks, and no Area shut down under a player
epic: connection-and-sync
state: done
priority: 1
blocked_by: []
estimate: S
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/test/client-liveness.test.ts
```

## What prompted it

Kris on the phone, v10 and then v11: the APK's character walked straight through portal rings
(server-driven, so impossible on a live channel), the other player never appeared, and the
server had the character barely moved. Read `overview.md`'s diagnosis. This task is the
smallest change that removes the cause and the two hazards v11 added, and it ships alone.

## What to do

**Client** (`game/world/client/client_main.gd`, `client/ui/app.gd`):
- `expecting_game_close` becomes a property of the peer being closed: stash the closing
  `WebSocketPeer` (`_closing_ws`) and only a disconnect observed on *that* peer consumes the
  flag; the new peer starts with the flag clear. (Minimum: clear it right after
  `game_ws = WebSocketPeer.new()` in `_connect_game_channel`.)
- Seed `_last_game_rx_ms = _last_ping_ms = Time.get_ticks_msec()` where `game_connected =
  true` is set in `_connect_game_channel` — the watchdog must never judge a fresh socket by an
  old (or zero) timestamp.
- Every branch of `_on_game_disconnected` logs a `CLIENT_EVENT type=game_disconnected
  reason=…`; `_send_game` returns `bool` and logs a no-op once per silence; `move_intent_sent`
  is logged only when the frame actually went; a `send_text` error is treated as a drop.
- `_apply_error`: `NOT_HELLO` → re-hello (as `PORTAL_REJECTED` already does); `BAD_GRANT` →
  `_handle_unexpected_disconnect`.
- `app.gd _on_area_lost`: the reason is shown on the title screen (the same line "Can't reach
  the realm" uses), not written into a chip that `_show_title()` hides.
- The hold-to-move chain gate compares against the avatar's standing height (`_standing_y`),
  not `y = 0`, so it keeps chaining in water.

**Area server** (`game/world/area_server.gd`):
- `_reject_hello` and the newest-wins takeover send their reason and hang up through
  `departing_peers` (the 1.5 s deferral 012 introduced for `portal_result`), never in the same
  tick — so this client can read `TAKEN_OVER` / `BAD_GRANT`.
- Log game-peer connect, disconnect (with cause), kick and unbind — today none of these print.
- `_send_raw_to_peer` checks `put_packet`'s return; the first failure per peer is logged.

**Director** (`game/director/src/areaInstance.ts`, `server.ts`):
- `leave` reaching zero defers `shutdownInstance` by a 60 s grace that a re-join cancels (the
  proper, presence-based rule is 003). This defuses the heartbeat-shutdown hazard now.
- `resume_portal` checks `alreadyJoined` before `join()`, like the three join handlers.

## Definition of done

- `test/client-liveness.test.ts` extended, all with the real Godot client: (a) portal hop
  then a real drop → the client re-joins (the exact zombie); (b) a healthy connect whose
  first frame arrives 300 ms after OPEN is *not* killed; (c) `NOT_HELLO` → re-hello;
  (d) `TAKEN_OVER` reaches the client before the close.
- Director tests: deferred shutdown cancelled by a re-join; `resume_portal` twice = one
  refcount.
- `test_runner.gd`, the Godot-backed suites, the live verifiers green; director deployed; web
  build + **APK v12** on `/play`. Kris re-runs APK → browser → back and walks into a door on
  each.
- `deliverables/011-*.md` created as **promised** (delivered by 008).

## Where the work lands

`land-of-lor/game/world/client/`, `game/world/area_server.gd`, `game/director/src/`,
`game/director/test/`; workspace `deliverables/`, `site/`.

## What was done (2026-08-18, laptop session)

- Client: `expecting_game_close` cleared for every new peer (the fix); liveness clocks seeded
  on connect; `_send_game` → bool, logs a dropped frame, treats a send error as a drop;
  `move_intent_sent` only when sent; every `_on_game_disconnected` branch logs a reason;
  `NOT_HELLO` → re-hello, `BAD_GRANT` → re-join; `title_screen.show_notice()` shows
  `area_lost`'s reason; the water hold-to-move gate.
- Area server: `_hang_up_later()` defers the close after `TAKEN_OVER` / `BAD_GRANT`; peer
  connect / hello / disconnect / hang-up logged; `put_packet` checked (first failure logged).
- Director: `leave(…, {deferShutdown})` — a dropped socket gives an empty Area a 60 s grace
  (`SHUTDOWN_GRACE_MS`, cancelled by any join); explicit `leave_area` unchanged;
  `resume_portal` refcounts once per socket.
- Tests: `client-liveness.test.ts` is now a five-scenario harness with the real Godot client
  (silence, sticky-flag, late-frame, not-hello, taken-over) — **sticky-flag and not-hello
  fail on the pre-fix client** (verified by stashing the fix); late-frame passes on both, so
  the "unseeded watchdog" was a frame-ordering race rather than the deterministic trigger the
  plan claimed — the seed is still right. `area-server.test.ts`: TAKEN_OVER arrives ≥1 s
  before the close; dropped-socket grace + cancel-on-rejoin. `home-portal.test.ts`: two
  `resume_portal`s = one refcount. `test_runner.gd` 176/176.
- Shipped: director `7a07744`, web build on `/play`, **APK v12**; five live verifiers green.
- **For Kris:** the APK-then-browser-then-back check, walking into a door on each.

### connection-and-sync/002 — The client connection state machine — freeze rather than fake, reconnect visibly, give up honestly
```yaml
id: connection-and-sync/002
title: The client connection state machine — freeze rather than fake, reconnect visibly, give up honestly
epic: connection-and-sync
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/client/game_channel.gd
```

## What to do

- New `game/world/client/game_channel.gd` (RefCounted, table-driven, pure where possible):
  `IDLE → CONNECTING → HELLO_SENT → LIVE → STALLED → DEAD` (+ `LEAVING` for portal / exit).
  Inputs: socket state, a received frame, ack / reject / `SUPERSEDED`, a send failure, silence
  (6 s), oldest-unacked-intent age (2 s), portal events, explicit leave, app pause / resume.
  Owns `game_ws`, the timers, `pending_intents` (bounded, stamped with send time, discarded on
  reconnect — the server's position wins), the closing-peer bookkeeping. Emits
  `state_changed(from, to, reason)`.
- `client_main.gd` consumes it: `send_move` only in `LIVE`; in `STALLED` input is accepted but
  **not predicted** — the avatar stops — and a "Reconnecting…" overlay shows; `DEAD` → rejoin
  through the director with a **15 s** budget in a `multiplayer` Area, then a "Connection lost"
  screen (Retry / Main menu) and no play until `LIVE` again.
- New `client/director_channel.gd` — a view over `account_session.gd` (`ONLINE / OFFLINE /
  RECONNECTING`) that `client_main` *listens* to: a director drop while `LIVE` is fine; while
  `DEAD` it blocks the rejoin, and says so.
- Lifecycle: `NOTIFICATION_APPLICATION_PAUSED / RESUMED`, `FOCUS_OUT / IN`,
  `WM_GO_BACK_REQUEST` (Android back = confirm, `leave_area`, clean close). On resume both
  channels are probed: game — a frame within 2 s or `STALLED` → reconnect; director — a 5 s
  `whoami`.
- Configure `WebSocketPeer` buffers (`inbound_buffer_size`, `max_queued_packets`) before
  `connect_to_url`; build the ground mesh **outside** the packet-drain frame (deferred, chunked).

## Definition of done

`test_runner.gd` drives every transition of the table; vitest with the fake director/Area (from
001) covers: silence, unacked-only stall (pongs and updates flow, intents refused),
close-without-frame, portal hop then drop, director dead during rejoin, resume probes,
first-frame-late connects. The Godot-backed suites and the client harness still pass; 012's
rule holds (a drop during a pending portal defers to the portal fallback, never rejoins the
origin).

## Where the work lands

`land-of-lor/game/world/client/`, `game/director/test/`.

## What was done (2026-08-18, laptop session)

- `client/game_channel.gd`: the machine (IDLE / CONNECTING / HELLO_SENT / LIVE / STALLED /
  DEAD / LEAVING) with STALL_MS 2 s, SILENCE_DEAD_MS 6 s, UNACKED_DEAD_MS 6 s, GIVE_UP_MS
  15 s, PING_MS 3 s; `tick()` returns `ping` / `declare_dead` / `give_up`; 23 checks in
  `test_runner.gd` (199 total).
- `client_main.gd` obeys it: `_send_move` predicts only in LIVE (`move_held` logged once per
  stall); STALLED snaps the avatar to `confirmed_pos`; DEAD closes and re-joins **with
  retries** inside the budget (`rejoin_retry`), then `_give_up()` → `area_lost` with a
  sentence on the title; every transition logged `type=connection from=… to=… reason=…`;
  buffers 1 MiB / 8192 packets; ground mesh built off the drain frame; app resume probes
  the game channel (`on_resumed`); `connection_changed` signal → HUD notice
  (`hud.set_connection_notice`, "Reconnecting…").
- Android back (`quit_on_go_back=false` + `app._notification`) leaves cleanly and tells the
  director (`leave_area`); on the title it quits.
- Scenarios added to `client-liveness.test.ts`: **unacked** (frames flow, moves unanswered →
  STALLED, avatar held, DEAD, re-join) and **give-up** (dies, director refuses the re-join
  → retries, then `give_up` at ~15 s from the stall). Seven scenarios green; harness and
  Godot-backed suites green.
- Not done here (folded into 004): the director-channel view object — `client_main` still
  reads `_session.is_director_open()` directly; the wait is budgeted.
- Shipped with 003: director + world `cee0ccd`, web on `/play`, **APK v13**.

### connection-and-sync/003 — Server truth — every intent answered, every kick delivered, no Area shut down under a player
```yaml
id: connection-and-sync/003
title: Server truth — every intent answered, every kick delivered, no Area shut down under a player
epic: connection-and-sync
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/area_server.gd
```

## What to do

**Area server** (`game/world/area_server.gd`):
- Intents are processed **in order** within the movement budget, or the overwritten ones are
  rejected with `SUPERSEDED` — the client can reconcile every seq. (Today the per-tick
  Dictionary keeps only the last one and says nothing about the rest.)
- `_send_raw_to_peer` failure ⇒ log and drop the peer (its body stays).
- A peer left unbound (portal step 1, `_drop_other_entities_for_account`, takeover) is told —
  `error {REHELLO}` / `PORTAL_IN_PROGRESS` — instead of answering `NOT_HELLO` forever.
- Pre-hello peers receive nothing but `hello_ack` / errors (closes the unauthenticated feed).
- `pending_transfers` get a timeout: restore the entity at `from_cell` (010's restore path) and
  send `portal_blocked {TIMEOUT}`.
- The snapshot ack wedge (`pending_ack_seq`): retry on a timer; never block persistence
  forever on one seq.
- `area_closing {reason}` to every peer, then a grace, before **any** `quit()` (shutdown, retry
  exhaustion, control error) — and the control-error `quit(1)` guarded to the stale-spawnToken
  case only.
- `area_snapshot` (or a periodic `area_presence`) carries `connectedPeers`.

**Director** (`game/director/src/areaInstance.ts`, `server.ts`):
- `shutdownInstance` only when the world reports zero connected peers; the refcount becomes
  advisory (001's grace stays as belt-and-braces); portal arrival is covered by that rule.
- A `close` handler and a heartbeat on the control listener → `instance.registered = false`,
  `sendToArea` returns false and callers answer the client at once (`GRANT_FAILED` now, not
  after a 5 s stall).
- Every portal-transfer failure branch replies to the origin (`portal_transfer_rejected
  {reason}`), the exception catch included.
- Join-grant TTL 30 s → 90 s; the `completedTransfers` cache bounded and expiring.

## Definition of done

`area-server.test.ts`: three intents in one tick → three answers; `put_packet` failure drops
the peer and keeps the body; an unbound peer is told; `TAKEN_OVER` reaches a Godot client
before the close. Director tests: peer-count-based shutdown; portal-arrival presence;
`resume_portal` idempotence; control-close handling; every portal failure branch replies;
`area_closing` reaches peers. All existing suites and verifiers green.

## Where the work lands

`land-of-lor/game/world/area_server.gd`, `game/director/src/`, `game/director/test/`.

## What was done (2026-08-18, laptop session)

- `area_server.gd`: per-entity intent queue (one applied per tick, `TOO_FAST` past eight,
  `ENTITY_GONE`, `PORTAL_IN_PROGRESS` for moves queued behind a door or sent mid-transfer);
  `REHELLO` to a peer unbound by `_drop_other_entities_for_account`; broadcast only to
  bound peers; `_sweep_stale_transfers` (15 s → restore + `portal_blocked {TIMEOUT}`);
  snapshot resend after two intervals unacked; `area_closing` + 600 ms grace before any
  `quit()`, control-error quit guarded to `INVALID_REGISTRATION`; presence
  (`connectedPeers`, `boundAccounts`) on every hello / disconnect / 5 s and on snapshots.
- Director: `AreaPresence` schemas; `applyPresence`; `leave` keeps an Area up while the world
  reports someone else bound and shuts it down on the first empty report; drop-grace
  cancelled by presence; `controlSocketClosed`; `sendToArea` → bool, grant registration
  fails fast; every transfer failure replies `INTERNAL`; grants 90 s; the completed-transfer
  cache bounded (500) and expiring (1 h).
- Tests: three intents in one tick → three answers + `TOO_FAST`; presence keeps an Area up
  after the director socket drops and shuts it down when the game socket goes;
  `area_closing` observed before a graceful exit; all suites green.
- Not done: a test for the `REHELLO` unbind path (needs a duplicate-body arrival);
  `put_packet` failure → drop peer (kept at log-first — dropping on a transient full buffer
  seemed worse than a logged one; revisit in 007's soak).

### connection-and-sync/005 — Area connectivity — single_player, multiplayer, either
```yaml
id: connection-and-sync/005
title: Area connectivity — single_player, multiplayer, either
epic: connection-and-sync
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-18
updated: 2026-08-19
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/handoff/connection-and-sync/005-area-connectivity.md
```

## What to do

- `schema.sql`: `ALTER TABLE areas ADD COLUMN IF NOT EXISTS connectivity TEXT NOT NULL DEFAULT
  'multiplayer';` homes set to `either` at creation and by a `[migrate]` backfill.
- `worldContent.ts`: `AreaContentSchema.connectivity` (enum, default `multiplayer`); the
  validator insists the hub is `multiplayer` and homes are `either`. `spellgrove.world.json`
  unchanged (defaults apply).
- World editor: a segmented control in the Area inspector; a visual on the ball.
- `publicMap.ts` exposes it (it isn't a secret); `hello_ack.area.connectivity`; the in-game
  world map screen marks Areas that need a connection.
- `portal_blocked` gains reason `OFFLINE` — "That way needs a connection" — used by 006.

## Definition of done

Schema, loader and public-map round trips in tests; validator refusals; the editor writes and
reads the field; `verify-world-map` still green.

## Where the work lands

`land-of-lor/game/director/`, `tools/world-editor/`, `game/world/`.

### connection-and-sync/006 — Local play — your home when there is no realm
```yaml
id: connection-and-sync/006
title: Local play — your home when there is no realm
epic: connection-and-sync
state: ready
priority: 1
blocked_by: ["connection-and-sync/005"]
estimate: L
created: 2026-08-18
updated: 2026-08-19
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What to do

- `game/world/local_authority.gd`: the simulation half of `area_server.gd` — grid, ground,
  portals, entities, `_apply_intents`, movement budget, `_area_payload`, `_serialize_state` —
  driven in-process; the one outbound seam `_send_to_peer` becomes `deliver(type, payload)`;
  `area_server.gd` keeps the transport and builds on it.
- Client: `_send_game` / `_poll_game` behind a `GameTransport` (the WebSocket implementation,
  and a `LocalTransport` that calls the authority synchronously); `_handle_game_message`
  unchanged; `_ready()`'s "no session = fail" gate gains an explicit `LOCAL` mode.
- Behaviour: at Start with the director OFFLINE (or "Play offline"), the home (`either`) loads
  locally from `user://areas/home.json` (written from the last online `hello_ack`; else the
  default 16×16 grass home). Walking works; doors to `multiplayer` Areas bump `OFFLINE`; an
  "Offline — your home only" banner. When the realm returns, "Go online" re-enters the
  *server's* home; the local walk-only state is discarded (decision, 2026-08-18).
  `single_player` Areas use the same machinery once one is authored.

## Definition of done

The local authority passes the server's movement / portal / budget checks in `test_runner.gd`;
a headless client run with `--offline` reaches a home with no director and its door bumps; the
web and Android builds start offline in airplane mode.

## Where the work lands

`land-of-lor/game/world/`.

### connection-and-sync/008 — Ship, verify, record
```yaml
id: connection-and-sync/008
title: Ship, verify, record
epic: connection-and-sync
state: backlog
priority: 1
blocked_by: ["connection-and-sync/003","connection-and-sync/004","connection-and-sync/006","connection-and-sync/007"]
estimate: S
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: [11]
review_artifact: null
```

## What to do

Deploy the director; web build + APK v13 (and start bumping the Android `version/code`
monotonically); `Dev/CLAUDE.md` entry; `docs/handoff/connection-and-sync.md` (the diagnosis,
what changed, how to read a client report); README known-limits ("the game never pretends to
be online"); annotate `world-map-and-portals/016` (its liveness fix stands; 001 fixed what it
missed and what it worsened); flip `deliverables/011-*.md` to delivered ("Honest connections —
and playing your home offline"); regenerate `status.md`.

## Definition of done

All verifiers green after deploy; `/play` serves the new build; the APK is up and linked;
deliverable 11 delivered; both repos clean and pushed; the site rebuild confirmed from outside.

## Where the work lands

`land-of-lor/` and this workspace.

### connection-and-sync/004 — Observability — the connection indicator, and the phone that reports its own diagnostics
```yaml
id: connection-and-sync/004
title: Observability — the connection indicator, and the phone that reports its own diagnostics
epic: connection-and-sync
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-18
updated: 2026-08-19
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/handoff/connection-and-sync/004-observability.md
```

## What to do

- HUD: a small connection indicator (green LIVE / amber STALLED with seconds / red DEAD). Tap →
  a panel: both channels' states, last-frame age, unacked count and age, RTT, Area, build id,
  and a **Send diagnostics** button.
- `client_report` (authed, director channel): the last ~200 lines of a client-side ring buffer
  (`CLIENT_EVENT`s, state transitions, `OS.get_name()`, build commit) — sent on tap and
  **automatically** on `DEAD` and on give-up. The director logs it as
  `[client-report account=… platform=… build=…]` + lines and keeps the last N per account for
  `npm run client-reports`. This is what replaces plugging the phone into adb.
- Server side: a periodic per-peer counters line (frames sent / failed, last rx age).

## Definition of done

The fake director in the harness receives a report after an induced DEAD; the indicator shows
the right colour in each state in `_shots.gd`; `npm run client-reports` prints the last report.

## Where the work lands

`land-of-lor/game/world/client/`, `game/director/src/`, `game/director/cli-test-client/`.

### connection-and-sync/007 — The fault-injection harness and the soak
```yaml
id: connection-and-sync/007
title: The fault-injection harness and the soak
epic: connection-and-sync
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-18
updated: 2026-08-19
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/handoff/connection-and-sync/007-fault-harness-and-soak.md
```

## What to do

- `test/helpers/faultProxy.ts`: a TCP/WebSocket proxy between the real Godot client and the
  real director / world in tests, with knobs — drop client→server, drop server→client,
  blackhole both (half-open), delay, RST without a close frame — so 001–003's scenarios also
  run against the real server, not only the fake Area.
- `npm run soak` (opt-in, minutes): N headless clients walking, portal-hopping, being cut and
  restored by the proxy; asserts no client ends STALLED / DEAD without a report, positions
  converge, at most one body per account, presence settles to zero, no zombie peers, no Area
  shut down under a live peer.
- Cover the review's uncovered list: half-open self-takeover; kicks vs the Godot client;
  same-tick coalescing; the `NOT_HELLO` steady state; refcount leaks; director socket dying
  while playing; world exit with peers attached; control drop / snapshot wedge; portal limbo.
- `game/README.md`: the phone checklist (indicator, report, the exact APK-then-browser repro).

## Definition of done

The proxy-backed tests and the soak are green in `npm test` / `npm run soak`; the checklist is
in the README.

## Where the work lands

`land-of-lor/game/director/test/`, `game/README.md`.

## questions/ (0)

## deliverables (1)

- #11 — Honest connections — the game never pretends to be online, and your home works without the realm (2026-08-18) — /deliverables/11