# Sprint 20: Shape — Client Tasks **Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements. **Branch:** `client` **Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA) ## Carry-over from Sprint 19 None — Sprint 19 complete. #554 (save/load client UI) is finishing in Sprint 19. ## New Tickets | # | Title | Blocked by | |---|-------|------------| | #557 | Refactor: game_state.gd derived state in apply_snapshot() | — | | #558 | Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling | — | | #559 | Refactor: main.gd god coordinator — extract SnapshotEventRouter | — | | #560 | Refactor: unify duplicate YAML parsers | — | Use `db/connectors/ticket show ` for full details. ## Key Decisions - `decisions/architecture.md` — D-020 (Godot is a pure renderer: no game logic in GDScript; GameState reflects server-authoritative data, not derived behavior), D-085 (per-game save directory structure: `user://saves/-/`, F5=quicksave, F6=quickload, loading screen lists dirs by last-modified), D-088 (3-state pause system: client sends pause requests, server is authoritative) - `decisions/scope.md` — D-027 (vertical slice success criteria: game session must be resumable for 30-min playthroughs) ## Notes ### #557 — Refactor: game_state.gd derived state in apply_snapshot() Code review finding: `apply_snapshot()` in `client/scripts/autoloads/game_state.gd` computes two derived values inline: - `stationary_ticks` (increments when player position hasn't changed) — line 99 / ~line 131-133 - `current_zone_id` (derived from tile iteration) — line 105 / ~line 286 Per D-020, the Godot client is a pure renderer. Behavior-driving computations (stationary tick counting, zone identification) belong in the server, not in `apply_snapshot()`. The server already sends `zone_id` per tile — the client should read it directly rather than re-deriving it. What this ticket must deliver: - Move `stationary_ticks` accumulation out of `apply_snapshot()`. The server sends `stationary_ticks` (or equivalent) in the snapshot — if not yet present, add the field to `ObserverSnapshot` in `client/scripts/protocol/protocol.gd` and mark with a TODO for the server team to populate it. Client reads the server value directly. - Move `current_zone_id` resolution to a simple property read from the snapshot (`player_tile.zone_id`), removing the tile iteration loop from `apply_snapshot()`. - After: `apply_snapshot()` contains only direct field assignments from the snapshot dictionary — no conditional logic, no accumulation. - Add a comment citing D-020 on each removed computation to document the rationale. - Unit tests: `apply_snapshot()` with a snapshot missing the new fields should degrade gracefully (default values, no crash). Integration points: `client/scripts/autoloads/game_state.gd` only. Protocol fields may need a minor extension in `client/scripts/protocol/protocol.gd` — coordinate with server team if new snapshot fields are required. Gotcha: `stationary_ticks` drives `ListeningFocus` (D-071) — confirm the server already tracks and sends this value before removing client-side accumulation. If the server does not yet send it, add a feature-flagged fallback that keeps the old behavior with a deprecation comment. ### #558 — Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling Code review finding: `client/ui/dialogue_box.gd` directly mutates `GameState.dialogue_active` at 3 call sites (lines ~289, ~321, ~334) and calls `AudioManager.apply_dip()` / `AudioManager.clear_dip()` directly. Per D-020, UI components should not mutate shared state or call sibling autoloads directly — they should emit signals and let a coordinator (main.gd or a future SnapshotEventRouter) manage cross-component state. What this ticket must deliver: - Replace the 3 `GameState.dialogue_active = true/false` assignments with a signal: `signal dialogue_state_changed(active: bool)`. `main.gd` connects to this signal and updates `GameState.dialogue_active`. - Replace `AudioManager.apply_dip("dialogue")` and `AudioManager.apply_dip("confrontation")` / `AudioManager.clear_dip()` calls with signals: `signal audio_dip_requested(profile: String)` and `signal audio_dip_cleared()`. `main.gd` connects to these and calls `AudioManager`. - Result: `dialogue_box.gd` has zero references to `GameState` or `AudioManager`. - Unit tests: mock signal receivers capture the emitted signals with correct arguments; no direct autoload calls remain. Integration points: `client/ui/dialogue_box.gd` (source), `client/scripts/main.gd` (connects to new signals in `_ready()`). No server changes. Gotcha: `InputMapper` checks `GameState.dialogue_active` to suppress movement. The signal path adds one frame of latency — verify that the signal fires synchronously within the same frame (use `call_immediate` or connect with `CONNECT_DEFERRED` depending on timing requirements). The `is_dialogue_active()` method on `dialogue_box.gd` (line 337) can remain as a local query without touching `GameState`. ### #559 — Refactor: main.gd god coordinator — extract SnapshotEventRouter Code review finding: `client/scripts/main.gd` is 517 lines and dispatches to 15+ child nodes through a set of `consume_*` methods that all follow the same pattern: read field from snapshot, call method on child node. What this ticket must deliver: - Extract a `SnapshotEventRouter` class (`client/scripts/snapshot_event_router.gd`): takes the snapshot dictionary and routes each field to the correct child node via a registered handler map. - Registration pattern: `router.register("monologue", monologue_display.consume_monologue)` — callable-based dispatch. Handlers are registered in `main.gd`'s `_ready()`. - `main.gd` `_process()` calls `router.dispatch(snapshot)` instead of 15+ individual `if snapshot.has("X"): child.consume_X()` blocks. - `main.gd` retains scene tree ownership (`@onready` node references), camera logic, and input handling — the router only handles snapshot dispatch. - After: `main.gd` should be under 350 lines. - Unit tests: construct a `SnapshotEventRouter` with mock handlers, dispatch a snapshot, assert each handler received the correct field value. Integration points: `client/scripts/main.gd` (refactor target), new file `client/scripts/snapshot_event_router.gd`. No server changes, no protocol changes. Gotcha: Some consume methods in `main.gd` have cross-field dependencies (e.g., camera position depends on both `player_position` and `_camera_anchored` state). Identify these upfront and keep them in `main.gd` directly — only pure per-field dispatch moves to the router. Do not force all logic into the router pattern. ### #560 — Refactor: unify duplicate YAML parsers Code review finding: `client/scripts/checklist/checklist_evaluator.gd` contains its own YAML parser that partially duplicates `client/scripts/autoloads/ui_strings.gd`'s `_parse_yaml()` method. What this ticket must deliver: - Extract a shared `YamlParser` utility class at `client/scripts/util/yaml_parser.gd` (create the `util/` directory). - `YamlParser` exposes a static method `parse(text: String) -> Dictionary` that handles the common subset of YAML used across both call sites (key: value pairs, nested maps, arrays). - Replace `checklist_evaluator.gd`'s inline parser with `YamlParser.parse()`. - Replace `ui_strings.gd`'s `_parse_yaml()` with `YamlParser.parse()` (or delegate to it, keeping the method signature stable). - Unit tests: parse a sample YAML string with nested keys, arrays, and string values; assert round-trip correctness. Integration points: `client/scripts/checklist/checklist_evaluator.gd`, `client/scripts/autoloads/ui_strings.gd`, new `client/scripts/util/yaml_parser.gd`. No server changes. Gotcha: The two existing parsers may handle edge cases differently. Write the unit tests first against both parsers to document their current behavior, then unify. Prioritize correctness for existing content files (`client/data/ui-strings.yaml` and any checklist YAML files) — do not break live content. ## Dependency Chain ``` #557 (game_state derived state) ─┐ #558 (dialogue_box coupling) ├─ all parallel, no inter-dependency #559 (main.gd SnapshotEventRouter)│ #558 feeds into #559 (signal wiring in main.gd) #560 (unify YAML parsers) ─┘ ``` #558 should complete before #559 so that the new signals from dialogue_box are wired into `main.gd` as part of the router work, not as a separate pass. Otherwise all four tickets run in parallel. ## PR Workflow When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts: ```bash tea pr create --repo jpmschweitzer/settled-reach --login schweitz \ --title "feat(client): save/load UI and code quality refactors" \ --description "body" --base main --head client ```