NEO-153: kickoff implementation plan for contract HUD client slice.

Documents E7M4-10 scope, adopted kickoff decisions (separate contract_hud_controller,
fixed prototype_contract_dev_seed), file lists, and test plan ahead of Godot work.
pull/195/head
VinPropane 2026-06-28 13:17:20 -04:00
parent fe0d23b01c
commit 9d756c259b
1 changed files with 217 additions and 0 deletions

View File

@ -0,0 +1,217 @@
# 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
- [ ] Active contract visible with encounter objective id after issue.
- [ ] Completed contract shows reward summary line on HUD.
## 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` |