neon-sprawl/docs/plans/NEO-153-implementation-plan.md

226 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# NEO-153 — E7M4-10: Client contract issue + progress HUD (Godot)
**Linear:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153)
**Branch:** `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-10**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-122](NEO-122-implementation-plan.md) (`quest_progress_client.gd` + `quest_hud_controller.gd`); [NEO-110](NEO-110-implementation-plan.md) (encounter-complete refresh spine); [NEO-131](NEO-131-implementation-plan.md) (completion reward transition label)
**Precursors:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) **`Done`** — `POST …/contracts/issue` + `GET …/contracts` (**on `main`**); [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) **`Done`** — contract telemetry hook sites (**on `main`**)
**Blocks:** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) (Godot repeatable contract capstone)
**Server counterpart:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) — authoritative issue/list HTTP; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md)
## Goal
Player can **issue** and **track** an active contract in Godot without Bruno — **`Shift+C`** issues the frozen prototype template; HUD shows **`encounterTemplateId`** while active and a **reward summary line** when the contract completes in-session.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|---------------------|--------|
| **HUD wiring** | Separate controller + labels vs extend quest HUD? | **Separate `contract_hud_controller.gd`** + **`ContractActiveLabel`**, **`ContractIssueFeedbackLabel`**, **`ContractRewardDeliveryLabel`** — keeps quest HUD bounded; matches [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md) + NEO-122 extraction | **Adopted** — separate controller |
| **Shift+C seed bucket** | Fixed vs date vs session counter? | **Fixed `prototype_contract_dev_seed`** — NEO-153 single-loop AC; repeat re-issue seed rotation deferred to **NEO-154** | **Adopted** — fixed dev seed |
**Additional defaults (no kickoff question — settled by backlog / precedent):**
- **Issue key:** **Shift+C** (backlog E7M4-10); idle hint on **`ContractIssueFeedbackLabel`**.
- **Prototype POST fields:** **`templateId`** = **`prototype_contract_clear_combat_pocket`**; **`seedBucket`** = **`prototype_contract_dev_seed`**; **`schemaVersion`** **1**; body **`playerId`** matches path **`dev_player_id`**.
- **Refresh strategy:** boot hydrate + after successful issue POST + after encounter-progress GET when prototype encounter row reaches **`completed`** (extend **`_on_encounter_progress_received`** contract refresh — same spine as quest refresh today).
- **Completion reward label:** in-session **active → completed** transition only (NEO-131 idempotency — boot with prior completed row does not replay grant copy).
- **Deny copy:** readable HUD strings for backlog-listed codes plus stable generator codes surfaced on issue deny.
- **Server changes:** none — client-only story.
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`contract_client.gd`:** GET **`/game/players/{id}/contracts`** parse v1; POST **`/game/players/{id}/contracts/issue`**; helpers **`active_contract_row(snapshot)`**, **`completion_reward_summary(contract_instance_id, snapshot)`**; signals **`contracts_received`** / **`contracts_sync_failed`** / **`contract_issue_result_received`** / **`contract_issue_failed`**; separate sync/issue HTTP busy guards (quest/craft precedent).
- **`contract_hud_controller.gd`:** HUD render, **Shift+C** issue binding, deny copy, active-row display with **`encounterTemplateId`**, reward transition detection + summary lines (duplicate minimal grant formatters from quest HUD — item defs + faction standing clients injected).
- **`main.tscn`:** **`ContractClient`** node; three HUD labels in **`HudRootScroll/HudRoot`** after **`QuestRewardDeliveryLabel`**, before **`FactionStandingLabel`**.
- **`main.gd`:** thin setup — **`ContractHudController`**, authority HTTP config, encounter-complete contract refresh hook, **`_try_contract_issue_key_input`** in gameplay key router.
- GdUnit parse + controller tests with HTTP doubles.
- **`docs/manual-qa/NEO-153.md`** — Godot issue → clear pocket → reward line (no Bruno).
- **`client/README.md`** contract HUD subsection.
**Out of scope (from Linear + backlog):**
- Final contract board UI; zone picker (**E4.M1**).
- Repeat re-issue seed rotation (**NEO-154** capstone).
- Server route/DTO/Bruno changes.
- Telemetry ingest (**NEO-152** landed comment-only).
**Client counterpart:** this story **is** the E7M4-10 client slice; pairs **NEO-151** server HTTP.
## Acceptance criteria checklist
- [x] Active contract visible with encounter objective id after issue.
- [x] Completed contract shows reward summary line on HUD.
## Implementation reconciliation (shipped)
- **`contract_client.gd`:** GET/POST parse v1; **`active_contract_row`**, **`completion_reward_summary`**; separate sync/issue HTTP guards.
- **`contract_hud_controller.gd`:** **Shift+C** prototype issue; active/completed label with **`encounterTemplateId`**; deny copy; in-session reward transition + economy refresh; issue patch cleared when GET row exists (NEO-122 accept-patch precedent); faction-standing re-paint on reward label.
- **`main.tscn` / `main.gd`:** **`ContractClient`** node + three HUD labels; encounter-complete contract refresh hook.
- **Tests:** `contract_client_test.gd` (7 cases), `contract_hud_controller_test.gd` (6 cases) — GdUnit green.
- **Docs:** `client/README.md` contract HUD section; `docs/manual-qa/NEO-153.md`.
## Technical approach
### Server contract (landed — NEO-151)
- **`POST /game/players/{id}/contracts/issue`** → **`schemaVersion` 1**, **`issued`**, optional **`reasonCode`**, optional **`contract`** row. HTTP **200** on structured deny (quest accept precedent).
- **`GET /game/players/{id}/contracts`** → **`schemaVersion` 1**, **`playerId`**, **`contracts[]`**: active row (01) + up to 10 completed. Row fields: **`contractInstanceId`**, **`templateId`**, **`templateDisplayName`**, **`status`** (`active` \| `completed`), **`encounterTemplateId`**, **`seedBucket`**, **`issuedAt`**, optional **`completedAt`**, optional **`completionRewardSummary`** (same nested grant shape as quest NEO-129).
- Prototype template (**E7M4-01 freeze**): **`prototype_contract_clear_combat_pocket`** → **`encounterTemplateId`** **`prototype_combat_pocket`**; completion bundle **`scrap_metal_bulk` ×5**, **`salvage` +15 XP**.
### Client constants (`contract_hud_controller.gd` or shared const block)
```gdscript
const PROTOTYPE_CONTRACT_TEMPLATE_ID := "prototype_contract_clear_combat_pocket"
const PROTOTYPE_CONTRACT_SEED_BUCKET := "prototype_contract_dev_seed"
const ISSUE_IDLE_HINT := "Contract issue: — (Shift+C prototype)"
```
### 1. `contract_client.gd`
Mirror **`quest_progress_client.gd`** structure:
| Method / signal | Role |
|-----------------|------|
| **`request_sync_from_server()`** | GET contracts list |
| **`request_issue(template_id, seed_bucket)`** | POST issue with v1 body |
| **`parse_contracts_json(text)`** | Validate **`schemaVersion` 1**, **`playerId`**, **`contracts`** array |
| **`parse_contract_issue_json(text)`** | Validate **`issued`** bool present |
| **`active_contract_row(snapshot)`** | First row with **`status == "active"`** |
| **`completion_reward_summary(instance_id, snapshot)`** | Read **`completionRewardSummary`** from matching completed row |
| **`contracts_received`** / **`contracts_sync_failed`** | GET outcomes |
| **`contract_issue_result_received`** / **`contract_issue_failed`** | POST outcomes |
POST body:
```json
{
"schemaVersion": 1,
"playerId": "<dev_player_id>",
"templateId": "prototype_contract_clear_combat_pocket",
"seedBucket": "prototype_contract_dev_seed"
}
```
On issue deny (**`issued: false`**), emit result with **`reasonCode`**; **`push_warning`** with code (quest accept pattern).
### 2. `contract_hud_controller.gd`
Extracted controller (NEO-122 precedent):
- **`setup(contract_client, active_label, issue_label, reward_label, apply_http_config, item_defs_client, faction_standing_client)`**
- Boot: apply HTTP config → connect signals → **`request_sync_from_server()`**
- **`try_issue_key_input(event)`** — **Shift+C** only; calls **`request_issue(PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET)`**
- **`request_contract_refresh()`** — delegate GET sync
- **`ContractActiveLabel`** render:
- sync error → **`Contract: error — {reason}`**
- no active/completed rows → **`Contract: —`**
- **`active`** → **`{templateDisplayName}: active — objective {encounterTemplateId}`**
- latest in-session completed (status tracking) → **`{templateDisplayName}: completed — objective {encounterTemplateId}`**
- **`ContractIssueFeedbackLabel`** — idle hint / sending / issued success / deny copy map:
| **`reasonCode`** | HUD copy (prototype) |
|------------------|------------------------|
| **`active_contract_exists`** | **`Contract issue: denied — active contract already issued`** |
| **`no_eligible_template`** | **`Contract issue: denied — no eligible template (standing/band)`** |
| **`economy_cap_exceeded`** | **`Contract issue: denied — economy cap exceeded`** |
| **`invalid_reward_bundle`** | **`Contract issue: denied — invalid reward bundle`** |
| **`unknown_template`** | **`Contract issue: denied — unknown template`** |
| *(other / empty)* | **`Contract issue: denied — {reasonCode}`** |
- **`ContractRewardDeliveryLabel`** — header **`Contract rewards:`**; on in-session **`active``completed`** transition for tracked instance id, format **`completionRewardSummary`** grant lines (item qty, skill XP, rep — same line grammar as **`quest_hud_controller._format_reward_summary_lines`**); otherwise **`—`**.
**Transition tracking:** maintain **`_previous_status_by_instance`** + **`_last_reward_instance_id`** / **`_last_reward_summary`** keyed by **`contractInstanceId`** (parallel NEO-131 quest maps).
### 3. `main.gd` wiring
- Add **`ContractClient`** `@onready` reference (scene node).
- **`_setup_contract_hud()`** — instantiate **`ContractHudController`**, pass labels + **`_apply_authority_http_config_to_client`** + item/faction defs clients for grant display names.
- **`_try_contract_issue_key_input(event)`** — after quest accept router; delegate to controller.
- **`_request_contract_refresh()`** — called from controller hook + encounter complete path.
- In **`_on_encounter_progress_received`**, when prototype encounter **`state == "completed"`**, call **`_request_contract_refresh()`** (alongside existing inventory + quest refresh).
### 4. Scene labels (`main.tscn`)
Insert after **`QuestRewardDeliveryLabel`**:
| Node | Default text | Color cue |
|------|--------------|-----------|
| **`ContractActiveLabel`** | **`Contract:\n—`** | warm amber (distinct from quest blue) |
| **`ContractIssueFeedbackLabel`** | idle hint | lavender (match quest accept) |
| **`ContractRewardDeliveryLabel`** | **`Contract rewards:\n—`** | mint (match quest rewards) |
Add **`ContractClient`** sibling under scene root (with **`EncounterProgressClient`** / **`QuestProgressClient`**).
### 5. Flow
```mermaid
sequenceDiagram
participant Player
participant HUD as ContractHudController
participant Client as contract_client.gd
participant Server
participant Enc as encounter_progress_client
Player->>HUD: Shift+C
HUD->>Client: request_issue(prototype template, dev seed)
Client->>Server: POST …/contracts/issue
Server-->>Client: issued + active row (encounterTemplateId)
Client-->>HUD: contract_issue_result_received
HUD->>Client: request_sync_from_server()
Client->>Server: GET …/contracts
Server-->>HUD: contracts_received → ContractActiveLabel
Player->>Enc: defeat pocket NPCs
Enc->>Server: GET encounter-progress (via cast loop)
Server-->>Enc: encounter completed
Enc-->>HUD: main refresh hook
HUD->>Client: request_sync_from_server()
Server-->>HUD: completed row + completionRewardSummary
HUD-->>Player: ContractRewardDeliveryLabel grant lines
```
## Files to add
| Path | Rationale |
|------|-----------|
| `client/scripts/contract_client.gd` | GET/POST HTTP bridge + parse helpers + signals |
| `client/scripts/contract_client.gd.uid` | Godot uid companion (tracked) |
| `client/scripts/contract_hud_controller.gd` | Issue key, HUD render, reward transition |
| `client/scripts/contract_hud_controller.gd.uid` | Godot uid companion |
| `client/test/contract_client_test.gd` | GdUnit: parse GET/issue JSON; active row helper; sync/issue signals |
| `client/test/contract_client_test.gd.uid` | GdUnit uid companion |
| `client/test/contract_hud_controller_test.gd` | GdUnit: deny copy, active label with encounter id, reward transition |
| `client/test/contract_hud_controller_test.gd.uid` | GdUnit uid companion |
| `docs/manual-qa/NEO-153.md` | Godot manual QA — issue, track active, complete, reward line |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Add **`ContractClient`** node + three contract HUD labels |
| `client/scripts/main.gd` | Thin contract HUD setup, gameplay key route, encounter-complete refresh hook |
| `client/README.md` | Contract issue + progress HUD subsection (keys, refresh triggers, server deps NEO-151) |
## Tests
| File | Coverage |
|------|----------|
| `client/test/contract_client_test.gd` | **`parse_contracts_json`**: active + completed rows with **`encounterTemplateId`** + **`completionRewardSummary`**; schema mismatch → null; GET 404 → **`contracts_sync_failed`**; POST **`issued: true`** / **`issued: false`** + **`reasonCode`**; **`active_contract_row`** helper. AAA (`# Arrange` / `# Act` / `# Assert`). |
| `client/test/contract_hud_controller_test.gd` | Deny copy for **`economy_cap_exceeded`**, **`active_contract_exists`**; active label includes **`prototype_combat_pocket`**; reward label populates on simulated active→completed transition with summary grants. AAA layout. |
No new **C#** tests (client-only; server covered by **`ContractApiTests`**). No Bruno changes.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Fixed seed blocks re-issue after complete (same deterministic instance id) | **Accept for NEO-153** — single-loop AC; **NEO-154** adds seed rotation / fixture reset for repeat capstone | `adopted` |
| Duplicate reward formatter vs shared utility | **Duplicate minimal grant line helpers** inside **`contract_hud_controller.gd`** — avoid coupling to quest controller private methods | `adopted` |
| Issue while encounter already completed (server v1 edge) | **Document in manual QA** — prototype flow is issue-then-clear; HUD still shows **`active`** until server completes contract on a *future* matching clear (NEO-149 README edge case) | `adopted` |
| Concurrent GET + issue | Separate **`_sync_busy`** / **`_issue_busy`** guards; coalesce pending sync like quest client | `adopted` |
| Grant display names before item/faction defs load | Re-render contract reward label on **`definitions_ready`** / **`faction_standing_received`** when summary cached (NEO-122 item-defs re-paint precedent) | `adopted` |