diff --git a/docs/plans/NEO-142-implementation-plan.md b/docs/plans/NEO-142-implementation-plan.md new file mode 100644 index 0000000..f490a10 --- /dev/null +++ b/docs/plans/NEO-142-implementation-plan.md @@ -0,0 +1,232 @@ +# NEO-142 — E7M3-10: Client faction standing + gate feedback HUD (Godot) + +**Linear:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) +**Branch:** `NEO-142-client-faction-standing-gate-hud` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-10** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-122](NEO-122-implementation-plan.md) — quest HUD + accept feedback; [NEO-131](NEO-131-implementation-plan.md) — completion reward label + transition refresh; [NEO-73](NEO-73-implementation-plan.md) / [NEO-139](NEO-139-implementation-plan.md) — snapshot GET client +**Precursors:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) **`Done`** — `GET /game/players/{id}/faction-standing`; [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) **`Done`** — world **`factionGateRules`** + progress **`completionRewardSummary.reputationGrants`** +**Blocks:** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) — Slice 3 Godot capstone manual QA +**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) + [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) — authoritative GET projections; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) + +## Goal + +Player sees faction standing and accept gate denials in Godot without Bruno. + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|-------|----------|----------------------|--------| +| **Standing HUD placement** | HudRoot quest stack vs EconomyHudSection? | **HudRoot quest stack** — new **`FactionStandingLabel`** after **`QuestRewardDeliveryLabel`**; keeps quest/rep loop visible with NEO-122/131 labels; economy panel stays inventory/skills/gig. | **Adopted** — HudRoot quest stack | +| **Faction display names** | How to label standing/rep lines without `GET /game/world/faction-definitions`? | **Static prototype map** in client matching `content/factions/prototype_factions.json` freeze; fallback title-case **`id`** token. | **Adopted** (no kickoff question — NEO-139 server README: “join from client content”) | +| **`faction_gate_blocked` copy** | Raw `reasonCode` vs readable gate detail? | **Readable gate detail** — join cached world quest **`factionGateRules`** for denied quest + current standing from faction snapshot (e.g. “Grid Operators standing 15 required (have 0)”); other reason codes keep NEO-122 `denied — {reasonCode}`. | **Adopted** (meets Linear AC; NEO-140 gate projection landed) | +| **Standing refresh** | When to GET faction-standing? | **Boot hydrate** + on **`quest_completion_reward_transition`** (rep grants apply on completion, not accept); coalesce via client pending-sync like skill progression. | **Adopted** (backlog: reuse quest HUD completion signals) | +| **Reward label rep lines** | Where to surface **`reputationGrants`**? | **Extend `_format_reward_summary_lines`** in **`quest_hud_controller.gd`** — same transition-only reward label as NEO-131 item/skill lines. | **Adopted** (backlog explicit) | + +## Scope and out-of-scope + +**In scope (from Linear + [E7M3-10](E7M3-pre-production-backlog.md#e7m3-10--client-faction-standing--gate-feedback-hud-godot)):** + +- **`faction_standing_client.gd`** — GET **`/game/players/{id}/faction-standing`**; parse v1; **`faction_row(faction_id, snapshot)`**; signals **`faction_standing_received`** / **`faction_standing_sync_failed`**; **`request_sync_from_server()`** with pending coalesce. +- **`FactionStandingLabel`** in **`HudRoot`** after **`QuestRewardDeliveryLabel`** — both prototype factions with readable names + standing values. +- Parse + render refresh after quest completion transitions (wire through **`quest_hud_controller.gd`**). +- Surface **`faction_gate_blocked`** on **`QuestAcceptFeedbackLabel`** with readable gate copy (not raw code alone). +- Extend **`quest_hud_controller.gd`** reward formatter for **`reputationGrants`** lines in **`completionRewardSummary`**. +- Optional helper on **`quest_definitions_client.gd`**: **`faction_gate_rules_for(quest_id)`** reading cached world rows (NEO-140 field passes through existing parse). +- GdUnit parse + HUD tests ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)). +- **`docs/manual-qa/NEO-142.md`** — component checklist. +- **`client/README.md`** faction standing + gate feedback subsection. + +**Out of scope (from Linear + backlog):** + +- Final journal UI; faction art pass. +- **`GET /game/world/faction-definitions`** client (no backlog item). +- Server route/DTO changes. +- Full Slice 3 capstone script (**[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)**). +- Periodic faction-standing poll timer. + +**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140). + +## Acceptance criteria checklist + +- [ ] Standing visible after operator-chain completion (+15 Grid Operators). +- [ ] Accepting grid contract below rep shows readable deny copy on HUD. + +## Technical approach + +### 1. Server contracts (landed — NEO-139 / NEO-140) + +**Faction standing GET** (`schemaVersion` 1): + +```json +{ + "schemaVersion": 1, + "playerId": "dev-local-1", + "factions": [ + { "id": "prototype_faction_grid_operators", "standing": 15 }, + { "id": "prototype_faction_rust_collective", "standing": 0 } + ] +} +``` + +Consumers key rows by **`id`** — array order is not contractual. + +**World quest definition** (grid contract gate row): + +```json +{ + "id": "prototype_quest_grid_contract", + "factionGateRules": [ + { "factionId": "prototype_faction_grid_operators", "minStanding": 15 } + ] +} +``` + +**Completion reward summary** (operator chain rep line): + +```json +{ + "reputationGrants": [ + { "factionId": "prototype_faction_grid_operators", "amount": 15 } + ] +} +``` + +### 2. `faction_standing_client.gd` + +Mirror **`skill_progression_client.gd`**: + +| Concern | Implementation | +|---------|----------------| +| URL | `{base}/game/players/{dev_player_id}/faction-standing` | +| Parse | Require `schemaVersion == 1`, non-empty `playerId`, `factions` array of `{ id, standing }` | +| Helpers | **`faction_row(faction_id, snapshot)`**; **`standing_for(faction_id, snapshot) -> int`** default **0** when row missing | +| Display names | **`display_name_for(faction_id)`** — static map for two prototype ids; title-case fallback | +| Errors | 404 → `"HTTP 404 (player unknown)"`; schema mismatch → `"non-JSON body or schemaVersion mismatch"`; emit **`faction_standing_sync_failed`** | + +Static display-name map (matches [prototype_factions.json](../../content/factions/prototype_factions.json)): + +| `id` | Label | +|------|-------| +| `prototype_faction_grid_operators` | Grid Operators | +| `prototype_faction_rust_collective` | Rust Collective | + +Render order: Grid Operators first, Rust Collective second (catalog freeze order). + +### 3. HUD wiring (`main.gd` / `main.tscn`) + +- Add **`FactionStandingClient`** node sibling to **`QuestProgressClient`**. +- Add **`FactionStandingLabel`** after **`QuestRewardDeliveryLabel`** in **`HudRoot`** (user-adopted placement). +- Boot: apply authority HTTP config; connect client signals; **`request_sync_from_server()`** on setup. +- Extend **`QuestHudController.setup(...)`** with **`faction_standing_client`** + **`faction_standing_label`** (optional params to avoid breaking test harnesses — pass `null` when absent). + +### 4. Standing label render (`quest_hud_controller.gd`) + +Default idle: + +``` +Faction standing: + Grid Operators: 0 + Rust Collective: 0 +``` + +On sync error, append **`error — {reason}`** under header (NEO-122 pattern). On successful snapshot, paint both factions from GET (not local math). + +**Refresh triggers:** + +1. **`setup`** → faction client boot GET (via controller or `main` — prefer controller calling **`request_sync_from_server()`** after connect). +2. **`quest_completion_reward_transition`** → faction GET (standing changes on quest completion rep delivery). +3. Do **not** refresh on accept deny alone (standing unchanged on failed accept). + +### 5. Accept gate feedback (`faction_gate_blocked`) + +In **`_on_accept_result_received`**, when **`accepted == false`** and **`reasonCode == "faction_gate_blocked"`**: + +1. Read **`faction_gate_rules_for(quest_id)`** from defs client (first rule sufficient for prototype grid contract). +2. Read current standing from last faction snapshot via **`standing_for(factionId, snapshot)`**. +3. Render e.g. **`Quest accept: denied — Grid Operators standing 15 required (have 0)`**. +4. If gate rules or standing snapshot unavailable, fallback **`Quest accept: denied — faction standing too low (faction_gate_blocked)`**. + +Other deny codes unchanged: **`Quest accept: denied — {reasonCode}`**. + +### 6. Reward label — `reputationGrants` + +Extend **`_format_reward_summary_lines`** after skill XP block: + +```gdscript +# reputationGrants: [{ factionId, amount }] +lines.append(" Grid Operators +15 rep") +``` + +Use **`display_name_for(factionId)`** from faction standing client when wired; else title-case **`factionId`**. + +Update **`quest_reward_hud_test.gd`** (or new suite) for operator-chain transition including rep line. + +### 7. Flow + +```mermaid +sequenceDiagram + participant Main + participant QuestHud as QuestHudController + participant FactionClient as FactionStandingClient + participant QuestClient as QuestProgressClient + participant Server + + Main->>QuestHud: setup (quest + faction clients) + QuestHud->>FactionClient: request_sync_from_server (boot) + FactionClient->>Server: GET faction-standing + Server-->>FactionClient: factions[] + FactionClient-->>QuestHud: faction_standing_received + QuestHud->>QuestHud: render FactionStandingLabel + + QuestClient->>Server: GET quest-progress (completion) + QuestClient-->>QuestHud: quest_progress_received + QuestHud->>QuestHud: detect completed transition + QuestHud->>FactionClient: request_sync_from_server + QuestHud->>QuestHud: render reward (+ reputationGrants) + + QuestClient->>Server: POST accept grid contract (deny) + QuestClient-->>QuestHud: reasonCode faction_gate_blocked + QuestHud->>QuestHud: readable gate deny on accept label +``` + +## Files to add + +| Path | Rationale | +|------|-----------| +| `client/scripts/faction_standing_client.gd` | HTTP GET client for NEO-139 faction-standing snapshot | +| `client/scripts/faction_standing_client.gd.uid` | Godot uid companion (tracked with script) | +| `client/test/faction_standing_client_test.gd` | GdUnit parse, 404, `faction_row` / `display_name_for`, pending sync | +| `client/test/faction_standing_hud_test.gd` | GdUnit standing label render, gate deny copy, completion refresh hook | +| `docs/manual-qa/NEO-142.md` | Godot component checklist (standing +15, grid-contract gate deny) | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scripts/quest_hud_controller.gd` | Wire faction client/label; standing render; gate deny copy; `reputationGrants` reward lines; refresh faction on completion transition | +| `client/scripts/quest_definitions_client.gd` | Add **`faction_gate_rules_for(quest_id)`** helper over cached world rows | +| `client/scripts/main.gd` | `@onready` faction client + label; pass into quest HUD **`setup`**; boot HTTP config | +| `client/scenes/main.tscn` | **`FactionStandingClient`** node + **`FactionStandingLabel`** after reward label | +| `client/test/quest_reward_hud_test.gd` | Assert **`reputationGrants`** line on operator-chain completion transition | +| `client/README.md` | Document faction standing GET, label placement, gate feedback, refresh triggers | + +## Tests + +| File | Coverage | +|------|----------| +| `client/test/faction_standing_client_test.gd` | **Add:** parse valid snapshot; reject bad schema; 404 sync failed; `faction_row` / `standing_for`; prototype display names; pending sync coalesce | +| `client/test/faction_standing_hud_test.gd` | **Add:** standing label after snapshot (+15 Grid Operators); sync error line; `faction_gate_blocked` readable deny using mock defs + standing; fallback when rules missing | +| `client/test/quest_reward_hud_test.gd` | **Change:** operator-chain completion summary includes **`Grid Operators +15 rep`** line | + +Manual verification: **`docs/manual-qa/NEO-142.md`** — server + Godot; complete operator chain → standing **15**; accept grid contract before rep → readable deny. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Standing label shows **0** before any rep (not hidden until +15) | **Show both factions from boot** — AC scenario is “visible after completion” with **15**, not hidden-until-earned | `adopted` | +| Gate deny before operator-chain prerequisite (`prerequisite_incomplete`) | **Keep NEO-122 raw reasonCode** — AC targets **`faction_gate_blocked`** after chain complete | `adopted` | +| Faction GET lags quest completion refresh | **Accept brief stale standing** on same frame; completion handler fires faction GET immediately after transition | `adopted` | +| `main.tscn` ext_resource churn | Add script ext_resource for faction client; match existing client node pattern | `adopted` | +| NEO-143 capstone overlap | NEO-142 = component HUD; NEO-143 = full end-to-end manual QA script | `adopted` |