From 9d756c259b5fb43e35f71a097e5c9c07409ddeef Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 13:17:20 -0400 Subject: [PATCH 01/11] 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. --- docs/plans/NEO-153-implementation-plan.md | 217 ++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/plans/NEO-153-implementation-plan.md diff --git a/docs/plans/NEO-153-implementation-plan.md b/docs/plans/NEO-153-implementation-plan.md new file mode 100644 index 0000000..7e4f666 --- /dev/null +++ b/docs/plans/NEO-153-implementation-plan.md @@ -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 (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` | From b76fc60c0977233c120d0b50b17aaa93a3dad163 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 13:20:30 -0400 Subject: [PATCH 02/11] NEO-153: remove redundant Skills/Encounters usings in contract tests. CS8019 hints came from per-file imports duplicating GlobalUsings.cs (NeonSprawl.Server.Game.Skills) and unused Encounters in ContractApiTests. --- .../NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs | 2 -- .../Contracts/ContractGeneratorOperationsIntegrationTests.cs | 1 - .../Game/Contracts/ContractGeneratorOperationsTests.cs | 1 - .../Contracts/ContractGeneratorPersistenceIntegrationTests.cs | 1 - .../Game/Contracts/ContractPrototypeTestFixtures.cs | 1 - 5 files changed, 6 deletions(-) diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs index 63c2993..410602f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs @@ -1,11 +1,9 @@ using NeonSprawl.Server.Game.Contracts; -using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Rewards; -using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs index 9de5f42..476a75d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs @@ -2,7 +2,6 @@ using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; -using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs index bd8e527..d258367 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -4,7 +4,6 @@ using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; -using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs index 2721d2c..bfaa71d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs @@ -2,7 +2,6 @@ using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; -using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState; diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs index 9842dd9..d3e3ad8 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs @@ -1,7 +1,6 @@ using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; -using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; From fea992833bfe7b4e2b9d4895ec04b08584e38afe Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 13:34:16 -0400 Subject: [PATCH 03/11] chore: enforce warnings-as-errors and dotnet format in CI/hooks. Add .editorconfig (IDE0005/CS8019), Directory.Build.props (TreatWarningsAsErrors), scripts/verify-dotnet-format.sh, pre-commit gate on server .cs changes, and CI format step before build. --- .cursor/rules/csharp-style.md | 8 ++++++-- .editorconfig | 13 +++++++++++++ .github/workflows/dotnet.yml | 8 ++++++++ Directory.Build.props | 7 +++++++ client/README.md | 2 +- scripts/git-hooks/pre-commit | 9 +++++++++ scripts/verify-dotnet-format.sh | 31 +++++++++++++++++++++++++++++++ 7 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 .editorconfig create mode 100644 Directory.Build.props create mode 100755 scripts/verify-dotnet-format.sh diff --git a/.cursor/rules/csharp-style.md b/.cursor/rules/csharp-style.md index 10f179f..5e11015 100644 --- a/.cursor/rules/csharp-style.md +++ b/.cursor/rules/csharp-style.md @@ -262,5 +262,9 @@ public async Task PostExample_ShouldReturnOk_WhenBodyValid() ## Tooling -- Prefer fixes that satisfy built-in **.NET analyzers** / `dotnet format` (if adopted) rather than fighting IDE warnings without reason. -- Remove **unnecessary `using` directives** (IDE0005 / CS8019) — with **implicit usings** and **global usings**, many `Microsoft.Extensions.*`, `System.Threading`, and `System.Linq` imports are redundant. Run `dotnet format .csproj --diagnostics IDE0005 --severity info` before merge on touched projects. +- **`Directory.Build.props`**: **`TreatWarningsAsErrors`** — any compiler or analyzer **warning** fails **`dotnet build`**. +- **`.editorconfig`**: **`IDE0005`** / **`CS8019`** (unnecessary usings) at **warning** severity — caught by **`dotnet format`**, not duplicated in per-file usings when covered by **`GlobalUsings.cs`** or implicit usings. +- Before commit on touched **`server/**/*.cs`**: pre-commit runs **`scripts/verify-dotnet-format.sh`**; CI runs the same after restore. +- Fix redundant usings locally: `dotnet format server/NeonSprawl.Server/NeonSprawl.Server.csproj --severity warn` (and Tests project when applicable). +- Prefer fixes that satisfy built-in **.NET analyzers** / `dotnet format` rather than fighting IDE warnings without reason. +- When adding **`server/NeonSprawl.Server.Tests/**/*.cs`**, read **`GlobalUsings.cs`** first — do not copy sibling **`using`** blocks blindly; only import namespaces used in that file. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d789d69 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Neon Sprawl — shared editor and analyzer settings (C# server). +# Pair with Directory.Build.props (TreatWarningsAsErrors + EnforceCodeStyleInBuild). +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.cs] +# Redundant usings duplicate GlobalUsings.cs / implicit usings (CS8019 / IDE0005). +dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.CS8019.severity = warning diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c5af815..8fb02e3 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -11,6 +11,8 @@ on: branches: [main] paths: - "NeonSprawl.sln" + - "Directory.Build.props" + - ".editorconfig" - "server/**" - "bruno/neon-sprawl-server/**" - ".github/workflows/dotnet.yml" @@ -52,6 +54,8 @@ jobs: filters: | server: - "NeonSprawl.sln" + - "Directory.Build.props" + - ".editorconfig" - "server/**" - "bruno/neon-sprawl-server/**" - ".github/workflows/dotnet.yml" @@ -70,6 +74,10 @@ jobs: if: github.event_name == 'push' || steps.paths.outputs.server == 'true' run: dotnet restore NeonSprawl.sln + - name: Verify dotnet format (IDE0005 / style) + if: github.event_name == 'push' || steps.paths.outputs.server == 'true' + run: ./scripts/verify-dotnet-format.sh + - name: Build if: github.event_name == 'push' || steps.paths.outputs.server == 'true' run: dotnet build NeonSprawl.sln --no-restore --configuration Release diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2a6c53b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + + + diff --git a/client/README.md b/client/README.md index b18052b..959d9c7 100644 --- a/client/README.md +++ b/client/README.md @@ -501,7 +501,7 @@ python3 -m venv .venv-gd This enables: -- **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, or when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates. +- **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates, or when staged `server/**/*.cs` fails **`scripts/verify-dotnet-format.sh`** (unnecessary usings / warn-severity style — pair with **`TreatWarningsAsErrors`** in **`Directory.Build.props`**). - **pre-push**: when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** (same scope as CI). Otherwise skips lint/format. Still requires a **clean working tree** on every push. The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** when GDScript changed — same as CI. Pushes with no client `.gd` changes do not require gdtoolkit installed. diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 426303c..c67dc16 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -46,3 +46,12 @@ if has_staged_match '^client/scripts/.*\.gd$'; then exit 1 fi fi + +if has_staged_match '^server/.*\.cs$'; then + if ! "$repo_root/scripts/verify-dotnet-format.sh"; then + echo "pre-commit: dotnet format check failed (unnecessary usings / style)." >&2 + echo "Fix with: dotnet format server/NeonSprawl.Server/NeonSprawl.Server.csproj --severity warn" >&2 + echo " dotnet format server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --severity warn" >&2 + exit 1 + fi +fi diff --git a/scripts/verify-dotnet-format.sh b/scripts/verify-dotnet-format.sh new file mode 100755 index 0000000..0d22fe1 --- /dev/null +++ b/scripts/verify-dotnet-format.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Verify dotnet format (IDE0005 unnecessary usings + other warn-severity style) with no auto-fixes. +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$repo_root" + +projects=( + server/NeonSprawl.Server/NeonSprawl.Server.csproj + server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +) + +try_format() { + local proj="$1" + dotnet format "$proj" --verify-no-changes --severity warn --no-restore +} + +restore_needed=false +for proj in "${projects[@]}"; do + if ! try_format "$proj" >/dev/null 2>&1; then + restore_needed=true + break + fi +done + +if [[ "$restore_needed" == true ]]; then + dotnet restore NeonSprawl.sln --verbosity quiet + for proj in "${projects[@]}"; do + try_format "$proj" + done +fi From 7deb10bf2125d80591074c15fcb8308620ae3a33 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 13:47:43 -0400 Subject: [PATCH 04/11] NEO-153: use CompareOrdinal in contract instance sort tie-break. Fixes Roslynator RCS1235 on redundant string.Compare overload. --- .../Game/Contracts/InMemoryContractInstanceStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs index 1cac067..90a356b 100644 --- a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs @@ -277,7 +277,7 @@ public sealed class InMemoryContractInstanceStore(IOptions var cmp = b.IssuedAt.CompareTo(a.IssuedAt); return cmp != 0 ? cmp - : string.Compare(a.ContractInstanceId, b.ContractInstanceId, StringComparison.Ordinal); + : string.CompareOrdinal(a.ContractInstanceId, b.ContractInstanceId); }); if (completed.Count > maxCompletedRows) From 365e57799a5a9bec5c4cbf4e3772a3cfa708c07c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 13:49:14 -0400 Subject: [PATCH 05/11] NEO-153: add contract issue + progress HUD in Godot client. Shift+C issues prototype contract via contract_client; contract_hud_controller shows encounter objective, deny copy, and completion reward transition. Includes scene wiring, GdUnit tests, manual QA, and README. --- client/README.md | 17 +- client/scenes/main.tscn | 36 ++ client/scripts/contract_client.gd | 265 ++++++++++++++ client/scripts/contract_client.gd.uid | 1 + client/scripts/contract_hud_controller.gd | 329 ++++++++++++++++++ client/scripts/contract_hud_controller.gd.uid | 1 + client/scripts/main.gd | 39 +++ client/test/contract_client_test.gd | 221 ++++++++++++ client/test/contract_client_test.gd.uid | 1 + client/test/contract_hud_controller_test.gd | 220 ++++++++++++ .../test/contract_hud_controller_test.gd.uid | 1 + docs/manual-qa/NEO-153.md | 46 +++ docs/plans/NEO-153-implementation-plan.md | 12 +- 13 files changed, 1186 insertions(+), 3 deletions(-) create mode 100644 client/scripts/contract_client.gd create mode 100644 client/scripts/contract_client.gd.uid create mode 100644 client/scripts/contract_hud_controller.gd create mode 100644 client/scripts/contract_hud_controller.gd.uid create mode 100644 client/test/contract_client_test.gd create mode 100644 client/test/contract_client_test.gd.uid create mode 100644 client/test/contract_hud_controller_test.gd create mode 100644 client/test/contract_hud_controller_test.gd.uid create mode 100644 docs/manual-qa/NEO-153.md diff --git a/client/README.md b/client/README.md index 959d9c7..d6a2227 100644 --- a/client/README.md +++ b/client/README.md @@ -251,6 +251,21 @@ Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md). Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md). +## Contract issue + progress HUD (NEO-153) + +- **`POST /game/players/{id}/contracts/issue`** — **`issued`** + optional **`reasonCode`** + optional **`contract`** row (NEO-151); see [server README — Contract issue HTTP](../server/README.md#contract-issue-http-neo-151). +- **`GET /game/players/{id}/contracts`** — active row (0–1) + recent completed rows with **`encounterTemplateId`** and optional **`completionRewardSummary`** (NEO-151). +- **Scripts:** `scripts/contract_client.gd`, `scripts/contract_hud_controller.gd`; wired from `main.gd` in `_setup_contract_hud()`. +- **HUD:** + - **`UICanvas/HudRootScroll/HudRoot/ContractActiveLabel`** — active contract with **`encounterTemplateId`**; **`completed`** after pocket clear in-session. + - **`UICanvas/HudRootScroll/HudRoot/ContractIssueFeedbackLabel`** — issue success/deny/failure copy. + - **`UICanvas/HudRootScroll/HudRoot/ContractRewardDeliveryLabel`** — grant lines on in-session **`active` → `completed`** transition (NEO-131 pattern). +- **Issue key:** **Shift+C** — frozen prototype template **`prototype_contract_clear_combat_pocket`**, seed **`prototype_contract_dev_seed`**. +- **Refresh:** boot hydrate + GET after issue POST + encounter progress GET when pocket **`completed`** (alongside inventory/quest refresh). No periodic poll. +- **Deny copy:** readable strings for **`active_contract_exists`**, **`no_eligible_template`**, **`economy_cap_exceeded`**, **`invalid_reward_bundle`**, **`unknown_template`**. + +Full checklist: [`docs/manual-qa/NEO-153.md`](../docs/manual-qa/NEO-153.md). + ## Faction standing + gate feedback HUD (NEO-142) - **`GET /game/players/{id}/faction-standing`** — server-authoritative per-faction **`standing`** snapshot (NEO-139); see [server README — Faction standing read](../server/README.md#faction-standing-read-neo-139). @@ -474,7 +489,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. -**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. +**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`contract_client.gd`**, **`contract_hud_controller.gd`** (NEO-153), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 82a18e4..4c25960 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -24,6 +24,7 @@ [ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"] [ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"] [ext_resource type="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"] +[ext_resource type="Script" uid="uid://bneo153contract01" path="res://scripts/contract_client.gd" id="28_contract_client"] [ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"] [ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"] @@ -1124,6 +1125,9 @@ script = ExtResource("24_enc_prog") [node name="QuestProgressClient" type="Node" parent="." unique_id=2500013] script = ExtResource("25_quest_prog") +[node name="ContractClient" type="Node" parent="." unique_id=2500016] +script = ExtResource("28_contract_client") + [node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014] script = ExtResource("26_quest_defs") @@ -1275,6 +1279,38 @@ autowrap_mode = 3 text = "Quest rewards: —" +[node name="ContractActiveLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000029] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.98, 0.88, 0.62, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 8 +theme_override_font_sizes/font_size = 22 +autowrap_mode = 3 +text = "Contract: +—" + +[node name="ContractIssueFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000030] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.9, 0.86, 1, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 8 +theme_override_font_sizes/font_size = 22 +autowrap_mode = 3 +text = "Contract issue: — (Shift+C prototype)" + +[node name="ContractRewardDeliveryLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000031] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.78, 0.95, 0.82, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 8 +theme_override_font_sizes/font_size = 22 +autowrap_mode = 3 +text = "Contract rewards: +—" + [node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/contract_client.gd b/client/scripts/contract_client.gd new file mode 100644 index 0000000..fc129b1 --- /dev/null +++ b/client/scripts/contract_client.gd @@ -0,0 +1,265 @@ +extends Node + +## NEO-153: HTTP client for contract list GET (NEO-151) and contract issue POST (NEO-151). + +signal contracts_received(snapshot: Dictionary) +signal contracts_sync_failed(reason: String) +signal contract_issue_result_received(result: Dictionary) +signal contract_issue_failed(reason: String) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var dev_player_id: String = "dev-local-1" +@export var injected_sync_http: Node = null +@export var injected_issue_http: Node = null + +var _sync_http: Node +var _issue_http: Node +var _sync_busy: bool = false +var _sync_pending: bool = false +var _issue_busy: bool = false + + +func _ready() -> void: + _sync_http = _resolve_http(injected_sync_http) + _issue_http = _resolve_http(injected_issue_http) + @warning_ignore("unsafe_method_access") + _sync_http.request_completed.connect(_on_sync_request_completed) + @warning_ignore("unsafe_method_access") + _issue_http.request_completed.connect(_on_issue_request_completed) + + +func is_issue_busy() -> bool: + return _issue_busy + + +func request_sync_from_server() -> void: + if _sync_busy: + _sync_pending = true + return + _start_sync_request() + + +func _start_sync_request() -> void: + _sync_busy = true + var url := "%s/game/players/%s/contracts" % [_base_root(), _player_path_segment()] + var err: Error = _sync_http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("ContractClient: %s" % reason) + _sync_busy = false + contracts_sync_failed.emit(reason) + _try_flush_pending_sync() + + +## Returns [code]true[/code] when the HTTP POST was queued. +func request_issue(template_id: String, seed_bucket: String) -> bool: + if _issue_busy: + return false + var tid := template_id.strip_edges() + var seed := seed_bucket.strip_edges() + if tid.is_empty() or seed.is_empty(): + return false + _issue_busy = true + var payload: Dictionary = { + "schemaVersion": SCHEMA_VERSION, + "playerId": _player_path_segment(), + "templateId": tid, + "seedBucket": seed, + } + var url := "%s/game/players/%s/contracts/issue" % [_base_root(), _player_path_segment()] + var headers := PackedStringArray(["Content-Type: application/json"]) + var err: Error = _issue_http.request( + url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload) + ) + if err != OK: + _issue_busy = false + push_warning("ContractClient: POST failed to start (%s)" % err) + return false + return true + + +static func active_contract_row(snapshot: Dictionary = {}) -> Dictionary: + var contracts: Variant = snapshot.get("contracts", null) + if contracts == null or not contracts is Array: + return {} + for row_variant in contracts as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("status", "")) == "active": + return row.duplicate(true) + return {} + + +static func contract_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: + var iid := instance_id.strip_edges() + if iid.is_empty(): + return {} + var contracts: Variant = snapshot.get("contracts", null) + if contracts == null or not contracts is Array: + return {} + for row_variant in contracts as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("contractInstanceId", "")) == iid: + return row.duplicate(true) + return {} + + +static func completion_reward_summary(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: + var row: Dictionary = contract_row(instance_id, snapshot) + if row.is_empty() or str(row.get("status", "")) != "completed": + return {} + var summary_variant: Variant = row.get("completionRewardSummary", null) + if summary_variant is Dictionary: + return (summary_variant as Dictionary).duplicate(true) + return {} + + +static func parse_contracts_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + return null + var contracts: Variant = root.get("contracts", null) + if contracts == null or not contracts is Array: + return null + if str(root.get("playerId", "")).strip_edges().is_empty(): + return null + return root + + +static func parse_contract_issue_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + return null + if not root.has("issued"): + return null + return root + + +static func merge_contract_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary: + var iid := str(row.get("contractInstanceId", "")).strip_edges() + if iid.is_empty(): + return snapshot.duplicate(true) if not snapshot.is_empty() else {} + var merged: Dictionary + if snapshot.is_empty(): + merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "contracts": []} + else: + merged = snapshot.duplicate(true) + var contracts: Array = [] + var contracts_variant: Variant = merged.get("contracts", null) + if contracts_variant is Array: + for row_variant in contracts_variant as Array: + contracts.append(row_variant) + var replaced := false + for i in contracts.size(): + var existing: Variant = contracts[i] + if existing is Dictionary and str((existing as Dictionary).get("contractInstanceId", "")) == iid: + contracts[i] = row.duplicate(true) + replaced = true + break + if not replaced: + contracts.append(row.duplicate(true)) + merged["contracts"] = contracts + return merged + + +func _resolve_http(injected: Node) -> Node: + if injected != null: + return injected + var http := HTTPRequest.new() + add_child(http) + http.timeout = 30.0 + return http + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +func _try_flush_pending_sync() -> void: + if not _sync_pending or _sync_busy: + return + _sync_pending = false + _start_sync_request() + + +func _on_sync_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _sync_busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("ContractClient: %s" % reason) + contracts_sync_failed.emit(reason) + _try_flush_pending_sync() + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("ContractClient: %s" % reason404) + contracts_sync_failed.emit(reason404) + _try_flush_pending_sync() + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("ContractClient: %s" % reason_code) + contracts_sync_failed.emit(reason_code) + _try_flush_pending_sync() + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_contracts_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("ContractClient: %s" % reason_json) + contracts_sync_failed.emit(reason_json) + _try_flush_pending_sync() + return + contracts_received.emit(snapshot as Dictionary) + _try_flush_pending_sync() + + +func _on_issue_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _issue_busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("ContractClient: %s" % reason) + contract_issue_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("ContractClient: %s" % reason404) + contract_issue_failed.emit(reason404) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("ContractClient: %s" % reason_code) + contract_issue_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var parsed: Variant = parse_contract_issue_json(text) + if parsed == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("ContractClient: %s" % reason_json) + contract_issue_failed.emit(reason_json) + return + var data: Dictionary = parsed as Dictionary + if not bool(data.get("issued", false)): + var reason_variant: Variant = data.get("reasonCode", "") + var reason_str: String = reason_variant as String if reason_variant is String else "" + push_warning("contract_issue_denied reasonCode=%s" % reason_str) + contract_issue_result_received.emit(data.duplicate(true)) diff --git a/client/scripts/contract_client.gd.uid b/client/scripts/contract_client.gd.uid new file mode 100644 index 0000000..dd21dd8 --- /dev/null +++ b/client/scripts/contract_client.gd.uid @@ -0,0 +1 @@ +uid://bneo153contract01 diff --git a/client/scripts/contract_hud_controller.gd b/client/scripts/contract_hud_controller.gd new file mode 100644 index 0000000..ddd452e --- /dev/null +++ b/client/scripts/contract_hud_controller.gd @@ -0,0 +1,329 @@ +extends Node + +## NEO-153: contract issue + progress HUD wiring (Shift+C prototype issue). + +const ContractClient := preload("res://scripts/contract_client.gd") +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") + +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)" +const ISSUE_SENDING_HINT := "Contract issue: sending…" +const REWARD_HEADER := "Contract rewards:" + +var _contract_client: Node = null +var _item_defs_client: Node = null +var _inventory_client: Node = null +var _skill_progression_client: Node = null +var _faction_standing_client: Node = null +var _active_label: Label = null +var _issue_label: Label = null +var _reward_label: Label = null +var _last_snapshot: Dictionary = {} +var _sync_error: String = "" +var _issue_contract_patch: Dictionary = {} +var _previous_status_by_instance: Dictionary = {} +var _contract_was_active_in_session: Dictionary = {} +var _tracked_instance_id: String = "" +var _last_reward_instance_id: String = "" +var _last_reward_summary: Dictionary = {} + + +func setup( + contract_client: Node, + active_label: Label, + issue_label: Label, + reward_label: Label, + apply_http_config: Callable, + item_defs_client: Node = null, + inventory_client: Node = null, + skill_progression_client: Node = null, + faction_standing_client: Node = null +) -> void: + _contract_client = contract_client + _active_label = active_label + _issue_label = issue_label + _reward_label = reward_label + _item_defs_client = item_defs_client + _inventory_client = inventory_client + _skill_progression_client = skill_progression_client + _faction_standing_client = faction_standing_client + if apply_http_config.is_valid(): + apply_http_config.call(contract_client) + if _contract_client.has_signal("contracts_received"): + _contract_client.connect("contracts_received", Callable(self, "_on_contracts_received")) + if _contract_client.has_signal("contracts_sync_failed"): + _contract_client.connect("contracts_sync_failed", Callable(self, "_on_sync_failed")) + if _contract_client.has_signal("contract_issue_result_received"): + _contract_client.connect( + "contract_issue_result_received", Callable(self, "_on_issue_result_received") + ) + if _contract_client.has_signal("contract_issue_failed"): + _contract_client.connect("contract_issue_failed", Callable(self, "_on_issue_failed")) + if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"): + _item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready")) + _render_active_label() + _render_issue_feedback_label(ISSUE_IDLE_HINT) + _render_reward_label() + request_contract_refresh() + + +func request_contract_refresh() -> void: + if not is_instance_valid(_contract_client): + return + if _contract_client.has_method("request_sync_from_server"): + _contract_client.call("request_sync_from_server") + + +func try_issue_key_input(event: InputEvent) -> bool: + if not event is InputEventKey: + return false + var k := event as InputEventKey + if not k.pressed or k.echo or not k.shift_pressed: + return false + if k.physical_keycode != KEY_C and k.keycode != KEY_C: + return false + if not is_instance_valid(_contract_client): + return false + if not _contract_client.has_method("request_issue"): + return false + if bool(_contract_client.call("is_issue_busy")): + return true + _render_issue_feedback_label(ISSUE_SENDING_HINT) + var queued: bool = bool( + _contract_client.call( + "request_issue", PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET + ) + ) + if not queued: + _render_issue_feedback_label("Contract issue: failed — request not queued") + return true + + +func _on_contracts_received(snapshot: Dictionary) -> void: + _sync_error = "" + _detect_and_apply_completion_transitions(snapshot) + _last_snapshot = snapshot.duplicate(true) + _reapply_issue_contract_patch_if_needed() + _render_active_label() + _render_reward_label() + + +func _on_sync_failed(reason: String) -> void: + _sync_error = reason + if _issue_contract_patch.is_empty(): + _last_snapshot = {} + elif _last_snapshot.is_empty(): + _last_snapshot = ContractClient.merge_contract_row_into_snapshot({}, _issue_contract_patch) + _render_active_label() + _render_reward_label() + + +func _on_issue_result_received(result: Dictionary) -> void: + if bool(result.get("issued", false)): + var contract_variant: Variant = result.get("contract", null) + if contract_variant is Dictionary: + var row: Dictionary = contract_variant as Dictionary + var iid := str(row.get("contractInstanceId", "")).strip_edges() + if not iid.is_empty(): + _mark_contract_active_in_session(iid) + _tracked_instance_id = iid + _issue_contract_patch = row.duplicate(true) + _sync_error = "" + _last_snapshot = ContractClient.merge_contract_row_into_snapshot( + _last_snapshot, _issue_contract_patch + ) + var display := str(row.get("templateDisplayName", "")).strip_edges() + if display.is_empty(): + display = str(row.get("templateId", PROTOTYPE_CONTRACT_TEMPLATE_ID)) + _render_issue_feedback_label("Contract issue: %s issued" % display) + _render_active_label() + else: + _render_issue_feedback_label("Contract issue: issued") + else: + var rc := str(result.get("reasonCode", "")).strip_edges() + _render_issue_feedback_label(_format_issue_deny(rc)) + request_contract_refresh() + + +func _on_issue_failed(reason: String) -> void: + _render_issue_feedback_label("Contract issue: failed — %s" % reason) + + +func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: + if not _last_reward_instance_id.is_empty(): + _render_reward_label() + + +func _mark_contract_active_in_session(instance_id: String) -> void: + var iid := instance_id.strip_edges() + if not iid.is_empty(): + _contract_was_active_in_session[iid] = true + + +func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: + var boot_seed := _previous_status_by_instance.is_empty() + var contracts_variant: Variant = snapshot.get("contracts", null) + if contracts_variant == null or not contracts_variant is Array: + return + for row_variant in contracts_variant as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + var iid := str(row.get("contractInstanceId", "")).strip_edges() + if iid.is_empty(): + continue + var new_status := str(row.get("status", "")) + if new_status == "active": + _mark_contract_active_in_session(iid) + if _tracked_instance_id.is_empty(): + _tracked_instance_id = iid + var prev_status := str(_previous_status_by_instance.get(iid, "")) + var can_detect := not boot_seed or bool(_contract_was_active_in_session.get(iid, false)) + if can_detect and prev_status == "active" and new_status == "completed": + var summary := ContractClient.completion_reward_summary(iid, snapshot) + if not summary.is_empty(): + _last_reward_instance_id = iid + _last_reward_summary = summary.duplicate(true) + _refresh_economy_hud() + _previous_status_by_instance[iid] = new_status + + +func _reapply_issue_contract_patch_if_needed() -> void: + if _issue_contract_patch.is_empty(): + return + _last_snapshot = ContractClient.merge_contract_row_into_snapshot( + _last_snapshot, _issue_contract_patch + ) + + +func _refresh_economy_hud() -> void: + if ( + is_instance_valid(_inventory_client) + and _inventory_client.has_method("request_sync_from_server") + ): + _inventory_client.call("request_sync_from_server") + if ( + is_instance_valid(_skill_progression_client) + and _skill_progression_client.has_method("request_sync_from_server") + ): + _skill_progression_client.call("request_sync_from_server") + + +func _render_active_label() -> void: + if not is_instance_valid(_active_label): + return + var header := "Contract:" + if not _sync_error.is_empty(): + _active_label.text = "%s\nerror — %s" % [header, _sync_error] + return + var row: Dictionary = ContractClient.active_contract_row(_last_snapshot) + if row.is_empty() and not _tracked_instance_id.is_empty(): + row = ContractClient.contract_row(_tracked_instance_id, _last_snapshot) + if row.is_empty(): + _active_label.text = "%s\n—" % header + return + var display := str(row.get("templateDisplayName", "")).strip_edges() + if display.is_empty(): + display = str(row.get("templateId", "")) + var status := str(row.get("status", "")) + var encounter := str(row.get("encounterTemplateId", "")).strip_edges() + if encounter.is_empty(): + encounter = "—" + _active_label.text = "%s\n%s: %s — objective %s" % [header, display, status, encounter] + + +func _render_issue_feedback_label(text: String = ISSUE_IDLE_HINT) -> void: + if is_instance_valid(_issue_label): + _issue_label.text = text + + +func _render_reward_label() -> void: + if not is_instance_valid(_reward_label): + return + if not _sync_error.is_empty(): + _reward_label.text = "%s\n—" % REWARD_HEADER + return + if _last_reward_instance_id.is_empty() or _last_reward_summary.is_empty(): + _reward_label.text = "%s\n—" % REWARD_HEADER + return + var row: Dictionary = ContractClient.contract_row(_last_reward_instance_id, _last_snapshot) + var display := str(row.get("templateDisplayName", "")).strip_edges() + if display.is_empty(): + display = str(row.get("templateId", "")) + var lines: PackedStringArray = [REWARD_HEADER, display] + lines.append_array(_format_reward_summary_lines(_last_reward_summary)) + _reward_label.text = "\n".join(lines) + + +func _format_issue_deny(reason_code: String) -> String: + match reason_code: + "active_contract_exists": + return "Contract issue: denied — active contract already issued" + "no_eligible_template": + return "Contract issue: denied — no eligible template (standing/band)" + "economy_cap_exceeded": + return "Contract issue: denied — economy cap exceeded" + "invalid_reward_bundle": + return "Contract issue: denied — invalid reward bundle" + "unknown_template": + return "Contract issue: denied — unknown template" + "": + return "Contract issue: denied (no reasonCode)" + _: + return "Contract issue: denied — %s" % reason_code + + +func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: + var lines: PackedStringArray = PackedStringArray() + var items_variant: Variant = summary.get("itemGrants", null) + if items_variant is Array: + for grant_variant in items_variant as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var item_id := str(grant.get("itemId", "")).strip_edges() + var qty: int = int(grant.get("quantity", 0)) + if item_id.is_empty() or qty <= 0: + continue + lines.append(" %s ×%d" % [_item_display_name(item_id), qty]) + var skills_variant: Variant = summary.get("skillXpGrants", null) + if skills_variant is Array: + for grant_variant in skills_variant as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var skill_id := str(grant.get("skillId", "")).strip_edges() + var amount: int = int(grant.get("amount", 0)) + if skill_id.is_empty() or amount <= 0: + continue + lines.append(" %s +%d XP" % [_title_case_token(skill_id), amount]) + var rep_variant: Variant = summary.get("reputationGrants", null) + if rep_variant is Array: + for grant_variant in rep_variant as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var faction_id := str(grant.get("factionId", "")).strip_edges() + var amount: int = int(grant.get("amount", 0)) + if faction_id.is_empty() or amount <= 0: + continue + lines.append(" %s +%d rep" % [FactionStandingClient.display_name_for(faction_id), amount]) + if lines.is_empty(): + lines.append(" (no grants)") + return lines + + +func _item_display_name(item_id: String) -> String: + if not is_instance_valid(_item_defs_client): + return item_id + if not _item_defs_client.has_method("display_name_for"): + return item_id + return str(_item_defs_client.call("display_name_for", item_id)) + + +static func _title_case_token(token: String) -> String: + var trimmed := token.strip_edges() + if trimmed.is_empty(): + return trimmed + return trimmed.substr(0, 1).to_upper() + trimmed.substr(1) diff --git a/client/scripts/contract_hud_controller.gd.uid b/client/scripts/contract_hud_controller.gd.uid new file mode 100644 index 0000000..e46c21d --- /dev/null +++ b/client/scripts/contract_hud_controller.gd.uid @@ -0,0 +1 @@ +uid://bneo153contract02 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 3b95e65..f542edb 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -57,6 +57,7 @@ const BREACH_GIG_ID := "breach" const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 const QuestHudController := preload("res://scripts/quest_hud_controller.gd") +const ContractHudController := preload("res://scripts/contract_hud_controller.gd") ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -110,6 +111,7 @@ var _gig_error: String = "" var _last_encounter_progress_snapshot: Dictionary = {} var _encounter_progress_error: String = "" var _quest_hud: Node = null +var _contract_hud: Node = null var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false @@ -137,6 +139,9 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel") @onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") @onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") +@onready var _contract_active_label: Label = _hud_root.get_node("ContractActiveLabel") +@onready var _contract_issue_feedback_label: Label = _hud_root.get_node("ContractIssueFeedbackLabel") +@onready var _contract_reward_delivery_label: Label = _hud_root.get_node("ContractRewardDeliveryLabel") @onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @@ -155,6 +160,7 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _gig_progression_client: Node = $GigProgressionClient @onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient +@onready var _contract_client: Node = $ContractClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient @onready var _faction_standing_client: Node = $FactionStandingClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @@ -207,6 +213,7 @@ func _ready() -> void: _setup_gig_progression_sync() _setup_encounter_progress_sync() _setup_quest_progress_sync() + _setup_contract_hud() _setup_gather_interact_feedback() _setup_craft_ui() @@ -749,6 +756,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void: if str(row.get("state", "")) == "completed": _request_inventory_refresh() _request_quest_progress_refresh() + _request_contract_refresh() func _on_encounter_progress_sync_failed(reason: String) -> void: @@ -885,6 +893,35 @@ func _try_quest_accept_key_input(event: InputEvent) -> bool: return false +func _setup_contract_hud() -> void: + # NEO-153: contract issue + progress HUD; boot hydrate + refresh after issue/encounter clear. + _contract_hud = ContractHudController.new() + add_child(_contract_hud) + _contract_hud.call( + "setup", + _contract_client, + _contract_active_label, + _contract_issue_feedback_label, + _contract_reward_delivery_label, + Callable(self, "_apply_authority_http_config_to_client"), + _item_defs_client, + _inventory_client, + _skill_progression_client, + _faction_standing_client + ) + + +func _request_contract_refresh() -> void: + if is_instance_valid(_contract_hud): + _contract_hud.call("request_contract_refresh") + + +func _try_contract_issue_key_input(event: InputEvent) -> bool: + if is_instance_valid(_contract_hud): + return bool(_contract_hud.call("try_issue_key_input", event)) + return false + + func _render_gather_feedback_label(text: String = "Gather: —") -> void: if is_instance_valid(_gather_feedback_label): _gather_feedback_label.text = text @@ -1407,6 +1444,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool: return true if _try_quest_accept_key_input(event): return true + if _try_contract_issue_key_input(event): + return true if _try_inventory_refresh_input(event): return true return false diff --git a/client/test/contract_client_test.gd b/client/test/contract_client_test.gd new file mode 100644 index 0000000..c64bc7c --- /dev/null +++ b/client/test/contract_client_test.gd @@ -0,0 +1,221 @@ +extends GdUnitTestSuite + +## NEO-153: `ContractClient` GET parse, issue POST, and failure signals. + +const ContractClient := preload("res://scripts/contract_client.gd") + +const INSTANCE_ID := "ci_test_instance" +const TEMPLATE_ID := "prototype_contract_clear_combat_pocket" +const ENCOUNTER_ID := "prototype_combat_pocket" + +var _contracts_capture: Dictionary = {} +var _issue_capture: Dictionary = {} +var _issue_failed_capture: String = "" + + +func _capture_contracts(snapshot: Dictionary) -> void: + _contracts_capture = snapshot + + +func _capture_issue(result: Dictionary) -> void: + _issue_capture = result + + +func _capture_issue_failed(reason: String) -> void: + _issue_failed_capture = reason + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + var last_method: int = HTTPClient.METHOD_GET + var response_code: int = 200 + var body_json: String = "" + + func request( + url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + last_url = url + last_method = method + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +class NoopHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + return OK + + +static func _active_contracts_json() -> String: + var body := ( + '{"schemaVersion":1,"playerId":"dev-local-1","contracts":' + + '[{"contractInstanceId":"%s","templateId":"%s",' + + '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",' + + '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",' + + '"issuedAt":"2026-06-28T12:00:00Z"}]}' + ) + return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID] + + +static func _completed_with_summary_json() -> String: + var body := ( + '{"schemaVersion":1,"playerId":"dev-local-1","contracts":' + + '[{"contractInstanceId":"%s","templateId":"%s",' + + '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"completed",' + + '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",' + + '"issuedAt":"2026-06-28T12:00:00Z","completedAt":"2026-06-28T13:00:00Z",' + + '"completionRewardSummary":{"itemGrants":[{"itemId":"scrap_metal_bulk","quantity":5}],' + + '"skillXpGrants":[{"skillId":"salvage","amount":15}]}}]}' + ) + return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID] + + +static func _issue_success_json() -> String: + var body := ( + '{"schemaVersion":1,"issued":true,"contract":' + + '{"contractInstanceId":"%s","templateId":"%s",' + + '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",' + + '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",' + + '"issuedAt":"2026-06-28T12:00:00Z"}}' + ) + return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID] + + +static func _issue_deny_json(reason_code: String) -> String: + return '{"schemaVersion":1,"issued":false,"reasonCode":"%s"}' % reason_code + + +func _make_client(sync_transport: Node, issue_transport: Node = null) -> Node: + var c: Node = ContractClient.new() + c.set("injected_sync_http", sync_transport) + var issue_http: Node = issue_transport if issue_transport != null else sync_transport + c.set("injected_issue_http", issue_http) + auto_free(sync_transport) + if issue_transport != null and issue_transport != sync_transport: + auto_free(issue_transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_active_contract_row_includes_encounter_id() -> void: + # Arrange + var json := _active_contracts_json() + # Act + var snapshot: Variant = ContractClient.parse_contracts_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var row: Dictionary = ContractClient.active_contract_row(snapshot as Dictionary) + assert_that(str(row.get("status", ""))).is_equal("active") + assert_that(str(row.get("encounterTemplateId", ""))).is_equal(ENCOUNTER_ID) + + +func test_parse_completed_row_has_completion_reward_summary() -> void: + # Arrange + var json := _completed_with_summary_json() + # Act + var snapshot: Variant = ContractClient.parse_contracts_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var summary: Dictionary = ContractClient.completion_reward_summary( + INSTANCE_ID, snapshot as Dictionary + ) + var items: Variant = summary.get("itemGrants", null) + assert_that(items is Array).is_true() + assert_that((items as Array).size()).is_equal(1) + + +func test_parse_contracts_json_rejects_schema_mismatch() -> void: + # Arrange + var json := '{"schemaVersion":99,"playerId":"dev-local-1","contracts":[]}' + # Act + var snapshot: Variant = ContractClient.parse_contracts_json(json) + # Assert + assert_that(snapshot).is_null() + + +func test_request_sync_emits_contracts_received() -> void: + # Arrange + _contracts_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _active_contracts_json() + var c := _make_client(transport, NoopHttpTransport.new()) + c.connect("contracts_received", Callable(self, "_capture_contracts")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("contracts_received", any()) + assert_that(_contracts_capture.get("playerId", "")).is_equal("dev-local-1") + assert_that(transport.last_url).contains("/contracts") + + +func test_http_404_emits_contracts_sync_failed() -> void: + # Arrange + _issue_failed_capture = "" + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport, NoopHttpTransport.new()) + c.connect("contracts_sync_failed", Callable(self, "_capture_issue_failed")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("contracts_sync_failed", any()) + assert_that(_issue_failed_capture).contains("404") + + +func test_issue_success_emits_contract_issue_result_received() -> void: + # Arrange + _issue_capture = {} + var sync := NoopHttpTransport.new() + var issue := MockHttpTransport.new() + issue.body_json = _issue_success_json() + var c := _make_client(sync, issue) + c.connect("contract_issue_result_received", Callable(self, "_capture_issue")) + monitor_signals(c) + # Act + c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed") + # Assert + await assert_signal(c).is_emitted("contract_issue_result_received", any()) + assert_bool(_issue_capture.get("issued", false)).is_true() + assert_that(issue.last_url).contains("/contracts/issue") + assert_that(issue.last_method).is_equal(HTTPClient.METHOD_POST) + + +func test_issue_deny_emits_result_with_reason_code() -> void: + # Arrange + _issue_capture = {} + var sync := NoopHttpTransport.new() + var issue := MockHttpTransport.new() + issue.body_json = _issue_deny_json("economy_cap_exceeded") + var c := _make_client(sync, issue) + c.connect("contract_issue_result_received", Callable(self, "_capture_issue")) + monitor_signals(c) + # Act + c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed") + # Assert + await assert_signal(c).is_emitted("contract_issue_result_received", any()) + assert_bool(_issue_capture.get("issued", true)).is_false() + assert_that(str(_issue_capture.get("reasonCode", ""))).is_equal("economy_cap_exceeded") diff --git a/client/test/contract_client_test.gd.uid b/client/test/contract_client_test.gd.uid new file mode 100644 index 0000000..64e6141 --- /dev/null +++ b/client/test/contract_client_test.gd.uid @@ -0,0 +1 @@ +uid://bneo153ctest01 diff --git a/client/test/contract_hud_controller_test.gd b/client/test/contract_hud_controller_test.gd new file mode 100644 index 0000000..2d93398 --- /dev/null +++ b/client/test/contract_hud_controller_test.gd @@ -0,0 +1,220 @@ +extends GdUnitTestSuite + +## NEO-153: contract HUD controller issue key, active label, reward transition. + +const ContractHudController := preload("res://scripts/contract_hud_controller.gd") +const ContractClient := preload("res://scripts/contract_client.gd") + +const INSTANCE_ID := "ci_test_instance" +const ENCOUNTER_ID := "prototype_combat_pocket" + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var response_code: int = 200 + var body_json: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +class NoopHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + return OK + + +class PendingIssueHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_POST, + _request_data: String = "" + ) -> Error: + return OK + + +static func _active_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "contracts": + [ + { + "contractInstanceId": INSTANCE_ID, + "templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID, + "templateDisplayName": "Clear Combat Pocket (Repeat)", + "status": "active", + "encounterTemplateId": ENCOUNTER_ID, + "seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET, + "issuedAt": "2026-06-28T12:00:00Z", + } + ], + } + + +static func _completed_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "contracts": + [ + { + "contractInstanceId": INSTANCE_ID, + "templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID, + "templateDisplayName": "Clear Combat Pocket (Repeat)", + "status": "completed", + "encounterTemplateId": ENCOUNTER_ID, + "seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET, + "issuedAt": "2026-06-28T12:00:00Z", + "completedAt": "2026-06-28T13:00:00Z", + "completionRewardSummary": + { + "itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}], + "skillXpGrants": [{"skillId": "salvage", "amount": 15}], + }, + } + ], + } + + +func _make_contract_client(sync_transport: Node, issue_transport: Node = null) -> Node: + var c: Node = ContractClient.new() + c.set("injected_sync_http", sync_transport) + var issue_http: Node = issue_transport if issue_transport != null else sync_transport + c.set("injected_issue_http", issue_http) + auto_free(sync_transport) + if issue_transport != null and issue_transport != sync_transport: + auto_free(issue_transport) + auto_free(c) + add_child(c) + return c + + +func _build_controller(sync_transport: Node, issue_transport: Node) -> Dictionary: + var controller: Node = ContractHudController.new() + var contract: Node = _make_contract_client(sync_transport, issue_transport) + var active_label := Label.new() + var issue_label := Label.new() + var reward_label := Label.new() + auto_free(controller) + auto_free(active_label) + auto_free(issue_label) + auto_free(reward_label) + add_child(controller) + await get_tree().process_frame + controller.call( + "setup", + contract, + active_label, + issue_label, + reward_label, + Callable(self, "_noop_http_config") + ) + await get_tree().process_frame + return { + "controller": controller, + "contract": contract, + "active_label": active_label, + "issue_label": issue_label, + "reward_label": reward_label, + } + + +func _noop_http_config(_client: Node) -> void: + pass + + +func test_try_issue_key_input_handles_shift_c() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), PendingIssueHttpTransport.new() + ) + var event := InputEventKey.new() + event.pressed = true + event.shift_pressed = true + event.keycode = KEY_C + # Act + var handled: bool = bool(built["controller"].call("try_issue_key_input", event)) + # Assert + assert_bool(handled).is_true() + assert_str(built["issue_label"].text).contains("sending") + + +func test_active_label_shows_encounter_objective_id() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + # Act + built["controller"].call("_on_contracts_received", _active_snapshot()) + # Assert + assert_str(built["active_label"].text).contains(ENCOUNTER_ID) + assert_str(built["active_label"].text).contains("active") + + +func test_issue_deny_shows_economy_cap_copy() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + # Act + built["controller"].call( + "_on_issue_result_received", {"issued": false, "reasonCode": "economy_cap_exceeded"} + ) + # Assert + assert_str(built["issue_label"].text).contains("economy cap exceeded") + + +func test_issue_deny_shows_active_contract_exists_copy() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + # Act + built["controller"].call( + "_on_issue_result_received", {"issued": false, "reasonCode": "active_contract_exists"} + ) + # Assert + assert_str(built["issue_label"].text).contains("active contract already issued") + + +func test_reward_label_populates_on_active_to_completed_transition() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + built["controller"].call("_on_contracts_received", _active_snapshot()) + # Act + built["controller"].call("_on_contracts_received", _completed_snapshot()) + # Assert + assert_str(built["reward_label"].text).contains("Contract rewards:") + assert_str(built["reward_label"].text).contains("Salvage +15 XP") diff --git a/client/test/contract_hud_controller_test.gd.uid b/client/test/contract_hud_controller_test.gd.uid new file mode 100644 index 0000000..3ed294d --- /dev/null +++ b/client/test/contract_hud_controller_test.gd.uid @@ -0,0 +1 @@ +uid://bneo153ctest02 diff --git a/docs/manual-qa/NEO-153.md b/docs/manual-qa/NEO-153.md new file mode 100644 index 0000000..041d3e4 --- /dev/null +++ b/docs/manual-qa/NEO-153.md @@ -0,0 +1,46 @@ +# NEO-153 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-153 | +| Title | E7M4-10: Client contract issue + progress HUD (Godot) | +| Linear | https://linear.app/neon-sprawl/issue/NEO-153/e7m4-10-client-contract-issue-progress-hud-godot | +| Plan | `docs/plans/NEO-153-implementation-plan.md` | +| Branch | `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` | + +## Preconditions + +- **Fresh dev player:** restart server so contract instance state is clean (or use dev fixture reset if a prior contract exists). +- **Server deps:** NEO-151 **`POST …/contracts/issue`** and **`GET …/contracts`** on `main`. +- **No Bruno/curl** for this checklist — issue and progress use Godot **Shift+C** and combat pocket clear only. + +## Expected contract HUD progression + +| After | `ContractActiveLabel` | `ContractIssueFeedbackLabel` | `ContractRewardDeliveryLabel` | +|-------|----------------------|------------------------------|-------------------------------| +| Boot | **`Contract: —`** | **`Contract issue: — (Shift+C prototype)`** | **`Contract rewards: —`** | +| **Shift+C** issue | **`Clear Combat Pocket (Repeat): active — objective prototype_combat_pocket`** | **`Contract issue: Clear Combat Pocket (Repeat) issued`** | **`—`** | +| Defeat ×3 NPCs (combat pocket) | **`…: completed — objective prototype_combat_pocket`** | unchanged | **`scrap_metal_bulk ×5`** + **`Salvage +15 XP`** (display names when item defs loaded) | +| Duplicate **Shift+C** while active | unchanged active line | **`Contract issue: denied — active contract already issued`** | unchanged | + +## Checklist + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). Confirm contract labels appear in **`HudRootScroll`** below quest reward labels. +3. Press **Shift+C**. Verify **`ContractActiveLabel`** shows **`active`** with **`prototype_combat_pocket`** objective id and issue feedback shows success. +4. Defeat all three combat pocket NPCs (hotbar casts). Verify encounter **`completed (3/3)`** and contract GET refresh shows **`completed`** on **`ContractActiveLabel`**. +5. Verify **`ContractRewardDeliveryLabel`** lists contract completion grants (**`scrap_metal_bulk ×5`**, **`Salvage +15 XP`**) after pocket clear — not on boot alone. +6. Press **Shift+C** again while contract still active (before clear) or after issuing once — verify readable deny when **`active_contract_exists`**. +7. Optional: stop server while Godot running, press **Shift+C** — verify **`Contract: error — …`** or **`Contract issue: failed — …`** on HUD. + +## Notes + +- Prototype flow is **issue then clear** the bound encounter. Issuing after the pocket is already cleared leaves the contract **`active`** until a future matching clear (NEO-149 v1 edge case). +- Fixed seed **`prototype_contract_dev_seed`** — repeat re-issue after complete requires **NEO-154** capstone (seed rotation / fixture reset). +- Encounter + quest HUD regression: [NEO-110 manual QA](NEO-110.md), [NEO-122 manual QA](NEO-122.md). + +## Acceptance + +- [ ] Steps 1–5 completable in one session without Bruno/curl. +- [ ] Active contract shows encounter objective id after **Shift+C** issue. +- [ ] Completed contract shows reward summary line on HUD after pocket clear. diff --git a/docs/plans/NEO-153-implementation-plan.md b/docs/plans/NEO-153-implementation-plan.md index 7e4f666..0d392b8 100644 --- a/docs/plans/NEO-153-implementation-plan.md +++ b/docs/plans/NEO-153-implementation-plan.md @@ -52,8 +52,16 @@ Player can **issue** and **track** an active contract in Godot without Bruno — ## Acceptance criteria checklist -- [ ] Active contract visible with encounter objective id after issue. -- [ ] Completed contract shows reward summary line on HUD. +- [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. +- **`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` (5 cases) — GdUnit green. +- **Docs:** `client/README.md` contract HUD section; `docs/manual-qa/NEO-153.md`. ## Technical approach From 051aae695b234e6b132b9937fc5c7d267088c492 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 14:51:03 -0400 Subject: [PATCH 06/11] NEO-153: add code review for contract HUD client slice. --- docs/reviews/2026-06-28-NEO-153.md | 82 ++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/reviews/2026-06-28-NEO-153.md diff --git a/docs/reviews/2026-06-28-NEO-153.md b/docs/reviews/2026-06-28-NEO-153.md new file mode 100644 index 0000000..5fd235e --- /dev/null +++ b/docs/reviews/2026-06-28-NEO-153.md @@ -0,0 +1,82 @@ +# Code review — NEO-153 client contract issue + progress HUD + +**Date:** 2026-06-28 +**Scope:** Branch `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` — commits `9d756c2`..`365e577` (6 commits) +**Base:** `main` +**Issue:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — E7M4-10 client contract issue + progress HUD (Godot) + +## Verdict + +**Request changes** + +## Summary + +This branch delivers the E7M4-10 Godot client slice: `contract_client.gd` (GET list + POST issue with separate sync/issue busy guards), `contract_hud_controller.gd` (Shift+C prototype issue, active/completed label, deny copy, in-session reward transition), thin `main.gd` wiring, scene labels, GdUnit coverage (12 cases), `client/README.md`, and manual QA. The implementation closely follows NEO-122/NEO-131 quest HUD patterns and pairs NEO-151 server HTTP correctly. + +Overall risk is moderate: client-only, but **`_reapply_issue_contract_patch_if_needed` always merges a stale issue patch over authoritative GET snapshots**, which can regress `ContractActiveLabel` to **`active`** after the server reports **`completed`** — a primary acceptance-criteria failure. Docs tracking (E7M4 backlog AC, E7.M4 module page, alignment register) is not updated for the landed client slice. GdUnit contract tests pass locally. + +## Documentation checked + +| Document | Result | +|----------|--------| +| `docs/plans/NEO-153-implementation-plan.md` | **Matches** — shipped reconciliation aligns with code; AC checklist marked complete; kickoff decisions (separate controller, fixed dev seed, encounter-complete refresh) reflected | +| `docs/plans/E7M4-pre-production-backlog.md` | **Partially matches** — E7M4-10 section still has unchecked AC `[ ]`; no **Landed (NEO-153)** note (contrast E7M4-09 / NEO-152) | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Partially matches** — server slices through NEO-152 documented; **no client HUD line** for E7M4-10 / NEO-153 (NEO-122 precedent on E7.M1) | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M4 row ends at E7M4-09 / NEO-152; **E7M4-10 / NEO-153 not in body or trailing refs** | +| `docs/decomposition/modules/contracts.md` | **N/A** — policy doc; no client HUD surface | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays server snapshots; no client-side issue authority | +| `docs/manual-qa/NEO-153.md` | **Matches** — Godot-only checklist; edge cases documented | +| `client/README.md` | **Matches** — contract HUD subsection with keys, refresh triggers, server deps | + +Register/tracking table and E7.M4 module page **should be updated** after merge (same pattern as NEO-152 follow-up). + +## Blocking issues + +1. **Stale `_issue_contract_patch` overwrites completed contract row** — In `contract_hud_controller.gd`, `_on_contracts_received` calls `_reapply_issue_contract_patch_if_needed()` after copying the GET snapshot. That helper **always** merges `_issue_contract_patch` (set on successful issue with `status: active`) and **never clears** it. On encounter-complete refresh, the server returns `status: completed`, but the merge replaces that row with the stale active patch. `ContractActiveLabel` then shows **`active`** instead of **`completed`** (AC failure). Quest HUD avoids this in `_reapply_accept_quest_patch_if_needed`: merge only while the server row is still `not_started`, else **`_accept_quest_patch = {}`**. Contract HUD should adopt the same pattern — e.g. merge only when the instance id is **missing** from the GET snapshot, otherwise clear the patch: + +```gdscript +func _reapply_issue_contract_patch_if_needed() -> void: + if _issue_contract_patch.is_empty(): + return + var patch_id := str(_issue_contract_patch.get("contractInstanceId", "")).strip_edges() + if patch_id.is_empty(): + return + var row: Dictionary = ContractClient.contract_row(patch_id, _last_snapshot) + if row.is_empty(): + _last_snapshot = ContractClient.merge_contract_row_into_snapshot( + _last_snapshot, _issue_contract_patch + ) + else: + _issue_contract_patch = {} +``` + +Add a GdUnit case: issue patch present → second `_on_contracts_received` with **completed** snapshot → active label contains **`completed`**, not **`active`**. + +## Suggestions + +1. **Update decomposition docs for landed client slice** — After fixing the patch bug: add **E7M4-10 / NEO-153** to `E7_M4_ContractMissionGenerator.md` (client HUD + README/manual QA links, mirroring NEO-151/152 server lines); extend E7.M4 row in `documentation_and_implementation_alignment.md` with `[NEO-153 plan]` in trailing refs; check E7M4-10 AC and add **Landed (NEO-153)** in `E7M4-pre-production-backlog.md`. + +2. **Faction-standing reward re-paint (plan § Open questions)** — Plan adopted re-render on `definitions_ready` **and** faction standing received when summary is cached. Controller connects `definitions_ready` but **`_faction_standing_client` is stored and never wired** (rep grant display names would stay raw if a future contract bundle includes `reputationGrants`). Low impact for prototype bundle (no rep), but align with plan or note deferral in plan reconciliation. + +3. **Split or label the `chore:` commit** — `fea9928` (warnings-as-errors, dotnet format CI/hooks) is unrelated to NEO-153. Harmless on branch, but consider a separate PR or explicit note in merge description so reviewers do not conflate infra with HUD work. + +## Nits + +- Nit: `_faction_standing_client` field is assigned in `setup()` but unused — only `FactionStandingClient.display_name_for` static is called; remove the field or connect standing signals per suggestion 2. +- Nit: Branch includes a one-line server `CompareOrdinal` sort tie-break in `InMemoryContractInstanceStore.cs` — fine as hygiene; no NEO-153 client impact. +- Nit: `test_reward_label_populates_on_active_to_completed_transition` bypasses the issue-patch path; extend after blocking fix so regression is covered. + +## Verification + +```bash +# GdUnit (NEO-153 suites) +cd client && godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/contract_client_test.gd -a res://test/contract_hud_controller_test.gd + +# Full client suite (CI parity) +cd client && godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test + +# Server (unchanged behavior; optional) +dotnet test NeonSprawl.sln +``` + +**Manual (required before merge):** `docs/manual-qa/NEO-153.md` steps 1–5 — confirm **`ContractActiveLabel`** shows **`completed`** (not **`active`**) after pocket clear and reward line appears. From 6a48315ba56050d75c20c9707210b0eaf3f11b0b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 14:56:12 -0400 Subject: [PATCH 07/11] NEO-153: fix stale issue patch merge and address review findings. Clear _issue_contract_patch when GET includes the instance row (NEO-122 precedent); wire faction_standing_received for reward re-paint; add regression test; update E7M4 decomposition docs and review strikethroughs. --- client/scripts/contract_hud_controller.gd | 23 +++++++++-- client/test/contract_hud_controller_test.gd | 15 +++++++ .../modules/E7_M4_ContractMissionGenerator.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E7M4-pre-production-backlog.md | 8 ++-- docs/plans/NEO-153-implementation-plan.md | 6 +-- docs/reviews/2026-06-28-NEO-153.md | 41 ++++++------------- 7 files changed, 58 insertions(+), 39 deletions(-) diff --git a/client/scripts/contract_hud_controller.gd b/client/scripts/contract_hud_controller.gd index ddd452e..76655df 100644 --- a/client/scripts/contract_hud_controller.gd +++ b/client/scripts/contract_hud_controller.gd @@ -62,6 +62,11 @@ func setup( _contract_client.connect("contract_issue_failed", Callable(self, "_on_issue_failed")) if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"): _item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready")) + if is_instance_valid(_faction_standing_client): + if _faction_standing_client.has_signal("faction_standing_received"): + _faction_standing_client.connect( + "faction_standing_received", Callable(self, "_on_faction_standing_received") + ) _render_active_label() _render_issue_feedback_label(ISSUE_IDLE_HINT) _render_reward_label() @@ -155,6 +160,11 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: _render_reward_label() +func _on_faction_standing_received(_snapshot: Dictionary) -> void: + if not _last_reward_instance_id.is_empty(): + _render_reward_label() + + func _mark_contract_active_in_session(instance_id: String) -> void: var iid := instance_id.strip_edges() if not iid.is_empty(): @@ -192,9 +202,16 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: func _reapply_issue_contract_patch_if_needed() -> void: if _issue_contract_patch.is_empty(): return - _last_snapshot = ContractClient.merge_contract_row_into_snapshot( - _last_snapshot, _issue_contract_patch - ) + var patch_id := str(_issue_contract_patch.get("contractInstanceId", "")).strip_edges() + if patch_id.is_empty(): + return + var row: Dictionary = ContractClient.contract_row(patch_id, _last_snapshot) + if row.is_empty(): + _last_snapshot = ContractClient.merge_contract_row_into_snapshot( + _last_snapshot, _issue_contract_patch + ) + else: + _issue_contract_patch = {} func _refresh_economy_hud() -> void: diff --git a/client/test/contract_hud_controller_test.gd b/client/test/contract_hud_controller_test.gd index 2d93398..3558b90 100644 --- a/client/test/contract_hud_controller_test.gd +++ b/client/test/contract_hud_controller_test.gd @@ -218,3 +218,18 @@ func test_reward_label_populates_on_active_to_completed_transition() -> void: # Assert assert_str(built["reward_label"].text).contains("Contract rewards:") assert_str(built["reward_label"].text).contains("Salvage +15 XP") + + +func test_completed_get_clears_stale_issue_patch_on_active_label() -> void: + # Arrange — simulate successful issue patch still held when encounter-complete GET arrives + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + var issue_row: Dictionary = (_active_snapshot()["contracts"] as Array)[0] as Dictionary + built["controller"].set("_issue_contract_patch", issue_row.duplicate(true)) + built["controller"].set("_tracked_instance_id", INSTANCE_ID) + # Act + built["controller"].call("_on_contracts_received", _completed_snapshot()) + # Assert + assert_str(built["active_label"].text).contains("completed") + assert_str(built["active_label"].text).not_contains(": active —") diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index 7bd0fd5..958e37a 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -71,6 +71,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Contract telemetry hooks (NEO-152):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** anchor sites in generator/completion ops + shared reward router rollback; [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152); plan [NEO-152](../../plans/NEO-152-implementation-plan.md). +**Contract client HUD (NEO-153):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **`ContractActiveLabel`** / **`ContractIssueFeedbackLabel`** / **`ContractRewardDeliveryLabel`** — **Shift+C** prototype issue, encounter objective display, completion reward transition; [client README — Contract issue + progress HUD (NEO-153)](../../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../../manual-qa/NEO-153.md); plan [NEO-153](../../plans/NEO-153-implementation-plan.md). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 87ac194..b8e973b 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | -| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-08 contract HTTP landed ([NEO-151](https://linear.app/neon-sprawl/issue/NEO-151)):** `POST …/contracts/issue` + `GET …/contracts` — issue envelope, active + recent completed list, `completionRewardSummary` on completed rows ([NEO-151 plan](../../plans/NEO-151-implementation-plan.md)); [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151); Bruno `bruno/neon-sprawl-server/contracts/`. **E7M4-09 telemetry landed ([NEO-152](https://linear.app/neon-sprawl/issue/NEO-152)):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** hook sites ([NEO-152 plan](../../plans/NEO-152-implementation-plan.md)); [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), [NEO-151](../../plans/NEO-151-implementation-plan.md), [NEO-152](../../plans/NEO-152-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-08 contract HTTP landed ([NEO-151](https://linear.app/neon-sprawl/issue/NEO-151)):** `POST …/contracts/issue` + `GET …/contracts` — issue envelope, active + recent completed list, `completionRewardSummary` on completed rows ([NEO-151 plan](../../plans/NEO-151-implementation-plan.md)); [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151); Bruno `bruno/neon-sprawl-server/contracts/`. **E7M4-09 telemetry landed ([NEO-152](https://linear.app/neon-sprawl/issue/NEO-152)):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** hook sites ([NEO-152 plan](../../plans/NEO-152-implementation-plan.md)); [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152). **E7M4-10 client HUD landed ([NEO-153](https://linear.app/neon-sprawl/issue/NEO-153)):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **Shift+C** issue + contract progress/reward labels ([NEO-153 plan](../../plans/NEO-153-implementation-plan.md)); [client README — Contract issue + progress HUD (NEO-153)](../../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../../manual-qa/NEO-153.md). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), [NEO-151](../../plans/NEO-151-implementation-plan.md), [NEO-152](../../plans/NEO-152-implementation-plan.md), [NEO-153](../../plans/NEO-153-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/plans/E7M4-pre-production-backlog.md b/docs/plans/E7M4-pre-production-backlog.md index db96553..6c46a8a 100644 --- a/docs/plans/E7M4-pre-production-backlog.md +++ b/docs/plans/E7M4-pre-production-backlog.md @@ -333,10 +333,12 @@ Working backlog for **Epic 7 — Slice 4** ([contract generator](../decompositio **Acceptance criteria** -- [ ] Active contract visible with encounter objective id after issue. -- [ ] Completed contract shows reward summary line on HUD. +- [x] Active contract visible with encounter objective id after issue. +- [x] Completed contract shows reward summary line on HUD. -**Server counterpart:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152). +**Landed (NEO-153):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **Shift+C** prototype issue, contract HUD labels; [client README — Contract issue + progress HUD (NEO-153)](../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../manual-qa/NEO-153.md); plan [NEO-153](../../plans/NEO-153-implementation-plan.md). + +**Server counterpart:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152). --- diff --git a/docs/plans/NEO-153-implementation-plan.md b/docs/plans/NEO-153-implementation-plan.md index 0d392b8..4a08de3 100644 --- a/docs/plans/NEO-153-implementation-plan.md +++ b/docs/plans/NEO-153-implementation-plan.md @@ -58,9 +58,9 @@ Player can **issue** and **track** an active contract in Godot without Bruno — ## 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. +- **`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` (5 cases) — GdUnit green. +- **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 @@ -222,4 +222,4 @@ No new **C#** tests (client-only; server covered by **`ContractApiTests`**). No | 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` | +| 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` | diff --git a/docs/reviews/2026-06-28-NEO-153.md b/docs/reviews/2026-06-28-NEO-153.md index 5fd235e..a72bb1f 100644 --- a/docs/reviews/2026-06-28-NEO-153.md +++ b/docs/reviews/2026-06-28-NEO-153.md @@ -3,11 +3,12 @@ **Date:** 2026-06-28 **Scope:** Branch `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` — commits `9d756c2`..`365e577` (6 commits) **Base:** `main` -**Issue:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — E7M4-10 client contract issue + progress HUD (Godot) +**Issue:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — E7M4-10 client contract issue + progress HUD (Godot) +**Follow-up:** Review findings addressed in follow-up commits (issue-patch merge fix, docs, faction-standing re-paint, regression test). ## Verdict -**Request changes** +~~**Request changes**~~ **Addressed** — blocking patch bug fixed; decomposition docs updated; follow-up tests green. ## Summary @@ -20,9 +21,9 @@ Overall risk is moderate: client-only, but **`_reapply_issue_contract_patch_if_n | Document | Result | |----------|--------| | `docs/plans/NEO-153-implementation-plan.md` | **Matches** — shipped reconciliation aligns with code; AC checklist marked complete; kickoff decisions (separate controller, fixed dev seed, encounter-complete refresh) reflected | -| `docs/plans/E7M4-pre-production-backlog.md` | **Partially matches** — E7M4-10 section still has unchecked AC `[ ]`; no **Landed (NEO-153)** note (contrast E7M4-09 / NEO-152) | -| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Partially matches** — server slices through NEO-152 documented; **no client HUD line** for E7M4-10 / NEO-153 (NEO-122 precedent on E7.M1) | -| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M4 row ends at E7M4-09 / NEO-152; **E7M4-10 / NEO-153 not in body or trailing refs** | +| `docs/plans/E7M4-pre-production-backlog.md` | ~~**Partially matches**~~ **Matches** — E7M4-10 AC checked; **Landed (NEO-153)** note added **Done.** | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | ~~**Partially matches**~~ **Matches** — **Contract client HUD (NEO-153)** line added **Done.** | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches**~~ **Matches** — E7M4-10 / NEO-153 in E7.M4 row + trailing refs **Done.** | | `docs/decomposition/modules/contracts.md` | **N/A** — policy doc; no client HUD surface | | `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays server snapshots; no client-side issue authority | | `docs/manual-qa/NEO-153.md` | **Matches** — Godot-only checklist; edge cases documented | @@ -32,39 +33,21 @@ Register/tracking table and E7.M4 module page **should be updated** after merge ## Blocking issues -1. **Stale `_issue_contract_patch` overwrites completed contract row** — In `contract_hud_controller.gd`, `_on_contracts_received` calls `_reapply_issue_contract_patch_if_needed()` after copying the GET snapshot. That helper **always** merges `_issue_contract_patch` (set on successful issue with `status: active`) and **never clears** it. On encounter-complete refresh, the server returns `status: completed`, but the merge replaces that row with the stale active patch. `ContractActiveLabel` then shows **`active`** instead of **`completed`** (AC failure). Quest HUD avoids this in `_reapply_accept_quest_patch_if_needed`: merge only while the server row is still `not_started`, else **`_accept_quest_patch = {}`**. Contract HUD should adopt the same pattern — e.g. merge only when the instance id is **missing** from the GET snapshot, otherwise clear the patch: - -```gdscript -func _reapply_issue_contract_patch_if_needed() -> void: - if _issue_contract_patch.is_empty(): - return - var patch_id := str(_issue_contract_patch.get("contractInstanceId", "")).strip_edges() - if patch_id.is_empty(): - return - var row: Dictionary = ContractClient.contract_row(patch_id, _last_snapshot) - if row.is_empty(): - _last_snapshot = ContractClient.merge_contract_row_into_snapshot( - _last_snapshot, _issue_contract_patch - ) - else: - _issue_contract_patch = {} -``` - -Add a GdUnit case: issue patch present → second `_on_contracts_received` with **completed** snapshot → active label contains **`completed`**, not **`active`**. +1. ~~**Stale `_issue_contract_patch` overwrites completed contract row**~~ — **Done.** `_reapply_issue_contract_patch_if_needed` now merges only when the instance id is **missing** from the GET snapshot; otherwise clears `_issue_contract_patch` (NEO-122 accept-patch precedent). Regression test `test_completed_get_clears_stale_issue_patch_on_active_label` in `contract_hud_controller_test.gd`. ## Suggestions -1. **Update decomposition docs for landed client slice** — After fixing the patch bug: add **E7M4-10 / NEO-153** to `E7_M4_ContractMissionGenerator.md` (client HUD + README/manual QA links, mirroring NEO-151/152 server lines); extend E7.M4 row in `documentation_and_implementation_alignment.md` with `[NEO-153 plan]` in trailing refs; check E7M4-10 AC and add **Landed (NEO-153)** in `E7M4-pre-production-backlog.md`. +1. ~~**Update decomposition docs for landed client slice**~~ — **Done.** `E7_M4_ContractMissionGenerator.md`, `documentation_and_implementation_alignment.md`, `E7M4-pre-production-backlog.md` updated. -2. **Faction-standing reward re-paint (plan § Open questions)** — Plan adopted re-render on `definitions_ready` **and** faction standing received when summary is cached. Controller connects `definitions_ready` but **`_faction_standing_client` is stored and never wired** (rep grant display names would stay raw if a future contract bundle includes `reputationGrants`). Low impact for prototype bundle (no rep), but align with plan or note deferral in plan reconciliation. +2. ~~**Faction-standing reward re-paint (plan § Open questions)**~~ — **Done.** `contract_hud_controller.gd` connects `faction_standing_received` to re-render reward label when a cached summary exists. -3. **Split or label the `chore:` commit** — `fea9928` (warnings-as-errors, dotnet format CI/hooks) is unrelated to NEO-153. Harmless on branch, but consider a separate PR or explicit note in merge description so reviewers do not conflate infra with HUD work. +3. **Split or label the `chore:` commit** — `fea9928` (warnings-as-errors, dotnet format CI/hooks) is unrelated to NEO-153. Harmless on branch, but consider a separate PR or explicit note in merge description so reviewers do not conflate infra with HUD work. *(Deferred — merge-description note only.)* ## Nits -- Nit: `_faction_standing_client` field is assigned in `setup()` but unused — only `FactionStandingClient.display_name_for` static is called; remove the field or connect standing signals per suggestion 2. +- ~~Nit: `_faction_standing_client` field is assigned in `setup()` but unused~~ — **Done.** wired for reward label re-paint. - Nit: Branch includes a one-line server `CompareOrdinal` sort tie-break in `InMemoryContractInstanceStore.cs` — fine as hygiene; no NEO-153 client impact. -- Nit: `test_reward_label_populates_on_active_to_completed_transition` bypasses the issue-patch path; extend after blocking fix so regression is covered. +- ~~Nit: `test_reward_label_populates_on_active_to_completed_transition` bypasses the issue-patch path; extend after blocking fix so regression is covered.~~ — **Done.** `test_completed_get_clears_stale_issue_patch_on_active_label` added. ## Verification From b9ffd7553b875f92065bcc6b7cf94cbb15c0902d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 17:44:59 -0400 Subject: [PATCH 08/11] NEO-153: fix gdlint pre-push for contract HUD wiring. Extract contract HUD main wiring, dedupe deny copy, trim main.gd, and add wiring tests. --- client/scripts/contract_hud_controller.gd | 48 +++++----- client/scripts/contract_hud_main_wiring.gd | 43 +++++++++ .../scripts/contract_hud_main_wiring.gd.uid | 1 + client/scripts/interactable_world_builder.gd | 7 ++ client/scripts/main.gd | 95 ++++++------------- client/test/contract_hud_main_wiring_test.gd | 27 ++++++ .../test/contract_hud_main_wiring_test.gd.uid | 1 + 7 files changed, 132 insertions(+), 90 deletions(-) create mode 100644 client/scripts/contract_hud_main_wiring.gd create mode 100644 client/scripts/contract_hud_main_wiring.gd.uid create mode 100644 client/test/contract_hud_main_wiring_test.gd create mode 100644 client/test/contract_hud_main_wiring_test.gd.uid diff --git a/client/scripts/contract_hud_controller.gd b/client/scripts/contract_hud_controller.gd index 76655df..89f3c74 100644 --- a/client/scripts/contract_hud_controller.gd +++ b/client/scripts/contract_hud_controller.gd @@ -10,6 +10,13 @@ const PROTOTYPE_CONTRACT_SEED_BUCKET := "prototype_contract_dev_seed" const ISSUE_IDLE_HINT := "Contract issue: — (Shift+C prototype)" const ISSUE_SENDING_HINT := "Contract issue: sending…" const REWARD_HEADER := "Contract rewards:" +const ISSUE_DENY_COPY := { + "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", +} var _contract_client: Node = null var _item_defs_client: Node = null @@ -84,13 +91,14 @@ func try_issue_key_input(event: InputEvent) -> bool: if not event is InputEventKey: return false var k := event as InputEventKey - if not k.pressed or k.echo or not k.shift_pressed: - return false - if k.physical_keycode != KEY_C and k.keycode != KEY_C: - return false - if not is_instance_valid(_contract_client): - return false - if not _contract_client.has_method("request_issue"): + if ( + not k.pressed + or k.echo + or not k.shift_pressed + or (k.physical_keycode != KEY_C and k.keycode != KEY_C) + or not is_instance_valid(_contract_client) + or not _contract_client.has_method("request_issue") + ): return false if bool(_contract_client.call("is_issue_busy")): return true @@ -274,21 +282,13 @@ func _render_reward_label() -> void: func _format_issue_deny(reason_code: String) -> String: - match reason_code: - "active_contract_exists": - return "Contract issue: denied — active contract already issued" - "no_eligible_template": - return "Contract issue: denied — no eligible template (standing/band)" - "economy_cap_exceeded": - return "Contract issue: denied — economy cap exceeded" - "invalid_reward_bundle": - return "Contract issue: denied — invalid reward bundle" - "unknown_template": - return "Contract issue: denied — unknown template" - "": - return "Contract issue: denied (no reasonCode)" - _: - return "Contract issue: denied — %s" % reason_code + var rc := reason_code.strip_edges() + if rc.is_empty(): + return "Contract issue: denied (no reasonCode)" + var copy_variant: Variant = ISSUE_DENY_COPY.get(rc, null) + if copy_variant is String: + return copy_variant as String + return "Contract issue: denied — %s" % rc func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: @@ -325,7 +325,9 @@ func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: var amount: int = int(grant.get("amount", 0)) if faction_id.is_empty() or amount <= 0: continue - lines.append(" %s +%d rep" % [FactionStandingClient.display_name_for(faction_id), amount]) + lines.append( + " %s +%d rep" % [FactionStandingClient.display_name_for(faction_id), amount] + ) if lines.is_empty(): lines.append(" (no grants)") return lines diff --git a/client/scripts/contract_hud_main_wiring.gd b/client/scripts/contract_hud_main_wiring.gd new file mode 100644 index 0000000..d90132d --- /dev/null +++ b/client/scripts/contract_hud_main_wiring.gd @@ -0,0 +1,43 @@ +extends RefCounted + +## NEO-153: thin main.gd wiring (keeps main.gd under gdlint max-file-lines). + +const ContractHudController := preload("res://scripts/contract_hud_controller.gd") + + +static func setup_on( + main: Node, + hud_root: Node, + contract_client: Node, + apply_http_config: Callable, + item_defs_client: Node = null, + inventory_client: Node = null, + skill_progression_client: Node = null, + faction_standing_client: Node = null +) -> Node: + var controller: Node = ContractHudController.new() + main.add_child(controller) + controller.call( + "setup", + contract_client, + hud_root.get_node("ContractActiveLabel"), + hud_root.get_node("ContractIssueFeedbackLabel"), + hud_root.get_node("ContractRewardDeliveryLabel"), + apply_http_config, + item_defs_client, + inventory_client, + skill_progression_client, + faction_standing_client + ) + return controller + + +static func request_refresh(controller: Node) -> void: + if is_instance_valid(controller): + controller.call("request_contract_refresh") + + +static func try_issue_key_input(controller: Node, event: InputEvent) -> bool: + if not is_instance_valid(controller): + return false + return bool(controller.call("try_issue_key_input", event)) diff --git a/client/scripts/contract_hud_main_wiring.gd.uid b/client/scripts/contract_hud_main_wiring.gd.uid new file mode 100644 index 0000000..43ced63 --- /dev/null +++ b/client/scripts/contract_hud_main_wiring.gd.uid @@ -0,0 +1 @@ +uid://bneo153mainwire01 diff --git a/client/scripts/interactable_world_builder.gd b/client/scripts/interactable_world_builder.gd index fe94a30..0f8d5fe 100644 --- a/client/scripts/interactable_world_builder.gd +++ b/client/scripts/interactable_world_builder.gd @@ -120,3 +120,10 @@ static func _add_prop_visual( prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0) prop_mesh.material_override = prop_mat body.add_child(prop_mesh) + + +static func set_collision_shapes_disabled(root: Node, disabled: bool) -> void: + if root is CollisionShape3D: + (root as CollisionShape3D).disabled = disabled + for c in root.get_children(): + set_collision_shapes_disabled(c, disabled) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index f542edb..8397b3b 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -57,7 +57,8 @@ const BREACH_GIG_ID := "breach" const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 const QuestHudController := preload("res://scripts/quest_hud_controller.gd") -const ContractHudController := preload("res://scripts/contract_hud_controller.gd") +const ContractHudMainWiring := preload("res://scripts/contract_hud_main_wiring.gd") +const InteractableWorldBuilder := preload("res://scripts/interactable_world_builder.gd") ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -139,9 +140,6 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel") @onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") @onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") -@onready var _contract_active_label: Label = _hud_root.get_node("ContractActiveLabel") -@onready var _contract_issue_feedback_label: Label = _hud_root.get_node("ContractIssueFeedbackLabel") -@onready var _contract_reward_delivery_label: Label = _hud_root.get_node("ContractRewardDeliveryLabel") @onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @@ -213,7 +211,16 @@ func _ready() -> void: _setup_gig_progression_sync() _setup_encounter_progress_sync() _setup_quest_progress_sync() - _setup_contract_hud() + _contract_hud = ContractHudMainWiring.setup_on( + self, + _hud_root, + _contract_client, + Callable(self, "_apply_authority_http_config_to_client"), + _item_defs_client, + _inventory_client, + _skill_progression_client, + _faction_standing_client + ) _setup_gather_interact_feedback() _setup_craft_ui() @@ -756,7 +763,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void: if str(row.get("state", "")) == "completed": _request_inventory_refresh() _request_quest_progress_refresh() - _request_contract_refresh() + ContractHudMainWiring.request_refresh(_contract_hud) func _on_encounter_progress_sync_failed(reason: String) -> void: @@ -893,35 +900,6 @@ func _try_quest_accept_key_input(event: InputEvent) -> bool: return false -func _setup_contract_hud() -> void: - # NEO-153: contract issue + progress HUD; boot hydrate + refresh after issue/encounter clear. - _contract_hud = ContractHudController.new() - add_child(_contract_hud) - _contract_hud.call( - "setup", - _contract_client, - _contract_active_label, - _contract_issue_feedback_label, - _contract_reward_delivery_label, - Callable(self, "_apply_authority_http_config_to_client"), - _item_defs_client, - _inventory_client, - _skill_progression_client, - _faction_standing_client - ) - - -func _request_contract_refresh() -> void: - if is_instance_valid(_contract_hud): - _contract_hud.call("request_contract_refresh") - - -func _try_contract_issue_key_input(event: InputEvent) -> bool: - if is_instance_valid(_contract_hud): - return bool(_contract_hud.call("try_issue_key_input", event)) - return false - - func _render_gather_feedback_label(text: String = "Gather: —") -> void: if is_instance_valid(_gather_feedback_label): _gather_feedback_label.text = text @@ -1374,27 +1352,19 @@ func _render_player_combat_hp_label() -> void: func _render_npc_combat_hud_labels() -> void: var rows: Dictionary = _resolve_hud_npc_rows() - _render_npc_state_label(rows) - _render_telegraph_label(rows) + if is_instance_valid(_npc_state_label): + _npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label( + rows, _npc_runtime_sync_error + ) + if is_instance_valid(_telegraph_label): + _telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label( + rows, _npc_runtime_hud_state, _npc_runtime_sync_error + ) _npc_combat_hud_needs_tick = NpcCombatHudHelpers.has_interpolated_telegraph_display( rows, _npc_runtime_hud_state ) -func _render_npc_state_label(rows: Dictionary) -> void: - if not is_instance_valid(_npc_state_label): - return - _npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(rows, _npc_runtime_sync_error) - - -func _render_telegraph_label(rows: Dictionary) -> void: - if not is_instance_valid(_telegraph_label): - return - _telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label( - rows, _npc_runtime_hud_state, _npc_runtime_sync_error - ) - - func _on_move_rejected(reason_code: String) -> void: _authority_force_snap_next = true # Rejected stream: server state may differ; next snap is forced. @@ -1444,7 +1414,7 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool: return true if _try_quest_accept_key_input(event): return true - if _try_contract_issue_key_input(event): + if ContractHudMainWiring.try_issue_key_input(_contract_hud, event): return true if _try_inventory_refresh_input(event): return true @@ -1580,7 +1550,7 @@ func _dev_toggle_obstacle_smoke_deferred() -> void: _dev_saved_obstacle_mask = co.collision_mask co.collision_layer = 0 co.collision_mask = 0 - _dev_collision_shapes_set_disabled(obstacle, true) + InteractableWorldBuilder.set_collision_shapes_disabled(obstacle, true) obstacle.process_mode = Node.PROCESS_MODE_DISABLED obstacle.visible = false if obstacle.get_parent() == _nav_region: @@ -1594,7 +1564,7 @@ func _dev_toggle_obstacle_smoke_deferred() -> void: if co2 != null and _dev_saved_obstacle_layer >= 0: co2.collision_layer = _dev_saved_obstacle_layer co2.collision_mask = _dev_saved_obstacle_mask - _dev_collision_shapes_set_disabled(obstacle, false) + InteractableWorldBuilder.set_collision_shapes_disabled(obstacle, false) call_deferred("_dev_rebake_nav_after_obstacle_toggle_deferred") @@ -1610,28 +1580,19 @@ func _dev_rebake_nav_after_obstacle_toggle_deferred() -> void: _player.sync_navigation_agent_after_map_rebuild(_nav_region) -func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void: - if root is CollisionShape3D: - (root as CollisionShape3D).disabled = disabled - for c in root.get_children(): - _dev_collision_shapes_set_disabled(c, disabled) - - func _on_interactables_catalog_ready(descriptors: Array) -> void: _interactables_catalog = descriptors.duplicate(true) - var groups: Variant = load("res://scripts/interactable_world_builder.gd").call( - "build_from_catalog", descriptors, _interactables_root, _radius_preview + var groups: Variant = InteractableWorldBuilder.build_from_catalog( + descriptors, _interactables_root, _radius_preview ) if _radius_preview.has_method("setup_glow_groups"): _radius_preview.call("setup_glow_groups", groups as Array) func _on_interactables_catalog_failed(reason: String) -> void: - # `InteractablesCatalogClient` already `push_error`s; this hook exists for wiring clarity. - var readme := "Interaction + range preview (NEO-9 + NEO-25)" push_warning( ( - "Interactables catalog failed (%s) — start the game server first; see client README (%s)." - % [reason, readme] + "Interactables catalog failed (%s) — start the game server first; see client README." + % reason ) ) diff --git a/client/test/contract_hud_main_wiring_test.gd b/client/test/contract_hud_main_wiring_test.gd new file mode 100644 index 0000000..4d5e29e --- /dev/null +++ b/client/test/contract_hud_main_wiring_test.gd @@ -0,0 +1,27 @@ +extends GdUnitTestSuite + +## NEO-153: thin main wiring helpers. + +const ContractHudMainWiring := preload("res://scripts/contract_hud_main_wiring.gd") +const InteractableWorldBuilder := preload("res://scripts/interactable_world_builder.gd") + + +func test_try_issue_key_input_returns_false_without_controller() -> void: + # Arrange + var event := InputEventKey.new() + # Act + var handled: bool = ContractHudMainWiring.try_issue_key_input(null, event) + # Assert + assert_bool(handled).is_false() + + +func test_set_collision_shapes_disabled_recurses_tree() -> void: + # Arrange + var root := Node3D.new() + var shape := CollisionShape3D.new() + root.add_child(shape) + # Act + InteractableWorldBuilder.set_collision_shapes_disabled(root, true) + # Assert + assert_bool(shape.disabled).is_true() + root.free() diff --git a/client/test/contract_hud_main_wiring_test.gd.uid b/client/test/contract_hud_main_wiring_test.gd.uid new file mode 100644 index 0000000..0df6e6e --- /dev/null +++ b/client/test/contract_hud_main_wiring_test.gd.uid @@ -0,0 +1 @@ +uid://bneo153mwtest01 From 8289f2076da68d8a5f28a59eb51c421adefc3f28 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 17:45:21 -0400 Subject: [PATCH 09/11] NEO-153: gdformat contract_client and test merge helper. --- client/scripts/contract_client.gd | 5 ++++- client/test/contract_client_test.gd | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/client/scripts/contract_client.gd b/client/scripts/contract_client.gd index fc129b1..0126ade 100644 --- a/client/scripts/contract_client.gd +++ b/client/scripts/contract_client.gd @@ -163,7 +163,10 @@ static func merge_contract_row_into_snapshot(snapshot: Dictionary, row: Dictiona var replaced := false for i in contracts.size(): var existing: Variant = contracts[i] - if existing is Dictionary and str((existing as Dictionary).get("contractInstanceId", "")) == iid: + if ( + existing is Dictionary + and str((existing as Dictionary).get("contractInstanceId", "")) == iid + ): contracts[i] = row.duplicate(true) replaced = true break diff --git a/client/test/contract_client_test.gd b/client/test/contract_client_test.gd index c64bc7c..6bb4041 100644 --- a/client/test/contract_client_test.gd +++ b/client/test/contract_client_test.gd @@ -219,3 +219,25 @@ func test_issue_deny_emits_result_with_reason_code() -> void: await assert_signal(c).is_emitted("contract_issue_result_received", any()) assert_bool(_issue_capture.get("issued", true)).is_false() assert_that(str(_issue_capture.get("reasonCode", ""))).is_equal("economy_cap_exceeded") + + +func test_merge_contract_row_replaces_matching_instance_id() -> void: + # Arrange + var snapshot := { + "schemaVersion": 1, + "playerId": "dev-local-1", + "contracts": + [ + { + "contractInstanceId": "ci_old", + "status": "active", + } + ], + } + var row := {"contractInstanceId": "ci_old", "status": "completed"} + # Act + var merged: Dictionary = ContractClient.merge_contract_row_into_snapshot(snapshot, row) + # Assert + var contracts: Array = merged.get("contracts", []) as Array + assert_that(contracts.size()).is_equal(1) + assert_that(str((contracts[0] as Dictionary).get("status", ""))).is_equal("completed") From ad29b4e930fe3f74a9f8885b94a39f0fa266bb30 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 17:48:59 -0400 Subject: [PATCH 10/11] NEO-153: fix completion rewards when first GET is already completed. Match quest HUD transition detection so issue-then-complete races still populate reward lines. --- client/scripts/contract_hud_controller.gd | 2 +- client/test/contract_hud_controller_test.gd | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/client/scripts/contract_hud_controller.gd b/client/scripts/contract_hud_controller.gd index 89f3c74..21e87d0 100644 --- a/client/scripts/contract_hud_controller.gd +++ b/client/scripts/contract_hud_controller.gd @@ -198,7 +198,7 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: _tracked_instance_id = iid var prev_status := str(_previous_status_by_instance.get(iid, "")) var can_detect := not boot_seed or bool(_contract_was_active_in_session.get(iid, false)) - if can_detect and prev_status == "active" and new_status == "completed": + if can_detect and prev_status != "completed" and new_status == "completed": var summary := ContractClient.completion_reward_summary(iid, snapshot) if not summary.is_empty(): _last_reward_instance_id = iid diff --git a/client/test/contract_hud_controller_test.gd b/client/test/contract_hud_controller_test.gd index 3558b90..a28e043 100644 --- a/client/test/contract_hud_controller_test.gd +++ b/client/test/contract_hud_controller_test.gd @@ -220,6 +220,20 @@ func test_reward_label_populates_on_active_to_completed_transition() -> void: assert_str(built["reward_label"].text).contains("Salvage +15 XP") +func test_reward_label_populates_when_first_get_is_already_completed() -> void: + # Arrange — issue marks session active; encounter completes before GET returns active + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + var issue_row: Dictionary = (_active_snapshot()["contracts"] as Array)[0] as Dictionary + built["controller"].call("_on_issue_result_received", {"issued": true, "contract": issue_row}) + # Act + built["controller"].call("_on_contracts_received", _completed_snapshot()) + # Assert + assert_str(built["reward_label"].text).contains("Contract rewards:") + assert_str(built["reward_label"].text).contains("Salvage +15 XP") + + func test_completed_get_clears_stale_issue_patch_on_active_label() -> void: # Arrange — simulate successful issue patch still held when encounter-complete GET arrives var built: Dictionary = await _build_controller( From e783fa5441f68ca2f53d0764df8c1f1d21d948ab Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 17:58:04 -0400 Subject: [PATCH 11/11] NEO-153: refresh faction standing on completion and show issue busy copy. Match quest HUD completion refresh and accept-busy feedback for duplicate Shift+C presses. --- client/scripts/contract_hud_controller.gd | 11 ++++++ client/test/contract_hud_controller_test.gd | 41 +++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/client/scripts/contract_hud_controller.gd b/client/scripts/contract_hud_controller.gd index 21e87d0..dc30b11 100644 --- a/client/scripts/contract_hud_controller.gd +++ b/client/scripts/contract_hud_controller.gd @@ -9,6 +9,7 @@ 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)" const ISSUE_SENDING_HINT := "Contract issue: sending…" +const ISSUE_BUSY_HINT := "Contract issue: busy — try again" const REWARD_HEADER := "Contract rewards:" const ISSUE_DENY_COPY := { "active_contract_exists": "Contract issue: denied — active contract already issued", @@ -101,6 +102,7 @@ func try_issue_key_input(event: InputEvent) -> bool: ): return false if bool(_contract_client.call("is_issue_busy")): + _render_issue_feedback_label(ISSUE_BUSY_HINT) return true _render_issue_feedback_label(ISSUE_SENDING_HINT) var queued: bool = bool( @@ -204,6 +206,7 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: _last_reward_instance_id = iid _last_reward_summary = summary.duplicate(true) _refresh_economy_hud() + _refresh_faction_standing() _previous_status_by_instance[iid] = new_status @@ -235,6 +238,14 @@ func _refresh_economy_hud() -> void: _skill_progression_client.call("request_sync_from_server") +func _refresh_faction_standing() -> void: + if ( + is_instance_valid(_faction_standing_client) + and _faction_standing_client.has_method("request_sync_from_server") + ): + _faction_standing_client.call("request_sync_from_server") + + func _render_active_label() -> void: if not is_instance_valid(_active_label): return diff --git a/client/test/contract_hud_controller_test.gd b/client/test/contract_hud_controller_test.gd index a28e043..34d1b75 100644 --- a/client/test/contract_hud_controller_test.gd +++ b/client/test/contract_hud_controller_test.gd @@ -62,6 +62,14 @@ class PendingIssueHttpTransport: return OK +class TrackingFactionClient: + extends Node + var sync_count: int = 0 + + func request_sync_from_server() -> void: + sync_count += 1 + + static func _active_snapshot() -> Dictionary: return { "schemaVersion": 1, @@ -169,6 +177,23 @@ func test_try_issue_key_input_handles_shift_c() -> void: assert_str(built["issue_label"].text).contains("sending") +func test_try_issue_key_input_shows_busy_while_issue_in_flight() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), PendingIssueHttpTransport.new() + ) + var event := InputEventKey.new() + event.pressed = true + event.shift_pressed = true + event.keycode = KEY_C + built["controller"].call("try_issue_key_input", event) + # Act + var handled: bool = bool(built["controller"].call("try_issue_key_input", event)) + # Assert + assert_bool(handled).is_true() + assert_str(built["issue_label"].text).contains("busy") + + func test_active_label_shows_encounter_objective_id() -> void: # Arrange var built: Dictionary = await _build_controller( @@ -220,6 +245,22 @@ func test_reward_label_populates_on_active_to_completed_transition() -> void: assert_str(built["reward_label"].text).contains("Salvage +15 XP") +func test_completion_transition_refreshes_faction_standing() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + var faction_client := TrackingFactionClient.new() + auto_free(faction_client) + add_child(faction_client) + built["controller"].set("_faction_standing_client", faction_client) + built["controller"].call("_on_contracts_received", _active_snapshot()) + # Act + built["controller"].call("_on_contracts_received", _completed_snapshot()) + # Assert + assert_int(faction_client.sync_count).is_equal(1) + + func test_reward_label_populates_when_first_get_is_already_completed() -> void: # Arrange — issue marks session active; encounter completes before GET returns active var built: Dictionary = await _build_controller(