226 lines
15 KiB
Markdown
226 lines
15 KiB
Markdown
# 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 (0–1) + 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` |
|