15 KiB
NEO-153 — E7M4-10: Client contract issue + progress HUD (Godot)
Linear: NEO-153
Branch: NEO-153-e7m4-10-client-contract-issue-progress-hud-godot
Backlog: E7M4-pre-production-backlog.md — E7M4-10
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-122 (quest_progress_client.gd + quest_hud_controller.gd); NEO-110 (encounter-complete refresh spine); NEO-131 (completion reward transition label)
Precursors: NEO-151 Done — POST …/contracts/issue + GET …/contracts (on main); NEO-152 Done — contract telemetry hook sites (on main)
Blocks: NEO-154 (Godot repeatable contract capstone)
Server counterpart: NEO-151 — authoritative issue/list HTTP; Bruno is not prototype-complete per full-stack epic decomposition
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 + 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;schemaVersion1; bodyplayerIdmatches pathdev_player_id. - Refresh strategy: boot hydrate + after successful issue POST + after encounter-progress GET when prototype encounter row reaches
completed(extend_on_encounter_progress_receivedcontract 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}/contractsparse v1; POST/game/players/{id}/contracts/issue; helpersactive_contract_row(snapshot),completion_reward_summary(contract_instance_id, snapshot); signalscontracts_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 withencounterTemplateId, reward transition detection + summary lines (duplicate minimal grant formatters from quest HUD — item defs + faction standing clients injected).main.tscn:ContractClientnode; three HUD labels inHudRootScroll/HudRootafterQuestRewardDeliveryLabel, beforeFactionStandingLabel.main.gd: thin setup —ContractHudController, authority HTTP config, encounter-complete contract refresh hook,_try_contract_issue_key_inputin 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.mdcontract 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.
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 withencounterTemplateId; deny copy; in-session reward transition + economy refresh.main.tscn/main.gd:ContractClientnode + three HUD labels; encounter-complete contract refresh hook.- Tests:
contract_client_test.gd(7 cases),contract_hud_controller_test.gd(5 cases) — GdUnit green. - Docs:
client/README.mdcontract HUD section;docs/manual-qa/NEO-153.md.
Technical approach
Server contract (landed — NEO-151)
POST /game/players/{id}/contracts/issue→schemaVersion1,issued, optionalreasonCode, optionalcontractrow. HTTP 200 on structured deny (quest accept precedent).GET /game/players/{id}/contracts→schemaVersion1,playerId,contracts[]: active row (0–1) + up to 10 completed. Row fields:contractInstanceId,templateId,templateDisplayName,status(active|completed),encounterTemplateId,seedBucket,issuedAt, optionalcompletedAt, optionalcompletionRewardSummary(same nested grant shape as quest NEO-129).- Prototype template (E7M4-01 freeze):
prototype_contract_clear_combat_pocket→encounterTemplateIdprototype_combat_pocket; completion bundlescrap_metal_bulk×5,salvage+15 XP.
Client constants (contract_hud_controller.gd or shared const block)
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:
{
"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; callsrequest_issue(PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET)request_contract_refresh()— delegate GET syncContractActiveLabelrender:- 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}
- sync error →
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— headerContract rewards:; on in-sessionactive→completedtransition for tracked instance id, formatcompletionRewardSummarygrant lines (item qty, skill XP, rep — same line grammar asquest_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@onreadyreference (scene node). _setup_contract_hud()— instantiateContractHudController, 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 encounterstate == "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
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 |