NEO-142: add E7M3-10 client faction HUD implementation plan

Kickoff plan for Godot faction standing label, gate deny feedback, and
reputationGrants reward lines after NEO-139/140 server APIs landed.
pull/182/head
VinPropane 2026-06-17 21:08:56 -04:00
parent b4d48f6e36
commit fb9a8ab8b2
1 changed files with 232 additions and 0 deletions

View File

@ -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` |