# 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 (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": "", "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` |