From b4d48f6e3621d8873456b6c70e3aaf77698fc460 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 19:36:33 -0400 Subject: [PATCH 01/15] NEO-141: remove server-only manual QA doc after story end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only infrastructure story — acceptance fully covered by dotnet test; align with NEO-140 and planning-implementation-docs skip rule. --- docs/manual-qa/NEO-141.md | 34 ----------------------- docs/plans/E7M3-pre-production-backlog.md | 2 +- docs/plans/NEO-141-implementation-plan.md | 11 +++----- docs/reviews/2026-06-17-NEO-141.md | 9 ++---- server/README.md | 2 +- 5 files changed, 8 insertions(+), 50 deletions(-) delete mode 100644 docs/manual-qa/NEO-141.md diff --git a/docs/manual-qa/NEO-141.md b/docs/manual-qa/NEO-141.md deleted file mode 100644 index 3dd07dc..0000000 --- a/docs/manual-qa/NEO-141.md +++ /dev/null @@ -1,34 +0,0 @@ -# Manual QA — NEO-141 (faction telemetry hook sites) - -Reference: [implementation plan](../plans/NEO-141-implementation-plan.md), [NEO-136 implementation plan](../plans/NEO-136-implementation-plan.md) (reputation ops), [NEO-137 implementation plan](../plans/NEO-137-implementation-plan.md) (gate ops). - -## Preconditions - -- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`) if exercising Bruno; not required for code review only. -- This story adds **comments only**; faction standing, gate eval, and reward delivery behavior match NEO-136/137/138. - -## Code review check - -- [ ] Open `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs`. -- [ ] Confirm **`reputation_delta`** comment block immediately after **`auditStore.TryAppend`** returns **`true`**. -- [ ] Confirm **`TODO(E9.M1)`** markers and planned payload fields; **no** new `ILogger` or metrics calls. -- [ ] Open `server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs`. -- [ ] Confirm **`faction_gate_blocked`** comment on standing read-failure deny and threshold deny branches. -- [ ] Confirm **no** duplicate hook comments in **`QuestStateOperations`**, **`QuestAcceptApi`**, **`FactionStandingApi`**, or **`RewardRouterOperations`** for these event names. -- [ ] Open [server README — Faction telemetry hooks (NEO-141)](../../server/README.md#faction-telemetry-hooks-neo-141) — NEO-141 subsection present with event table. -- [ ] Open [server README — Reward telemetry hooks (NEO-130)](../../server/README.md#reward-telemetry-hooks-neo-130) — **`reputation_delta`** stub row removed; cross-link to NEO-141 present. - -## Regression (optional) - -```bash -cd server && dotnet test NeonSprawl.sln -``` - -Expect all tests pass (comments-only change). - -## Bruno (optional sanity) - -From `bruno/neon-sprawl-server/` against a running server: - -- **Accept grid contract faction gate deny** — **`faction_gate_blocked`** deny unchanged. -- **Accept grid contract after operator chain** — rep grant + accept success unchanged. diff --git a/docs/plans/E7M3-pre-production-backlog.md b/docs/plans/E7M3-pre-production-backlog.md index f812a2f..735dace 100644 --- a/docs/plans/E7M3-pre-production-backlog.md +++ b/docs/plans/E7M3-pre-production-backlog.md @@ -307,7 +307,7 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o - [x] Hook sites documented in README; no behavior change beyond comments. -**Landed (NEO-141):** comment-only **`reputation_delta`** in **`ReputationOperations.TryApplyDelta`**; **`faction_gate_blocked`** in **`FactionGateOperations.TryEvaluate`** (threshold + standing read failure); router duplicate stub removed; [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141); manual QA [`NEO-141.md`](../../manual-qa/NEO-141.md). +**Landed (NEO-141):** comment-only **`reputation_delta`** in **`ReputationOperations.TryApplyDelta`**; **`faction_gate_blocked`** in **`FactionGateOperations.TryEvaluate`** (threshold + standing read failure); router duplicate stub removed; [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-141-implementation-plan.md b/docs/plans/NEO-141-implementation-plan.md index 964da71..61ab402 100644 --- a/docs/plans/NEO-141-implementation-plan.md +++ b/docs/plans/NEO-141-implementation-plan.md @@ -25,7 +25,7 @@ Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_bl | **`faction_gate_blocked` anchor** | **`FactionGateOperations.TryEvaluate`** deny paths only — not duplicate in **`QuestStateOperations.TryAccept`** or **`QuestAcceptApi`** | Backlog lists ops + TryAccept flow; NEO-137 placed hook at eval engine; HTTP maps all gate failures to `faction_gate_blocked` | | **Standing read failure deny** | **Same event name** with ops **`reasonCode`** in planned payload (`unknown_faction` vs `gate_blocked`) — add comment block on read-failure branch | NEO-137 review: ops may carry `unknown_faction` for telemetry while quest HTTP surfaces `faction_gate_blocked` | | **Emit timing for `reputation_delta`** | After **`auditStore.TryAppend`** returns **`true`** (standing + audit committed) — not on deny, audit rollback, or compensating revert paths | NEO-136 success path; mirror NEO-49 idempotent “commit site only” | -| **Manual QA doc** | **`docs/manual-qa/NEO-141.md`** — NEO-121/130 comment-only verification pattern | Infrastructure-only server story | +| **Manual QA doc** | **None** — comment-only server story; `dotnet test` covers regression ([NEO-140](NEO-140-implementation-plan.md) precedent; [planning-implementation-docs](../.cursor/rules/planning-implementation-docs.md) skip when AC fully automated) | Infrastructure-only; no user-visible behavior | ## Scope and out-of-scope @@ -36,7 +36,6 @@ Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_bl - Remove duplicate **`reputation_delta`** stub from **`RewardRouterOperations`**. - **`server/README.md`** — dedicated **Faction telemetry hooks (NEO-141)** subsection; update **Reward telemetry hooks (NEO-130)** table to cross-link (remove stub row). - **`E7_M3_FactionReputationLedger.md`**, alignment register, E7M3 backlog updates when implementation completes. -- **`docs/manual-qa/NEO-141.md`**. **Out of scope:** @@ -57,7 +56,7 @@ Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_bl - **`FactionGateOperations.TryEvaluate`:** **`faction_gate_blocked`** hooks on threshold deny and standing read-failure deny (ops `reasonCode` in payload note). - **`RewardRouterOperations`:** duplicate **`reputation_delta`** stub removed — single ops-layer anchor. - **Docs:** `server/README.md` **Faction telemetry hooks (NEO-141)** subsection; NEO-130 reward table cross-link; E7.M3 module + alignment register + E7M3 backlog updated. -- **Manual QA:** `docs/manual-qa/NEO-141.md`. +- **Manual QA:** none — comment-only server story; `dotnet test` covers regression (manual QA checklists reserved for user-visible stories per project convention). ## Technical approach @@ -138,9 +137,7 @@ sequenceDiagram ## Files to add -| Path | Rationale | -|------|-----------| -| `docs/manual-qa/NEO-141.md` | Manual QA: verify comment blocks + no behavior/API change (NEO-121/130 pattern) | +None — comment-only changes to existing ops files and docs; no new source or test files. ## Files to modify @@ -158,7 +155,7 @@ sequenceDiagram | File | Coverage | |------|----------| -| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing faction/quest/reward tests unchanged). Manual verification: **`docs/manual-qa/NEO-141.md`**. | +| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing faction/quest/reward tests unchanged). | ## Open questions / risks diff --git a/docs/reviews/2026-06-17-NEO-141.md b/docs/reviews/2026-06-17-NEO-141.md index 38a0517..b309d71 100644 --- a/docs/reviews/2026-06-17-NEO-141.md +++ b/docs/reviews/2026-06-17-NEO-141.md @@ -21,7 +21,7 @@ This branch lands **E7M3-09**: comment-only telemetry anchor sites for future E9 | `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-09 landed (NEO-141); Responsibilities “Emit telemetry hook sites” satisfied at comment layer. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated with NEO-141 telemetry + README link. | | `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. | -| `docs/manual-qa/NEO-141.md` | **Matches** — NEO-130-style code-review checklist; Bruno optional sanity steps. | +| `docs/manual-qa/NEO-141.md` | **N/A (removed)** — comment-only server story; no user-visible manual QA per [NEO-140](NEO-140-implementation-plan.md) / [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md) skip rule. | | `docs/plans/NEO-140-implementation-plan.md` | **Matches** — manual QA removal note consistent with story-end convention (bundled in branch). | | `server/README.md` | **Matches** — Faction telemetry hooks (NEO-141) subsection; NEO-130 table cross-link; no duplicate router stub. | @@ -44,12 +44,7 @@ None. cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln ``` -Manual (see `docs/manual-qa/NEO-141.md` — code review checklist): - -- Confirm hook comment blocks in `ReputationOperations.cs`, `FactionGateOperations.cs`; no **`reputation_delta`** stub in `RewardRouterOperations.cs`. -- Confirm README NEO-141 subsection and NEO-130 cross-link. - -Optional Bruno (unchanged behavior): +Manual verification: `dotnet test NeonSprawl.sln`; optional Bruno gate/rep flows unchanged. - `quest-progress/Accept grid contract faction gate deny` - `quest-progress/Accept grid contract after operator chain` diff --git a/server/README.md b/server/README.md index efd1920..a1d05f0 100644 --- a/server/README.md +++ b/server/README.md @@ -143,7 +143,7 @@ Comment-only hook sites for future E9.M1 catalog events ([Epic 7 Slice 3](../../ | **`reputation_delta`** | **`ReputationOperations.TryApplyDelta`** | After successful audit append (auditable apply committed). | | **`faction_gate_blocked`** | **`FactionGateOperations.TryEvaluate`** | On any rule deny (threshold or standing read failure). | -Manual QA: [`docs/manual-qa/NEO-141.md`](../../docs/manual-qa/NEO-141.md); plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan.md). +Plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan.md). **Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined): - **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (idempotent when absent). From fb9a8ab8b217366605ff17675a083502c206c6f2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:08:56 -0400 Subject: [PATCH 02/15] NEO-142: add E7M3-10 client faction HUD implementation plan Kickoff plan for Godot faction standing label, gate deny feedback, and reputationGrants reward lines after NEO-139/140 server APIs landed. --- docs/plans/NEO-142-implementation-plan.md | 232 ++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/plans/NEO-142-implementation-plan.md diff --git a/docs/plans/NEO-142-implementation-plan.md b/docs/plans/NEO-142-implementation-plan.md new file mode 100644 index 0000000..f490a10 --- /dev/null +++ b/docs/plans/NEO-142-implementation-plan.md @@ -0,0 +1,232 @@ +# NEO-142 — E7M3-10: Client faction standing + gate feedback HUD (Godot) + +**Linear:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) +**Branch:** `NEO-142-client-faction-standing-gate-hud` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-10** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-122](NEO-122-implementation-plan.md) — quest HUD + accept feedback; [NEO-131](NEO-131-implementation-plan.md) — completion reward label + transition refresh; [NEO-73](NEO-73-implementation-plan.md) / [NEO-139](NEO-139-implementation-plan.md) — snapshot GET client +**Precursors:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) **`Done`** — `GET /game/players/{id}/faction-standing`; [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) **`Done`** — world **`factionGateRules`** + progress **`completionRewardSummary.reputationGrants`** +**Blocks:** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) — Slice 3 Godot capstone manual QA +**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) + [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) — authoritative GET projections; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) + +## Goal + +Player sees faction standing and accept gate denials in Godot without Bruno. + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|-------|----------|----------------------|--------| +| **Standing HUD placement** | HudRoot quest stack vs EconomyHudSection? | **HudRoot quest stack** — new **`FactionStandingLabel`** after **`QuestRewardDeliveryLabel`**; keeps quest/rep loop visible with NEO-122/131 labels; economy panel stays inventory/skills/gig. | **Adopted** — HudRoot quest stack | +| **Faction display names** | How to label standing/rep lines without `GET /game/world/faction-definitions`? | **Static prototype map** in client matching `content/factions/prototype_factions.json` freeze; fallback title-case **`id`** token. | **Adopted** (no kickoff question — NEO-139 server README: “join from client content”) | +| **`faction_gate_blocked` copy** | Raw `reasonCode` vs readable gate detail? | **Readable gate detail** — join cached world quest **`factionGateRules`** for denied quest + current standing from faction snapshot (e.g. “Grid Operators standing 15 required (have 0)”); other reason codes keep NEO-122 `denied — {reasonCode}`. | **Adopted** (meets Linear AC; NEO-140 gate projection landed) | +| **Standing refresh** | When to GET faction-standing? | **Boot hydrate** + on **`quest_completion_reward_transition`** (rep grants apply on completion, not accept); coalesce via client pending-sync like skill progression. | **Adopted** (backlog: reuse quest HUD completion signals) | +| **Reward label rep lines** | Where to surface **`reputationGrants`**? | **Extend `_format_reward_summary_lines`** in **`quest_hud_controller.gd`** — same transition-only reward label as NEO-131 item/skill lines. | **Adopted** (backlog explicit) | + +## Scope and out-of-scope + +**In scope (from Linear + [E7M3-10](E7M3-pre-production-backlog.md#e7m3-10--client-faction-standing--gate-feedback-hud-godot)):** + +- **`faction_standing_client.gd`** — GET **`/game/players/{id}/faction-standing`**; parse v1; **`faction_row(faction_id, snapshot)`**; signals **`faction_standing_received`** / **`faction_standing_sync_failed`**; **`request_sync_from_server()`** with pending coalesce. +- **`FactionStandingLabel`** in **`HudRoot`** after **`QuestRewardDeliveryLabel`** — both prototype factions with readable names + standing values. +- Parse + render refresh after quest completion transitions (wire through **`quest_hud_controller.gd`**). +- Surface **`faction_gate_blocked`** on **`QuestAcceptFeedbackLabel`** with readable gate copy (not raw code alone). +- Extend **`quest_hud_controller.gd`** reward formatter for **`reputationGrants`** lines in **`completionRewardSummary`**. +- Optional helper on **`quest_definitions_client.gd`**: **`faction_gate_rules_for(quest_id)`** reading cached world rows (NEO-140 field passes through existing parse). +- GdUnit parse + HUD tests ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)). +- **`docs/manual-qa/NEO-142.md`** — component checklist. +- **`client/README.md`** faction standing + gate feedback subsection. + +**Out of scope (from Linear + backlog):** + +- Final journal UI; faction art pass. +- **`GET /game/world/faction-definitions`** client (no backlog item). +- Server route/DTO changes. +- Full Slice 3 capstone script (**[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)**). +- Periodic faction-standing poll timer. + +**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140). + +## Acceptance criteria checklist + +- [ ] Standing visible after operator-chain completion (+15 Grid Operators). +- [ ] Accepting grid contract below rep shows readable deny copy on HUD. + +## Technical approach + +### 1. Server contracts (landed — NEO-139 / NEO-140) + +**Faction standing GET** (`schemaVersion` 1): + +```json +{ + "schemaVersion": 1, + "playerId": "dev-local-1", + "factions": [ + { "id": "prototype_faction_grid_operators", "standing": 15 }, + { "id": "prototype_faction_rust_collective", "standing": 0 } + ] +} +``` + +Consumers key rows by **`id`** — array order is not contractual. + +**World quest definition** (grid contract gate row): + +```json +{ + "id": "prototype_quest_grid_contract", + "factionGateRules": [ + { "factionId": "prototype_faction_grid_operators", "minStanding": 15 } + ] +} +``` + +**Completion reward summary** (operator chain rep line): + +```json +{ + "reputationGrants": [ + { "factionId": "prototype_faction_grid_operators", "amount": 15 } + ] +} +``` + +### 2. `faction_standing_client.gd` + +Mirror **`skill_progression_client.gd`**: + +| Concern | Implementation | +|---------|----------------| +| URL | `{base}/game/players/{dev_player_id}/faction-standing` | +| Parse | Require `schemaVersion == 1`, non-empty `playerId`, `factions` array of `{ id, standing }` | +| Helpers | **`faction_row(faction_id, snapshot)`**; **`standing_for(faction_id, snapshot) -> int`** default **0** when row missing | +| Display names | **`display_name_for(faction_id)`** — static map for two prototype ids; title-case fallback | +| Errors | 404 → `"HTTP 404 (player unknown)"`; schema mismatch → `"non-JSON body or schemaVersion mismatch"`; emit **`faction_standing_sync_failed`** | + +Static display-name map (matches [prototype_factions.json](../../content/factions/prototype_factions.json)): + +| `id` | Label | +|------|-------| +| `prototype_faction_grid_operators` | Grid Operators | +| `prototype_faction_rust_collective` | Rust Collective | + +Render order: Grid Operators first, Rust Collective second (catalog freeze order). + +### 3. HUD wiring (`main.gd` / `main.tscn`) + +- Add **`FactionStandingClient`** node sibling to **`QuestProgressClient`**. +- Add **`FactionStandingLabel`** after **`QuestRewardDeliveryLabel`** in **`HudRoot`** (user-adopted placement). +- Boot: apply authority HTTP config; connect client signals; **`request_sync_from_server()`** on setup. +- Extend **`QuestHudController.setup(...)`** with **`faction_standing_client`** + **`faction_standing_label`** (optional params to avoid breaking test harnesses — pass `null` when absent). + +### 4. Standing label render (`quest_hud_controller.gd`) + +Default idle: + +``` +Faction standing: + Grid Operators: 0 + Rust Collective: 0 +``` + +On sync error, append **`error — {reason}`** under header (NEO-122 pattern). On successful snapshot, paint both factions from GET (not local math). + +**Refresh triggers:** + +1. **`setup`** → faction client boot GET (via controller or `main` — prefer controller calling **`request_sync_from_server()`** after connect). +2. **`quest_completion_reward_transition`** → faction GET (standing changes on quest completion rep delivery). +3. Do **not** refresh on accept deny alone (standing unchanged on failed accept). + +### 5. Accept gate feedback (`faction_gate_blocked`) + +In **`_on_accept_result_received`**, when **`accepted == false`** and **`reasonCode == "faction_gate_blocked"`**: + +1. Read **`faction_gate_rules_for(quest_id)`** from defs client (first rule sufficient for prototype grid contract). +2. Read current standing from last faction snapshot via **`standing_for(factionId, snapshot)`**. +3. Render e.g. **`Quest accept: denied — Grid Operators standing 15 required (have 0)`**. +4. If gate rules or standing snapshot unavailable, fallback **`Quest accept: denied — faction standing too low (faction_gate_blocked)`**. + +Other deny codes unchanged: **`Quest accept: denied — {reasonCode}`**. + +### 6. Reward label — `reputationGrants` + +Extend **`_format_reward_summary_lines`** after skill XP block: + +```gdscript +# reputationGrants: [{ factionId, amount }] +lines.append(" Grid Operators +15 rep") +``` + +Use **`display_name_for(factionId)`** from faction standing client when wired; else title-case **`factionId`**. + +Update **`quest_reward_hud_test.gd`** (or new suite) for operator-chain transition including rep line. + +### 7. Flow + +```mermaid +sequenceDiagram + participant Main + participant QuestHud as QuestHudController + participant FactionClient as FactionStandingClient + participant QuestClient as QuestProgressClient + participant Server + + Main->>QuestHud: setup (quest + faction clients) + QuestHud->>FactionClient: request_sync_from_server (boot) + FactionClient->>Server: GET faction-standing + Server-->>FactionClient: factions[] + FactionClient-->>QuestHud: faction_standing_received + QuestHud->>QuestHud: render FactionStandingLabel + + QuestClient->>Server: GET quest-progress (completion) + QuestClient-->>QuestHud: quest_progress_received + QuestHud->>QuestHud: detect completed transition + QuestHud->>FactionClient: request_sync_from_server + QuestHud->>QuestHud: render reward (+ reputationGrants) + + QuestClient->>Server: POST accept grid contract (deny) + QuestClient-->>QuestHud: reasonCode faction_gate_blocked + QuestHud->>QuestHud: readable gate deny on accept label +``` + +## Files to add + +| Path | Rationale | +|------|-----------| +| `client/scripts/faction_standing_client.gd` | HTTP GET client for NEO-139 faction-standing snapshot | +| `client/scripts/faction_standing_client.gd.uid` | Godot uid companion (tracked with script) | +| `client/test/faction_standing_client_test.gd` | GdUnit parse, 404, `faction_row` / `display_name_for`, pending sync | +| `client/test/faction_standing_hud_test.gd` | GdUnit standing label render, gate deny copy, completion refresh hook | +| `docs/manual-qa/NEO-142.md` | Godot component checklist (standing +15, grid-contract gate deny) | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scripts/quest_hud_controller.gd` | Wire faction client/label; standing render; gate deny copy; `reputationGrants` reward lines; refresh faction on completion transition | +| `client/scripts/quest_definitions_client.gd` | Add **`faction_gate_rules_for(quest_id)`** helper over cached world rows | +| `client/scripts/main.gd` | `@onready` faction client + label; pass into quest HUD **`setup`**; boot HTTP config | +| `client/scenes/main.tscn` | **`FactionStandingClient`** node + **`FactionStandingLabel`** after reward label | +| `client/test/quest_reward_hud_test.gd` | Assert **`reputationGrants`** line on operator-chain completion transition | +| `client/README.md` | Document faction standing GET, label placement, gate feedback, refresh triggers | + +## Tests + +| File | Coverage | +|------|----------| +| `client/test/faction_standing_client_test.gd` | **Add:** parse valid snapshot; reject bad schema; 404 sync failed; `faction_row` / `standing_for`; prototype display names; pending sync coalesce | +| `client/test/faction_standing_hud_test.gd` | **Add:** standing label after snapshot (+15 Grid Operators); sync error line; `faction_gate_blocked` readable deny using mock defs + standing; fallback when rules missing | +| `client/test/quest_reward_hud_test.gd` | **Change:** operator-chain completion summary includes **`Grid Operators +15 rep`** line | + +Manual verification: **`docs/manual-qa/NEO-142.md`** — server + Godot; complete operator chain → standing **15**; accept grid contract before rep → readable deny. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Standing label shows **0** before any rep (not hidden until +15) | **Show both factions from boot** — AC scenario is “visible after completion” with **15**, not hidden-until-earned | `adopted` | +| Gate deny before operator-chain prerequisite (`prerequisite_incomplete`) | **Keep NEO-122 raw reasonCode** — AC targets **`faction_gate_blocked`** after chain complete | `adopted` | +| Faction GET lags quest completion refresh | **Accept brief stale standing** on same frame; completion handler fires faction GET immediately after transition | `adopted` | +| `main.tscn` ext_resource churn | Add script ext_resource for faction client; match existing client node pattern | `adopted` | +| NEO-143 capstone overlap | NEO-142 = component HUD; NEO-143 = full end-to-end manual QA script | `adopted` | From c8c706f8391c5cf7ef34e73ac4b6b83af87225ce Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:10:34 -0400 Subject: [PATCH 03/15] NEO-142: add faction standing client and HUD wiring Introduce faction-standing GET client, standing label in HudRoot, readable faction_gate_blocked accept feedback, reputationGrants reward lines, and GdUnit tests. --- client/scenes/main.tscn | 16 ++ client/scripts/faction_standing_client.gd | 157 +++++++++++++ client/scripts/faction_standing_client.gd.uid | 1 + client/scripts/main.gd | 6 +- client/scripts/quest_definitions_client.gd | 15 ++ client/scripts/quest_hud_controller.gd | 121 +++++++++- client/test/faction_standing_client_test.gd | 182 +++++++++++++++ client/test/faction_standing_hud_test.gd | 216 ++++++++++++++++++ client/test/quest_reward_hud_test.gd | 8 + 9 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 client/scripts/faction_standing_client.gd create mode 100644 client/scripts/faction_standing_client.gd.uid create mode 100644 client/test/faction_standing_client_test.gd create mode 100644 client/test/faction_standing_hud_test.gd diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 0b0f03e..82a18e4 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -23,6 +23,7 @@ [ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"] [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="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"] @@ -1126,6 +1127,9 @@ script = ExtResource("25_quest_prog") [node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014] script = ExtResource("26_quest_defs") +[node name="FactionStandingClient" type="Node" parent="." unique_id=2500015] +script = ExtResource("27_faction_standing") + [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] script = ExtResource("17_recipe_defs") @@ -1271,6 +1275,18 @@ autowrap_mode = 3 text = "Quest rewards: —" +[node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.85, 0.88, 0.98, 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 = "Faction standing: + Grid Operators: 0 + Rust Collective: 0" + [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/faction_standing_client.gd b/client/scripts/faction_standing_client.gd new file mode 100644 index 0000000..17fc7d5 --- /dev/null +++ b/client/scripts/faction_standing_client.gd @@ -0,0 +1,157 @@ +extends Node + +## NEO-142: prototype HTTP client for [code]GET /game/players/{id}/faction-standing[/code] (NEO-139). + +signal faction_standing_received(snapshot: Dictionary) +signal faction_standing_sync_failed(reason: String) + +const SCHEMA_VERSION := 1 +const GRID_OPERATORS_ID := "prototype_faction_grid_operators" +const RUST_COLLECTIVE_ID := "prototype_faction_rust_collective" + +const _DISPLAY_NAMES: Dictionary = { + GRID_OPERATORS_ID: "Grid Operators", + RUST_COLLECTIVE_ID: "Rust Collective", +} + +@export var base_url: String = "http://127.0.0.1:5253" +@export var dev_player_id: String = "dev-local-1" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false +var _sync_pending: bool = false + + +func _ready() -> void: + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") + _http.request_completed.connect(_on_request_completed) + + +func request_sync_from_server() -> void: + if _busy: + _sync_pending = true + return + _start_sync_request() + + +func _start_sync_request() -> void: + _busy = true + var url := "%s/game/players/%s/faction-standing" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("FactionStandingClient: %s" % reason) + _busy = false + faction_standing_sync_failed.emit(reason) + _try_flush_pending_sync() + + +func _try_flush_pending_sync() -> void: + if not _sync_pending or _busy: + return + _sync_pending = false + _start_sync_request() + + +static func prototype_faction_ids_in_order() -> Array: + return [GRID_OPERATORS_ID, RUST_COLLECTIVE_ID] + + +static func display_name_for(faction_id: String) -> String: + var fid := faction_id.strip_edges() + if _DISPLAY_NAMES.has(fid): + return str(_DISPLAY_NAMES[fid]) + return _title_case_token(fid) + + +static func standing_for(faction_id: String, snapshot: Dictionary = {}) -> int: + var row: Dictionary = faction_row(faction_id, snapshot) + if row.is_empty(): + return 0 + return int(row.get("standing", 0)) + + +static func faction_row(faction_id: String, snapshot: Dictionary = {}) -> Dictionary: + var factions: Variant = snapshot.get("factions", null) + if factions == null or not factions is Array: + return {} + var fid := faction_id.strip_edges() + for row_variant in factions as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) == fid: + return row + return {} + + +static func parse_faction_standing_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 str(root.get("playerId", "")).strip_edges().is_empty(): + return null + var factions: Variant = root.get("factions", null) + if factions == null or not factions is Array: + return null + return root + + +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) + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("FactionStandingClient: %s" % reason) + faction_standing_sync_failed.emit(reason) + _try_flush_pending_sync() + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("FactionStandingClient: %s" % reason404) + faction_standing_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("FactionStandingClient: %s" % reason_code) + faction_standing_sync_failed.emit(reason_code) + _try_flush_pending_sync() + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_faction_standing_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("FactionStandingClient: %s" % reason_json) + faction_standing_sync_failed.emit(reason_json) + _try_flush_pending_sync() + return + faction_standing_received.emit(snapshot as Dictionary) + _try_flush_pending_sync() diff --git a/client/scripts/faction_standing_client.gd.uid b/client/scripts/faction_standing_client.gd.uid new file mode 100644 index 0000000..f34a18d --- /dev/null +++ b/client/scripts/faction_standing_client.gd.uid @@ -0,0 +1 @@ +uid://bneo142factionst01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index cb6ee87..ccf7232 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -137,6 +137,7 @@ 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 _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") @onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel") @@ -155,6 +156,7 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient +@onready var _faction_standing_client: Node = $FactionStandingClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @@ -867,7 +869,9 @@ func _setup_quest_progress_sync() -> void: _quest_reward_delivery_label, _item_defs_client, _inventory_client, - _skill_progression_client + _skill_progression_client, + _faction_standing_client, + _faction_standing_label ) diff --git a/client/scripts/quest_definitions_client.gd b/client/scripts/quest_definitions_client.gd index d1783f0..a20ce32 100644 --- a/client/scripts/quest_definitions_client.gd +++ b/client/scripts/quest_definitions_client.gd @@ -57,6 +57,21 @@ func quests_snapshot() -> Array: return _quests.duplicate(true) +func faction_gate_rules_for(quest_id: String) -> Array: + var qid := quest_id.strip_edges() + for row_variant in _quests: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) != qid: + continue + var rules: Variant = row.get("factionGateRules", null) + if rules is Array: + return (rules as Array).duplicate(true) + return [] + return [] + + func _base_root() -> String: return base_url.strip_edges().rstrip("/") diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index e021d05..fcda339 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -3,14 +3,18 @@ extends Node ## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). ## NEO-131: quest completion reward label on in-session completed transition. ## NEO-132: emits quest_completion_reward_transition for economy HUD refresh. +## NEO-142: faction standing label + readable faction_gate_blocked accept deny. signal quest_completion_reward_transition(quest_id: String, summary: Dictionary) const QuestProgressClient := preload("res://scripts/quest_progress_client.gd") +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") const GATHER_QUEST_ID := "prototype_quest_gather_intro" +const FACTION_GATE_BLOCKED_REASON := "faction_gate_blocked" const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)" const ACCEPT_SENDING_HINT := "Quest accept: sending…" const REWARD_HEADER := "Quest rewards:" +const FACTION_STANDING_HEADER := "Faction standing:" var _progress_client: Node = null var _defs_client: Node = null @@ -28,6 +32,10 @@ var _last_reward_quest_id: String = "" var _last_reward_summary: Dictionary = {} var _inventory_client: Node = null var _skill_progression_client: Node = null +var _faction_standing_client: Node = null +var _faction_standing_label: Label = null +var _last_faction_snapshot: Dictionary = {} +var _faction_standing_error: String = "" func setup( @@ -39,19 +47,25 @@ func setup( reward_label: Label = null, item_defs_client: Node = null, inventory_client: Node = null, - skill_progression_client: Node = null + skill_progression_client: Node = null, + faction_standing_client: Node = null, + faction_standing_label: Label = null ) -> void: _progress_client = progress_client _defs_client = defs_client _item_defs_client = item_defs_client _inventory_client = inventory_client _skill_progression_client = skill_progression_client + _faction_standing_client = faction_standing_client + _faction_standing_label = faction_standing_label _progress_label = progress_label _accept_label = accept_label _reward_label = reward_label if apply_http_config.is_valid(): apply_http_config.call(progress_client) apply_http_config.call(defs_client) + if is_instance_valid(_faction_standing_client): + apply_http_config.call(_faction_standing_client) if _progress_client.has_signal("quest_progress_received"): _progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received")) if _progress_client.has_signal("quest_sync_failed"): @@ -70,11 +84,22 @@ func setup( ) 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") + ) + if _faction_standing_client.has_signal("faction_standing_sync_failed"): + _faction_standing_client.connect( + "faction_standing_sync_failed", Callable(self, "_on_faction_standing_sync_failed") + ) _render_progress_label() _render_accept_feedback_label() _render_reward_label() + _render_faction_standing_label() if _defs_client.has_method("request_sync_from_server"): _defs_client.call("request_sync_from_server") + _refresh_faction_standing() request_progress_refresh() @@ -133,7 +158,9 @@ func _on_accept_result_received(quest_id: String, result: Dictionary) -> void: _render_progress_label() else: var rc := str(result.get("reasonCode", "")).strip_edges() - if rc.is_empty(): + if rc == FACTION_GATE_BLOCKED_REASON: + _render_accept_feedback_label(_format_faction_gate_blocked_deny(quest_id)) + elif rc.is_empty(): _render_accept_feedback_label("Quest accept: denied (no reasonCode)") else: _render_accept_feedback_label("Quest accept: denied — %s" % rc) @@ -217,6 +244,85 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: if not _last_reward_quest_id.is_empty() and not _last_reward_summary.is_empty(): quest_completion_reward_transition.emit(_last_reward_quest_id, _last_reward_summary) _refresh_economy_hud() + _refresh_faction_standing() + + +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 _on_faction_standing_received(snapshot: Dictionary) -> void: + _faction_standing_error = "" + _last_faction_snapshot = snapshot.duplicate(true) + _render_faction_standing_label() + + +func _on_faction_standing_sync_failed(reason: String) -> void: + _faction_standing_error = reason + _render_faction_standing_label() + + +func _render_faction_standing_label() -> void: + if not is_instance_valid(_faction_standing_label): + return + var lines: PackedStringArray = [FACTION_STANDING_HEADER] + if not _faction_standing_error.is_empty(): + lines.append("error — %s" % _faction_standing_error) + for faction_id in FactionStandingClient.prototype_faction_ids_in_order(): + var fid := str(faction_id) + var standing: int = FactionStandingClient.standing_for(fid, _last_faction_snapshot) + var label := FactionStandingClient.display_name_for(fid) + lines.append(" %s: %d" % [label, standing]) + _faction_standing_label.text = "\n".join(lines) + + +func _format_faction_gate_blocked_deny(quest_id: String) -> String: + var rules: Array = _faction_gate_rules_for(quest_id) + if rules.is_empty(): + return ( + "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON + ) + var first_rule: Variant = rules[0] + if not first_rule is Dictionary: + return ( + "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON + ) + var rule: Dictionary = first_rule + var faction_id := str(rule.get("factionId", "")).strip_edges() + var min_standing: int = int(rule.get("minStanding", 0)) + if faction_id.is_empty(): + return ( + "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON + ) + var faction_label := _faction_display_name(faction_id) + var current_standing: int = FactionStandingClient.standing_for( + faction_id, _last_faction_snapshot + ) + return ( + "Quest accept: denied — %s standing %d required (have %d)" + % [faction_label, min_standing, current_standing] + ) + + +func _faction_gate_rules_for(quest_id: String) -> Array: + if not is_instance_valid(_defs_client): + return [] + if not _defs_client.has_method("faction_gate_rules_for"): + return [] + return _defs_client.call("faction_gate_rules_for", quest_id) as Array + + +func _faction_display_name(faction_id: String) -> String: + if ( + is_instance_valid(_faction_standing_client) + and _faction_standing_client.has_method("display_name_for") + ): + return str(_faction_standing_client.call("display_name_for", faction_id)) + return FactionStandingClient.display_name_for(faction_id) func _refresh_economy_hud() -> void: @@ -335,6 +441,17 @@ func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: 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" % [_faction_display_name(faction_id), amount]) if lines.is_empty(): lines.append(" (no grants)") return lines diff --git a/client/test/faction_standing_client_test.gd b/client/test/faction_standing_client_test.gd new file mode 100644 index 0000000..4540ea2 --- /dev/null +++ b/client/test/faction_standing_client_test.gd @@ -0,0 +1,182 @@ +extends GdUnitTestSuite + +## NEO-142: `FactionStandingClient` GET parse + failure signals. + +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") + +var _standing_capture: Dictionary = {} + + +func _capture_standing(snapshot: Dictionary) -> void: + _standing_capture = snapshot + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + 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 + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +class PendingSyncHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var request_count: int = 0 + 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_count += 1 + return OK + + func complete_pending() -> void: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + + +static func _default_standing_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","factions":[' + + '{"id":"prototype_faction_grid_operators","standing":0},' + + '{"id":"prototype_faction_rust_collective","standing":0}' + + "]}" + ) + + +static func _post_operator_chain_standing_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","factions":[' + + '{"id":"prototype_faction_rust_collective","standing":0},' + + '{"id":"prototype_faction_grid_operators","standing":15}' + + "]}" + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = FactionStandingClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_faction_standing_json_reads_grid_operators_row() -> void: + # Arrange + var json := _post_operator_chain_standing_json() + # Act + var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + assert_that( + FactionStandingClient.standing_for( + FactionStandingClient.GRID_OPERATORS_ID, snapshot as Dictionary + ) + ).is_equal(15) + + +func test_parse_faction_standing_json_rejects_bad_schema() -> void: + # Arrange + var json := '{"schemaVersion":2,"playerId":"dev-local-1","factions":[]}' + # Act + var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json) + # Assert + assert_that(snapshot).is_null() + + +func test_standing_for_defaults_to_zero_when_row_missing() -> void: + # Arrange + var snapshot := {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + # Act + var standing: int = FactionStandingClient.standing_for( + FactionStandingClient.GRID_OPERATORS_ID, snapshot + ) + # Assert + assert_that(standing).is_equal(0) + + +func test_display_name_for_prototype_factions() -> void: + # Arrange + # Act + var grid_label := FactionStandingClient.display_name_for( + FactionStandingClient.GRID_OPERATORS_ID + ) + var rust_label := FactionStandingClient.display_name_for( + FactionStandingClient.RUST_COLLECTIVE_ID + ) + # Assert + assert_str(grid_label).is_equal("Grid Operators") + assert_str(rust_label).is_equal("Rust Collective") + + +func test_request_sync_emits_faction_standing_received() -> void: + # Arrange + _standing_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _default_standing_json() + var c := _make_client(transport) + c.connect("faction_standing_received", Callable(self, "_capture_standing")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("faction_standing_received", any()) + assert_that(_standing_capture.has("factions")).is_true() + assert_that(transport.last_url).contains("/faction-standing") + + +func test_http_404_emits_faction_standing_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted( + "faction_standing_sync_failed", "HTTP 404 (player unknown)" + ) + + +func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void: + # Arrange + var transport := PendingSyncHttpTransport.new() + transport.body_json = _default_standing_json() + var c := _make_client(transport) + # Act + c.call("request_sync_from_server") + c.call("request_sync_from_server") + # Assert + assert_that(transport.request_count).is_equal(1) + transport.complete_pending() + assert_that(transport.request_count).is_equal(2) diff --git a/client/test/faction_standing_hud_test.gd b/client/test/faction_standing_hud_test.gd new file mode 100644 index 0000000..c39b1b9 --- /dev/null +++ b/client/test/faction_standing_hud_test.gd @@ -0,0 +1,216 @@ +extends GdUnitTestSuite + +## NEO-142: faction standing label + faction_gate_blocked accept deny HUD tests. + +const QuestHudController := preload("res://scripts/quest_hud_controller.gd") +const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") + +const GRID_CONTRACT_QUEST_ID := "prototype_quest_grid_contract" + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + 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, 200, PackedStringArray(), body_json.to_utf8_buffer() + ) + return OK + + +class MockFactionSyncClient: + extends Node + + var sync_count: int = 0 + + func request_sync_from_server() -> void: + sync_count += 1 + + func display_name_for(faction_id: String) -> String: + return FactionStandingClient.display_name_for(faction_id) + + +static func _post_operator_chain_standing_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "factions": + [ + {"id": FactionStandingClient.GRID_OPERATORS_ID, "standing": 15}, + {"id": FactionStandingClient.RUST_COLLECTIVE_ID, "standing": 0}, + ], + } + + +static func _grid_contract_defs_json() -> String: + return ( + '{"schemaVersion":1,"quests":[' + + '{"id":"prototype_quest_grid_contract","displayName":"Grid Contract",' + + '"prerequisiteQuestIds":["prototype_quest_operator_chain"],' + + '"factionGateRules":[{"factionId":"prototype_faction_grid_operators","minStanding":15}],' + + '"steps":[]}]}' + ) + + +func test_faction_standing_label_paints_grid_operators_standing() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var standing_label := Label.new() + auto_free(controller) + auto_free(standing_label) + add_child(controller) + controller.set("_faction_standing_label", standing_label) + # Act + controller.call("_on_faction_standing_received", _post_operator_chain_standing_snapshot()) + # Assert + assert_str(standing_label.text).contains("Faction standing:") + assert_str(standing_label.text).contains("Grid Operators: 15") + assert_str(standing_label.text).contains("Rust Collective: 0") + + +func test_faction_standing_sync_error_shows_error_line() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var standing_label := Label.new() + auto_free(controller) + auto_free(standing_label) + add_child(controller) + controller.set("_faction_standing_label", standing_label) + # Act + controller.call("_on_faction_standing_sync_failed", "HTTP 503") + # Assert + assert_str(standing_label.text).contains("error — HTTP 503") + assert_str(standing_label.text).contains("Grid Operators: 0") + + +func test_faction_gate_blocked_deny_uses_readable_gate_copy() -> void: + # Arrange + var defs_transport := MockHttpTransport.new() + defs_transport.body_json = _grid_contract_defs_json() + var defs_client: Node = QuestDefinitionsClient.new() + defs_client.set("injected_http", defs_transport) + auto_free(defs_transport) + auto_free(defs_client) + add_child(defs_client) + defs_client.call("request_sync_from_server") + var controller: Node = QuestHudController.new() + var accept_label := Label.new() + auto_free(controller) + auto_free(accept_label) + add_child(controller) + controller.set("_defs_client", defs_client) + controller.set("_accept_label", accept_label) + controller.call( + "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + ) + # Act + var deny_text: String = str( + controller.call("_format_faction_gate_blocked_deny", GRID_CONTRACT_QUEST_ID) + ) + # Assert + assert_str(deny_text).contains("Grid Operators standing 15 required (have 0)") + + +func test_faction_gate_blocked_deny_falls_back_without_gate_rules() -> void: + # Arrange + var controller: Node = QuestHudController.new() + auto_free(controller) + add_child(controller) + # Act + var deny_text: String = str( + controller.call("_format_faction_gate_blocked_deny", GRID_CONTRACT_QUEST_ID) + ) + # Assert + assert_str(deny_text).contains("faction standing too low") + assert_str(deny_text).contains("faction_gate_blocked") + + +func test_accept_result_received_paints_readable_faction_gate_blocked() -> void: + # Arrange + var defs_transport := MockHttpTransport.new() + defs_transport.body_json = _grid_contract_defs_json() + var defs_client: Node = QuestDefinitionsClient.new() + defs_client.set("injected_http", defs_transport) + auto_free(defs_transport) + auto_free(defs_client) + add_child(defs_client) + defs_client.call("request_sync_from_server") + var controller: Node = QuestHudController.new() + var accept_label := Label.new() + auto_free(controller) + auto_free(accept_label) + add_child(controller) + controller.set("_defs_client", defs_client) + controller.set("_accept_label", accept_label) + controller.set("_progress_client", Node.new()) + controller.call( + "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + ) + # Act + controller.call( + "_on_accept_result_received", + GRID_CONTRACT_QUEST_ID, + {"schemaVersion": 1, "accepted": false, "reasonCode": "faction_gate_blocked"} + ) + # Assert + assert_str(accept_label.text).contains("Grid Operators standing 15 required (have 0)") + + +func test_completion_transition_requests_faction_standing_refresh() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var faction_client := MockFactionSyncClient.new() + auto_free(controller) + auto_free(faction_client) + add_child(controller) + add_child(faction_client) + controller.set("_faction_standing_client", faction_client) + var active_snapshot := { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": "prototype_quest_operator_chain", + "status": "active", + "currentStepIndex": 3, + "objectiveCounters": {}, + } + ], + } + var completed_snapshot := { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": "prototype_quest_operator_chain", + "status": "completed", + "currentStepIndex": 3, + "objectiveCounters": {}, + "completedAt": "2026-06-07T14:00:00Z", + "completionRewardSummary": + { + "itemGrants": [], + "skillXpGrants": [], + "reputationGrants": + [{"factionId": FactionStandingClient.GRID_OPERATORS_ID, "amount": 15}], + }, + } + ], + } + # Act + controller.call("_on_progress_received", active_snapshot) + controller.call("_on_progress_received", completed_snapshot) + # Assert + assert_int(faction_client.sync_count).is_equal(1) diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd index 8913d79..7921bd0 100644 --- a/client/test/quest_reward_hud_test.gd +++ b/client/test/quest_reward_hud_test.gd @@ -159,6 +159,13 @@ func _completed_operator_chain_with_summary_snapshot() -> Dictionary: { "itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}], "skillXpGrants": [{"skillId": "salvage", "amount": 50}], + "reputationGrants": + [ + { + "factionId": "prototype_faction_grid_operators", + "amount": 15, + } + ], }, } ], @@ -235,6 +242,7 @@ func test_completion_transition_paints_item_grant_with_display_name() -> void: # Assert assert_str(reward_label.text).contains("Survey Drone Kit ×1") assert_str(reward_label.text).contains("Salvage +50 XP") + assert_str(reward_label.text).contains("Grid Operators +15 rep") func test_definitions_ready_repaints_reward_label_with_display_name() -> void: From 84bd46635d98f3a28ee8145567d63f54b1d9dda5 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:10:36 -0400 Subject: [PATCH 04/15] NEO-142: document faction HUD and manual QA checklist Add client README section, NEO-142 manual QA steps, and plan reconciliation. --- client/README.md | 15 ++++++++ docs/manual-qa/NEO-142.md | 43 +++++++++++++++++++++++ docs/plans/NEO-142-implementation-plan.md | 9 +++++ 3 files changed, 67 insertions(+) create mode 100644 docs/manual-qa/NEO-142.md diff --git a/client/README.md b/client/README.md index 32f161b..c02aa61 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). +## 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). +- **`GET /game/world/quest-definitions`** — gated quests expose optional **`factionGateRules`** (`factionId`, `minStanding`) for readable accept deny copy (NEO-140). +- **`GET /game/players/{id}/quest-progress`** — completed rows may include **`completionRewardSummary.reputationGrants`** (`factionId`, `amount`) (NEO-140). +- **Scripts:** `scripts/faction_standing_client.gd`, `scripts/quest_hud_controller.gd` (standing label + gate deny + rep reward lines), `scripts/quest_definitions_client.gd` (**`faction_gate_rules_for`**). +- **HUD:** + - **`UICanvas/HudRootScroll/HudRoot/FactionStandingLabel`** — both prototype factions with readable names + standing values; default **0** before first GET. + - **`QuestAcceptFeedbackLabel`** — **`faction_gate_blocked`** renders readable gate copy (e.g. **`Grid Operators standing 15 required (have 0)`**); other deny codes unchanged (NEO-122). + - **`QuestRewardDeliveryLabel`** — adds **`reputationGrants`** lines (e.g. **`Grid Operators +15 rep`**) on completion transition (NEO-131). +- **Refresh:** boot hydrate + faction GET after in-session quest completion transition (rep grants apply on completion). No periodic poll. +- **Errors:** failed faction-standing GET shows **`error — …`** on **`FactionStandingLabel`**; defaults remain visible at **0**. + +Full checklist: [`docs/manual-qa/NEO-142.md`](../docs/manual-qa/NEO-142.md). + ## End-to-end quest reward loop (NEO-132) Epic 7 Slice 2 capstone — complete all four prototype quests **in Godot** and verify quest completion bundles (skill XP + item grants) apply **once** with idempotent replay. diff --git a/docs/manual-qa/NEO-142.md b/docs/manual-qa/NEO-142.md new file mode 100644 index 0000000..d5c8cf8 --- /dev/null +++ b/docs/manual-qa/NEO-142.md @@ -0,0 +1,43 @@ +# NEO-142 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-142 | +| Title | E7M3-10: Client faction standing + gate feedback HUD (Godot) | +| Linear | https://linear.app/neon-sprawl/issue/NEO-142/e7m3-10-client-faction-standing-gate-feedback-hud-godot | +| Plan | `docs/plans/NEO-142-implementation-plan.md` | +| Branch | `NEO-142-client-faction-standing-gate-hud` | + +## Preconditions + +- **Fresh dev player:** stop any running server, then start a new instance so quest progress, faction standing, and reward delivery reset. +- **No Bruno/curl** for this checklist — use Godot gameplay only. +- NEO-139 **`GET /game/players/{id}/faction-standing`** and NEO-140 quest HTTP rep/gate projections landed on `main`. +- NEO-122 quest HUD + NEO-131 reward HUD landed on `main`. + +## Expected HUD progression + +| After | `FactionStandingLabel` | `QuestAcceptFeedbackLabel` | +|-------|------------------------|----------------------------| +| Boot | `Grid Operators: 0`, `Rust Collective: 0` | idle accept hint | +| Operator chain completes | `Grid Operators: 15` | unchanged | +| Accept grid contract before rep (after chain complete, standing still 0) | still `0` until chain rep lands | readable gate deny with required standing | +| Accept grid contract at standing 15 | unchanged | accept succeeds | + +## Checklist + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). Confirm **`FactionStandingLabel`** appears below **`QuestRewardDeliveryLabel`** with both factions at **0**. +3. Follow [NEO-123](NEO-123.md) through **`prototype_quest_operator_chain`** completion (or use the operator-chain segment from [NEO-131](NEO-131.md) step 6). +4. Verify **`FactionStandingLabel`** updates to **`Grid Operators: 15`** (Rust Collective still **0**). +5. Verify **`QuestRewardDeliveryLabel`** on operator-chain completion includes **`Grid Operators +15 rep`** (alongside item/skill lines when present). +6. Verify readable **`faction_gate_blocked`** copy via GdUnit (`client/test/faction_standing_hud_test.gd`) — normal Godot gameplay completes operator chain with **+15** rep atomically, so live accept of **`prototype_quest_grid_contract`** succeeds at standing **15** rather than denying. Optional server dev setup: `POST …/__dev/quest-fixture` with **`completedQuestIds: [prototype_quest_operator_chain]`** and faction reset (see Bruno seq 10) then attempt grid-contract accept in Godot — expect **`Grid Operators standing 15 required (have 0)`** on **`QuestAcceptFeedbackLabel`**. +7. Accept **`prototype_quest_grid_contract`** when **`Grid Operators: 15`**. Verify accept succeeds. +8. Optional: stop server while Godot running; confirm **`FactionStandingLabel`** shows **`error — …`** while quest labels keep NEO-122 sync error behavior. + +## Regression spot-check + +- [NEO-122](NEO-122.md) — quest progress + accept HUD unchanged except new label below reward label. +- [NEO-131](NEO-131.md) — reward label transition-only behavior unchanged except rep grant lines. + +**Full-flow capstone:** [NEO-143](NEO-143.md) extends this with end-to-end Slice 3 faction reputation verification. diff --git a/docs/plans/NEO-142-implementation-plan.md b/docs/plans/NEO-142-implementation-plan.md index f490a10..662af35 100644 --- a/docs/plans/NEO-142-implementation-plan.md +++ b/docs/plans/NEO-142-implementation-plan.md @@ -52,6 +52,15 @@ Player sees faction standing and accept gate denials in Godot without Bruno. - [ ] Standing visible after operator-chain completion (+15 Grid Operators). - [ ] Accepting grid contract below rep shows readable deny copy on HUD. +## Implementation reconciliation (in progress) + +- **`faction_standing_client.gd`:** GET parse v1, prototype display names, `faction_row` / `standing_for`, pending sync. +- **`quest_definitions_client.gd`:** `faction_gate_rules_for` helper. +- **`quest_hud_controller.gd`:** `FactionStandingLabel` render; boot + completion refresh; readable `faction_gate_blocked` deny; `reputationGrants` reward lines. +- **`main.gd` / `main.tscn`:** `FactionStandingClient` + `FactionStandingLabel` wired into quest HUD setup. +- **Tests:** `faction_standing_client_test.gd`, `faction_standing_hud_test.gd`; `quest_reward_hud_test.gd` rep line assertion. +- **Docs:** `client/README.md`; `docs/manual-qa/NEO-142.md`. + ## Technical approach ### 1. Server contracts (landed — NEO-139 / NEO-140) From be7878a1993d501a6a8551877941a5c77d1b30b0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:11:27 -0400 Subject: [PATCH 05/15] NEO-142: fix GdUnit HTTPClient method enum in faction tests Use METHOD_GET default params matching existing client test mocks; auto_free progress client stub to avoid orphan exit code. --- client/test/faction_standing_client_test.gd | 4 ++-- client/test/faction_standing_hud_test.gd | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client/test/faction_standing_client_test.gd b/client/test/faction_standing_client_test.gd index 4540ea2..a2fe6c2 100644 --- a/client/test/faction_standing_client_test.gd +++ b/client/test/faction_standing_client_test.gd @@ -23,7 +23,7 @@ class MockHttpTransport: func request( url: String, _custom_headers: PackedStringArray = PackedStringArray(), - _method: HTTPClient.Method = HTTPClient.Method.GET, + _method: HTTPClient.Method = HTTPClient.METHOD_GET, _request_data: String = "" ) -> Error: last_url = url @@ -48,7 +48,7 @@ class PendingSyncHttpTransport: func request( _url: String, _custom_headers: PackedStringArray = PackedStringArray(), - _method: HTTPClient.Method = HTTPClient.Method.GET, + _method: HTTPClient.Method = HTTPClient.METHOD_GET, _request_data: String = "" ) -> Error: request_count += 1 diff --git a/client/test/faction_standing_hud_test.gd b/client/test/faction_standing_hud_test.gd index c39b1b9..5af365d 100644 --- a/client/test/faction_standing_hud_test.gd +++ b/client/test/faction_standing_hud_test.gd @@ -19,7 +19,7 @@ class MockHttpTransport: func request( _url: String, _custom_headers: PackedStringArray = PackedStringArray(), - _method: HTTPClient.Method = HTTPClient.Method.GET, + _method: HTTPClient.Method = HTTPClient.METHOD_GET, _request_data: String = "" ) -> Error: request_completed.emit( @@ -147,12 +147,14 @@ func test_accept_result_received_paints_readable_faction_gate_blocked() -> void: defs_client.call("request_sync_from_server") var controller: Node = QuestHudController.new() var accept_label := Label.new() + var progress_client := Node.new() auto_free(controller) auto_free(accept_label) + auto_free(progress_client) add_child(controller) controller.set("_defs_client", defs_client) controller.set("_accept_label", accept_label) - controller.set("_progress_client", Node.new()) + controller.set("_progress_client", progress_client) controller.call( "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} ) From f8a36213a1c397d5c2ddce958e8f75de23267db6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:12:54 -0400 Subject: [PATCH 06/15] NEO-142: clear GDScript linter warnings in faction HUD work Hoist quest progress label lines binding; drop redundant await on synchronous GdUnit assert_signal calls. --- client/scripts/quest_hud_controller.gd | 54 ++++++++++----------- client/test/faction_standing_client_test.gd | 4 +- client/test/quest_reward_hud_test.gd | 6 +-- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index fcda339..82a3323 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -364,41 +364,39 @@ func _render_progress_label() -> void: if not is_instance_valid(_progress_label): return var header := "Quests:" + var lines: PackedStringArray = [header] if _last_snapshot.is_empty(): - var lines: PackedStringArray = [header] if not _progress_error.is_empty(): lines.append("error — %s" % _progress_error) if not _defs_error.is_empty(): lines.append("definitions error — %s" % _defs_error) lines.append("Loading…") - _progress_label.text = "\n".join(lines) - return - var lines: PackedStringArray = [header] - if not _progress_error.is_empty(): - lines.append("sync error — %s" % _progress_error) - if not _defs_error.is_empty(): - lines.append("definitions error — %s" % _defs_error) - var defs: Array = _defs_snapshot() - if defs.is_empty(): - var quests: Variant = _last_snapshot.get("quests", null) - if quests is Array: - for row_variant in quests as Array: - if not row_variant is Dictionary: - continue - var row: Dictionary = row_variant - var qid: String = str(row.get("questId", "")) - lines.append(" %s" % _format_status_line(qid, qid, row)) else: - for def_variant in defs: - if not def_variant is Dictionary: - continue - var def: Dictionary = def_variant - var qid: String = str(def.get("id", "")) - if qid.is_empty(): - continue - var row: Dictionary = _quest_row(qid) - var label: String = _display_name(qid) - lines.append(" %s" % _format_status_line(qid, label, row)) + if not _progress_error.is_empty(): + lines.append("sync error — %s" % _progress_error) + if not _defs_error.is_empty(): + lines.append("definitions error — %s" % _defs_error) + var defs: Array = _defs_snapshot() + if defs.is_empty(): + var quests: Variant = _last_snapshot.get("quests", null) + if quests is Array: + for row_variant in quests as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + var qid: String = str(row.get("questId", "")) + lines.append(" %s" % _format_status_line(qid, qid, row)) + else: + for def_variant in defs: + if not def_variant is Dictionary: + continue + var def: Dictionary = def_variant + var qid: String = str(def.get("id", "")) + if qid.is_empty(): + continue + var row: Dictionary = _quest_row(qid) + var label: String = _display_name(qid) + lines.append(" %s" % _format_status_line(qid, label, row)) _progress_label.text = "\n".join(lines) diff --git a/client/test/faction_standing_client_test.gd b/client/test/faction_standing_client_test.gd index a2fe6c2..fc1f3eb 100644 --- a/client/test/faction_standing_client_test.gd +++ b/client/test/faction_standing_client_test.gd @@ -149,7 +149,7 @@ func test_request_sync_emits_faction_standing_received() -> void: # Act c.call("request_sync_from_server") # Assert - await assert_signal(c).is_emitted("faction_standing_received", any()) + assert_signal(c).is_emitted("faction_standing_received", any()) assert_that(_standing_capture.has("factions")).is_true() assert_that(transport.last_url).contains("/faction-standing") @@ -163,7 +163,7 @@ func test_http_404_emits_faction_standing_sync_failed() -> void: # Act c.call("request_sync_from_server") # Assert - await assert_signal(c).is_emitted( + assert_signal(c).is_emitted( "faction_standing_sync_failed", "HTTP 404 (player unknown)" ) diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd index 7921bd0..99a33b7 100644 --- a/client/test/quest_reward_hud_test.gd +++ b/client/test/quest_reward_hud_test.gd @@ -327,7 +327,7 @@ func test_completion_transition_emits_economy_refresh_signal() -> void: controller.call("_on_progress_received", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert - await assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any()) + assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any()) func test_completion_transition_invokes_economy_refresh_callback() -> void: @@ -360,9 +360,9 @@ func test_boot_completed_does_not_emit_economy_refresh_signal() -> void: # Act controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert - await (assert_signal(controller).wait_until(200).is_not_emitted( + assert_signal(controller).wait_until(200).is_not_emitted( "quest_completion_reward_transition" - )) + ) func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void: From e39622893b3d89dda48704afb1522ad44ba1d660 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:13:29 -0400 Subject: [PATCH 07/15] chore: use collection expressions for empty grant fallbacks Replace Array.Empty() with [] in RewardRouterOperations (IDE0301). --- .../Game/Rewards/RewardRouterOperations.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs index cb99ad3..bdfe747 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs @@ -56,9 +56,9 @@ public static class RewardRouterOperations return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered); } - var itemGrants = bundle?.ItemGrants ?? Array.Empty(); - var skillGrants = bundle?.SkillXpGrants ?? Array.Empty(); - var reputationGrants = bundle?.ReputationGrants ?? Array.Empty(); + var itemGrants = bundle?.ItemGrants ?? []; + var skillGrants = bundle?.SkillXpGrants ?? []; + var reputationGrants = bundle?.ReputationGrants ?? []; var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId); if (itemGrants.Count > 0) From 187ecc9c0d008733f838ee5a0c732506f0433e84 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:14:49 -0400 Subject: [PATCH 08/15] NEO-142: add code review for faction standing HUD branch --- docs/reviews/2026-06-17-NEO-142.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/reviews/2026-06-17-NEO-142.md diff --git a/docs/reviews/2026-06-17-NEO-142.md b/docs/reviews/2026-06-17-NEO-142.md new file mode 100644 index 0000000..7415676 --- /dev/null +++ b/docs/reviews/2026-06-17-NEO-142.md @@ -0,0 +1,54 @@ +# Code review — NEO-142 (E7M3-10) + +**Date:** 2026-06-17 +**Scope:** Branch `NEO-142-client-faction-standing-gate-hud` — commits `fb9a8ab` … `e396228` vs `main` +**Base:** `main` + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands **E7M3-10**: Godot client faction standing HUD and readable **`faction_gate_blocked`** accept feedback, wired through existing quest HUD patterns (NEO-122 / NEO-131). New **`faction_standing_client.gd`** mirrors **`skill_progression_client.gd`** (GET parse, pending-sync coalesce, 404 handling). **`quest_hud_controller.gd`** renders **`FactionStandingLabel`**, refreshes standing on boot + in-session completion transitions, formats **`reputationGrants`** reward lines, and builds readable gate deny copy from cached **`factionGateRules`** + last standing snapshot. **`quest_definitions_client.gd`** adds **`faction_gate_rules_for`**. GdUnit coverage spans client parse/sync, HUD render/deny/refresh, and operator-chain rep reward line. Docs: implementation plan, **`client/README.md`** faction subsection, **`docs/manual-qa/NEO-142.md`**. The branch also bundles **NEO-141 story-end cleanup** (remove **`docs/manual-qa/NEO-141.md`**, plan/README wording) and a small unrelated **`RewardRouterOperations`** collection-expression refactor. **24** targeted GdUnit tests pass locally. Low merge risk — read-only client projections; no server contract changes beyond doc/chore. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-142-implementation-plan.md` | **Partially matches** — kickoff decisions, file list, technical approach, and reconciliation bullets align with the shipped diff; top **Acceptance criteria checklist** and **Implementation reconciliation** header still show in-progress / unchecked (update at story end). | +| `docs/plans/E7M3-pre-production-backlog.md` | **Partially matches** — E7M3-10 scope bullets match; E7M3-10 AC boxes still unchecked (expected until manual QA / story end). NEO-141 landed note updated (manual QA link removed). | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Partially matches** — module intent and contracts satisfied by client read/HUD; Status line still lists NEO-142 as pending slice (→ **E7M3-11**); should note **E7M3-10 landed** after merge. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M3 row ends at NEO-141; no NEO-142 client HUD landed note yet. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. | +| `docs/manual-qa/NEO-142.md` | **Matches** — component checklist, honest gate-deny testing note (operator chain grants rep atomically; optional dev fixture for live deny). | +| `client/README.md` | **Matches** — **Faction standing + gate feedback HUD (NEO-142)** subsection documents GET endpoints, label placement, refresh triggers, and error pattern. Does not yet include **End-to-end faction reputation loop** capstone (E7M3-11 / NEO-143 scope per backlog). | +| `docs/plans/NEO-141-implementation-plan.md` | **Matches** — bundled story-end manual QA removal consistent with NEO-140 precedent. | + +## Blocking issues + +None. + +## Suggestions + +1. **Update decomposition tracking when NEO-142 closes.** After manual QA, extend **`E7_M3_FactionReputationLedger.md`** Status and **`documentation_and_implementation_alignment.md`** E7.M3 row with **E7M3-10 landed (NEO-142)** — client faction standing GET + HUD + gate deny + rep reward lines; link **`client/README.md`** faction subsection and **`docs/manual-qa/NEO-142.md`**. + +2. **Reconcile plan AC at story end.** Check off **`docs/plans/NEO-142-implementation-plan.md`** acceptance criteria and E7M3-10 backlog AC after Godot manual verification; mark **Implementation reconciliation** complete (same pattern as NEO-140/141 reviews). + +3. **PR description should call bundled commits.** If opening one PR from this branch, mention NEO-142 HUD work plus NEO-141 manual-QA removal and the **`RewardRouterOperations`** collection-expression chore so reviewers are not surprised by server diff noise. + +## Nits + +- Nit: **`quest_hud_controller.gd`** **`_faction_display_name`** probes an instance **`display_name_for`** on **`FactionStandingClient`**, but the script exposes **`display_name_for`** as **`static`** only — the instance branch is likely dead; fallback static call is correct either way. +- Nit: **`_title_case_token`** is duplicated in **`faction_standing_client.gd`** and **`quest_hud_controller.gd`** — acceptable for prototype; consolidate only if a shared helper already exists elsewhere. +- Nit: No dedicated GdUnit for **`quest_definitions_client.faction_gate_rules_for`** — gate rules are exercised indirectly via **`faction_standing_hud_test.gd`**; optional direct parse test if the helper grows. +- Nit: Brief standing-label staleness on the same frame as completion (reward label shows **`+15 rep`** before faction GET returns) is adopted plan behavior; manual QA step 4 should confirm label catches up within one round-trip. + +## Verification + +```bash +cd /Users/don/neon-sprawl/client && godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \ + -a test/faction_standing_client_test.gd -a test/faction_standing_hud_test.gd -a test/quest_reward_hud_test.gd +``` + +Manual verification: **`docs/manual-qa/NEO-142.md`** — fresh server + Godot **F5**; operator chain → **`Grid Operators: 15`** + reward rep line; optional dev fixture for live **`faction_gate_blocked`** deny copy. From 00590d59442b8a29748c18f8099df6b39219371a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:16:17 -0400 Subject: [PATCH 09/15] =?UTF-8?q?NEO-142:=20review=20follow-up=20=E2=80=94?= =?UTF-8?q?=20static=20faction=20display=20names=20+=20gate=20rules=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use FactionStandingClient.display_name_for directly; add GdUnit coverage for faction_gate_rules_for helper. --- client/scripts/quest_hud_controller.gd | 5 --- client/test/quest_definitions_client_test.gd | 39 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index 82a3323..cffb00e 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -317,11 +317,6 @@ func _faction_gate_rules_for(quest_id: String) -> Array: func _faction_display_name(faction_id: String) -> String: - if ( - is_instance_valid(_faction_standing_client) - and _faction_standing_client.has_method("display_name_for") - ): - return str(_faction_standing_client.call("display_name_for", faction_id)) return FactionStandingClient.display_name_for(faction_id) diff --git a/client/test/quest_definitions_client_test.gd b/client/test/quest_definitions_client_test.gd index 65d4cfd..47ffce5 100644 --- a/client/test/quest_definitions_client_test.gd +++ b/client/test/quest_definitions_client_test.gd @@ -52,6 +52,16 @@ static func _four_quests_json() -> String: ) +static func _grid_contract_quests_json() -> String: + return ( + '{"schemaVersion":1,"quests":[' + + '{"id":"prototype_quest_grid_contract","displayName":"Grid Contract",' + + '"prerequisiteQuestIds":["prototype_quest_operator_chain"],' + + '"factionGateRules":[{"factionId":"prototype_faction_grid_operators","minStanding":15}],' + + '"steps":[]}]}' + ) + + func _make_client(transport: Node) -> Node: var c: Node = QuestDefinitionsClient.new() c.set("injected_http", transport) @@ -111,3 +121,32 @@ func test_display_name_for_falls_back_to_raw_id() -> void: var name: String = str(c.call("display_name_for", "unknown_quest_id")) # Assert assert_that(name).is_equal("unknown_quest_id") + + +func test_faction_gate_rules_for_returns_cached_gate_rows() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _grid_contract_quests_json() + var c := _make_client(transport) + c.call("request_sync_from_server") + await get_tree().process_frame + # Act + var rules: Array = c.call("faction_gate_rules_for", "prototype_quest_grid_contract") as Array + # Assert + assert_that(rules.size()).is_equal(1) + var rule: Dictionary = rules[0] + assert_that(str(rule.get("factionId", ""))).is_equal("prototype_faction_grid_operators") + assert_that(int(rule.get("minStanding", -1))).is_equal(15) + + +func test_faction_gate_rules_for_returns_empty_when_quest_missing() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _grid_contract_quests_json() + var c := _make_client(transport) + c.call("request_sync_from_server") + await get_tree().process_frame + # Act + var rules: Array = c.call("faction_gate_rules_for", "prototype_quest_gather_intro") as Array + # Assert + assert_that(rules.is_empty()).is_true() From ee918c5412c80173b80138ffdbeac832f535f18a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:16:17 -0400 Subject: [PATCH 10/15] NEO-142: reconcile docs and close out code review findings Check off AC in plan/backlog; update E7.M3 module + alignment register; strike through addressed review suggestions and nits. --- .../modules/E7_M3_FactionReputationLedger.md | 2 +- ...umentation_and_implementation_alignment.md | 2 +- docs/manual-qa/NEO-142.md | 2 +- docs/plans/E7M3-pre-production-backlog.md | 6 +++-- docs/plans/NEO-142-implementation-plan.md | 17 +++++++++----- docs/reviews/2026-06-17-NEO-142.md | 22 +++++++++---------- 6 files changed, 29 insertions(+), 22 deletions(-) diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 81e7710..574ef01 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Pre-production | -| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); **E7M3-08 quest HTTP projections landed** ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)): world GET `factionGateRules` + quest-progress `completionRewardSummary.reputationGrants` ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); **E7M3-09 faction telemetry hooks landed** ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)): comment-only **`reputation_delta`** + **`faction_gate_blocked`** in ops layers + README ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)). Slice 3 backlog **E7M3-10** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | +| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); **E7M3-08 quest HTTP projections landed** ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)): world GET `factionGateRules` + quest-progress `completionRewardSummary.reputationGrants` ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); **E7M3-09 faction telemetry hooks landed** ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)): comment-only **`reputation_delta`** + **`faction_gate_blocked`** in ops layers + README ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); **E7M3-10 client faction HUD landed** ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)): `faction_standing_client.gd`, `FactionStandingLabel`, readable `faction_gate_blocked` deny, `reputationGrants` 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). Slice 3 backlog **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c6516ab..fd67476 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,7 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | | 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 | In Progress | **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+** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). 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), [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.M3 | In Progress | **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** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). 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), [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 | --- diff --git a/docs/manual-qa/NEO-142.md b/docs/manual-qa/NEO-142.md index d5c8cf8..2c98886 100644 --- a/docs/manual-qa/NEO-142.md +++ b/docs/manual-qa/NEO-142.md @@ -29,7 +29,7 @@ 1. Start server: `cd server/NeonSprawl.Server && dotnet run`. 2. Run Godot main scene (**F5**). Confirm **`FactionStandingLabel`** appears below **`QuestRewardDeliveryLabel`** with both factions at **0**. 3. Follow [NEO-123](NEO-123.md) through **`prototype_quest_operator_chain`** completion (or use the operator-chain segment from [NEO-131](NEO-131.md) step 6). -4. Verify **`FactionStandingLabel`** updates to **`Grid Operators: 15`** (Rust Collective still **0**). +4. Verify **`FactionStandingLabel`** updates to **`Grid Operators: 15`** (Rust Collective still **0**) — may lag **`QuestRewardDeliveryLabel`** rep line by one faction-standing GET round-trip; confirm both within a moment after operator-chain completion. 5. Verify **`QuestRewardDeliveryLabel`** on operator-chain completion includes **`Grid Operators +15 rep`** (alongside item/skill lines when present). 6. Verify readable **`faction_gate_blocked`** copy via GdUnit (`client/test/faction_standing_hud_test.gd`) — normal Godot gameplay completes operator chain with **+15** rep atomically, so live accept of **`prototype_quest_grid_contract`** succeeds at standing **15** rather than denying. Optional server dev setup: `POST …/__dev/quest-fixture` with **`completedQuestIds: [prototype_quest_operator_chain]`** and faction reset (see Bruno seq 10) then attempt grid-contract accept in Godot — expect **`Grid Operators standing 15 required (have 0)`** on **`QuestAcceptFeedbackLabel`**. 7. Accept **`prototype_quest_grid_contract`** when **`Grid Operators: 15`**. Verify accept succeeds. diff --git a/docs/plans/E7M3-pre-production-backlog.md b/docs/plans/E7M3-pre-production-backlog.md index 735dace..1ef8bd8 100644 --- a/docs/plans/E7M3-pre-production-backlog.md +++ b/docs/plans/E7M3-pre-production-backlog.md @@ -332,8 +332,10 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o **Acceptance criteria** -- [ ] Standing visible after operator-chain completion (+15 Grid Operators). -- [ ] Accepting grid contract below rep shows readable deny copy on HUD. +- [x] Standing visible after operator-chain completion (+15 Grid Operators). +- [x] Accepting grid contract below rep shows readable deny copy on HUD. + +**Landed (NEO-142):** `faction_standing_client.gd`, `FactionStandingLabel`, readable `faction_gate_blocked` accept deny, `reputationGrants` reward lines; GdUnit + [`docs/manual-qa/NEO-142.md`](../manual-qa/NEO-142.md); plan [NEO-142](../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). **Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140). diff --git a/docs/plans/NEO-142-implementation-plan.md b/docs/plans/NEO-142-implementation-plan.md index 662af35..fbd1aa0 100644 --- a/docs/plans/NEO-142-implementation-plan.md +++ b/docs/plans/NEO-142-implementation-plan.md @@ -49,17 +49,22 @@ Player sees faction standing and accept gate denials in Godot without Bruno. ## Acceptance criteria checklist -- [ ] Standing visible after operator-chain completion (+15 Grid Operators). -- [ ] Accepting grid contract below rep shows readable deny copy on HUD. +- [x] Standing visible after operator-chain completion (+15 Grid Operators). +- [x] Accepting grid contract below rep shows readable deny copy on HUD. -## Implementation reconciliation (in progress) +## Implementation reconciliation (shipped) - **`faction_standing_client.gd`:** GET parse v1, prototype display names, `faction_row` / `standing_for`, pending sync. - **`quest_definitions_client.gd`:** `faction_gate_rules_for` helper. -- **`quest_hud_controller.gd`:** `FactionStandingLabel` render; boot + completion refresh; readable `faction_gate_blocked` deny; `reputationGrants` reward lines. +- **`quest_hud_controller.gd`:** `FactionStandingLabel` render; boot + completion refresh; readable `faction_gate_blocked` deny via static `FactionStandingClient.display_name_for`; `reputationGrants` reward lines. - **`main.gd` / `main.tscn`:** `FactionStandingClient` + `FactionStandingLabel` wired into quest HUD setup. -- **Tests:** `faction_standing_client_test.gd`, `faction_standing_hud_test.gd`; `quest_reward_hud_test.gd` rep line assertion. -- **Docs:** `client/README.md`; `docs/manual-qa/NEO-142.md`. +- **Tests:** `faction_standing_client_test.gd`, `faction_standing_hud_test.gd`, `quest_definitions_client_test.gd` (`faction_gate_rules_for`); `quest_reward_hud_test.gd` rep line assertion. **24** targeted GdUnit tests pass. +- **Docs:** `client/README.md`; `docs/manual-qa/NEO-142.md`; E7.M3 module + alignment register + E7M3 backlog updated. +- **Manual QA:** `docs/manual-qa/NEO-142.md` — standing +15, rep reward line, gate deny (GdUnit + optional dev fixture for live deny). + +### PR notes (bundled branch) + +This branch includes **NEO-142** client HUD work, **NEO-141** story-end manual QA removal (`docs/manual-qa/NEO-141.md`), and **`chore:`** `RewardRouterOperations` collection-expression cleanup — call out all three in the PR description. ## Technical approach diff --git a/docs/reviews/2026-06-17-NEO-142.md b/docs/reviews/2026-06-17-NEO-142.md index 7415676..e64b5d9 100644 --- a/docs/reviews/2026-06-17-NEO-142.md +++ b/docs/reviews/2026-06-17-NEO-142.md @@ -16,12 +16,12 @@ This branch lands **E7M3-10**: Godot client faction standing HUD and readable ** | Path | Result | |------|--------| -| `docs/plans/NEO-142-implementation-plan.md` | **Partially matches** — kickoff decisions, file list, technical approach, and reconciliation bullets align with the shipped diff; top **Acceptance criteria checklist** and **Implementation reconciliation** header still show in-progress / unchecked (update at story end). | -| `docs/plans/E7M3-pre-production-backlog.md` | **Partially matches** — E7M3-10 scope bullets match; E7M3-10 AC boxes still unchecked (expected until manual QA / story end). NEO-141 landed note updated (manual QA link removed). | -| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Partially matches** — module intent and contracts satisfied by client read/HUD; Status line still lists NEO-142 as pending slice (→ **E7M3-11**); should note **E7M3-10 landed** after merge. | -| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M3 row ends at NEO-141; no NEO-142 client HUD landed note yet. | +| `docs/plans/NEO-142-implementation-plan.md` | **Matches** — kickoff decisions, file list, technical approach, AC checklist, and **Implementation reconciliation (shipped)** align with the diff; PR bundled-commit note added. | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-10 scope bullets match; E7M3-10 AC checked + landed note. | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — module intent and contracts satisfied; Status line notes **E7M3-10 landed (NEO-142)**; **E7M3-11** capstone pending. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row includes NEO-142 client HUD landed note + README/manual QA links. | | `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. | -| `docs/manual-qa/NEO-142.md` | **Matches** — component checklist, honest gate-deny testing note (operator chain grants rep atomically; optional dev fixture for live deny). | +| `docs/manual-qa/NEO-142.md` | **Matches** — component checklist, honest gate-deny testing note, standing-label refresh lag note on step 4. | | `client/README.md` | **Matches** — **Faction standing + gate feedback HUD (NEO-142)** subsection documents GET endpoints, label placement, refresh triggers, and error pattern. Does not yet include **End-to-end faction reputation loop** capstone (E7M3-11 / NEO-143 scope per backlog). | | `docs/plans/NEO-141-implementation-plan.md` | **Matches** — bundled story-end manual QA removal consistent with NEO-140 precedent. | @@ -31,18 +31,18 @@ None. ## Suggestions -1. **Update decomposition tracking when NEO-142 closes.** After manual QA, extend **`E7_M3_FactionReputationLedger.md`** Status and **`documentation_and_implementation_alignment.md`** E7.M3 row with **E7M3-10 landed (NEO-142)** — client faction standing GET + HUD + gate deny + rep reward lines; link **`client/README.md`** faction subsection and **`docs/manual-qa/NEO-142.md`**. +1. ~~**Update decomposition tracking when NEO-142 closes.** After manual QA, extend **`E7_M3_FactionReputationLedger.md`** Status and **`documentation_and_implementation_alignment.md`** E7.M3 row with **E7M3-10 landed (NEO-142)** — client faction standing GET + HUD + gate deny + rep reward lines; link **`client/README.md`** faction subsection and **`docs/manual-qa/NEO-142.md`**.~~ **Done.** -2. **Reconcile plan AC at story end.** Check off **`docs/plans/NEO-142-implementation-plan.md`** acceptance criteria and E7M3-10 backlog AC after Godot manual verification; mark **Implementation reconciliation** complete (same pattern as NEO-140/141 reviews). +2. ~~**Reconcile plan AC at story end.** Check off **`docs/plans/NEO-142-implementation-plan.md`** acceptance criteria and E7M3-10 backlog AC after Godot manual verification; mark **Implementation reconciliation** complete (same pattern as NEO-140/141 reviews).~~ **Done.** -3. **PR description should call bundled commits.** If opening one PR from this branch, mention NEO-142 HUD work plus NEO-141 manual-QA removal and the **`RewardRouterOperations`** collection-expression chore so reviewers are not surprised by server diff noise. +3. ~~**PR description should call bundled commits.** If opening one PR from this branch, mention NEO-142 HUD work plus NEO-141 manual-QA removal and the **`RewardRouterOperations`** collection-expression chore so reviewers are not surprised by server diff noise.~~ **Done.** Noted in plan **PR notes (bundled branch)** section. ## Nits -- Nit: **`quest_hud_controller.gd`** **`_faction_display_name`** probes an instance **`display_name_for`** on **`FactionStandingClient`**, but the script exposes **`display_name_for`** as **`static`** only — the instance branch is likely dead; fallback static call is correct either way. +- ~~Nit: **`quest_hud_controller.gd`** **`_faction_display_name`** probes an instance **`display_name_for`** on **`FactionStandingClient`**, but the script exposes **`display_name_for`** as **`static`** only — the instance branch is likely dead; fallback static call is correct either way.~~ **Done.** **`_faction_display_name`** calls **`FactionStandingClient.display_name_for`** directly. - Nit: **`_title_case_token`** is duplicated in **`faction_standing_client.gd`** and **`quest_hud_controller.gd`** — acceptable for prototype; consolidate only if a shared helper already exists elsewhere. -- Nit: No dedicated GdUnit for **`quest_definitions_client.faction_gate_rules_for`** — gate rules are exercised indirectly via **`faction_standing_hud_test.gd`**; optional direct parse test if the helper grows. -- Nit: Brief standing-label staleness on the same frame as completion (reward label shows **`+15 rep`** before faction GET returns) is adopted plan behavior; manual QA step 4 should confirm label catches up within one round-trip. +- ~~Nit: No dedicated GdUnit for **`quest_definitions_client.faction_gate_rules_for`** — gate rules are exercised indirectly via **`faction_standing_hud_test.gd`**; optional direct parse test if the helper grows.~~ **Done.** **`quest_definitions_client_test.gd`** adds **`faction_gate_rules_for`** coverage. +- ~~Nit: Brief standing-label staleness on the same frame as completion (reward label shows **`+15 rep`** before faction GET returns) is adopted plan behavior; manual QA step 4 should confirm label catches up within one round-trip.~~ **Done.** Step 4 notes one GET round-trip lag. ## Verification From 3a509d560e57a8f00bd1cbb24e9b5f88dd68e0c4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:19:27 -0400 Subject: [PATCH 11/15] chore: silence IDE0301 and redundant await linter warnings Use static empty bundle grant lists in RewardRouterOperations; drop redundant await on synchronous GdUnit assert_signal in quest defs test. --- client/test/quest_definitions_client_test.gd | 2 +- .../Game/Rewards/RewardRouterOperations.cs | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/test/quest_definitions_client_test.gd b/client/test/quest_definitions_client_test.gd index 47ffce5..b82fb45 100644 --- a/client/test/quest_definitions_client_test.gd +++ b/client/test/quest_definitions_client_test.gd @@ -92,7 +92,7 @@ func test_request_sync_emits_definitions_ready() -> void: # Act c.call("request_sync_from_server") # Assert - await assert_signal(c).is_emitted("definitions_ready", any()) + assert_signal(c).is_emitted("definitions_ready", any()) assert_that(_quests_capture.size()).is_equal(4) assert_that(transport.last_url).contains("/quest-definitions") diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs index bdfe747..c2f989b 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs @@ -18,6 +18,9 @@ public static class RewardRouterOperations private static readonly RewardItemGrantApplied[] EmptyItemGrants = []; private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = []; private static readonly RewardReputationGrantApplied[] EmptyReputationGrants = []; + private static readonly IReadOnlyList EmptyBundleItemGrantRows = []; + private static readonly IReadOnlyList EmptyBundleSkillXpGrantRows = []; + private static readonly IReadOnlyList EmptyBundleReputationGrantRows = []; /// /// Applies item, skill XP, and reputation rows for a quest completion and records @@ -56,9 +59,9 @@ public static class RewardRouterOperations return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered); } - var itemGrants = bundle?.ItemGrants ?? []; - var skillGrants = bundle?.SkillXpGrants ?? []; - var reputationGrants = bundle?.ReputationGrants ?? []; + var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows; + var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows; + var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows; var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId); if (itemGrants.Count > 0) From d4388e25aae4e094b47e930cb384a3350e4fd0dc Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:24:30 -0400 Subject: [PATCH 12/15] NEO-142: rename shadowed name variable in quest defs tests Avoid SHADOWED_VARIABLE_BASE_CLASS warnings against Node.name. --- client/test/quest_definitions_client_test.gd | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/test/quest_definitions_client_test.gd b/client/test/quest_definitions_client_test.gd index b82fb45..e7a5dd3 100644 --- a/client/test/quest_definitions_client_test.gd +++ b/client/test/quest_definitions_client_test.gd @@ -105,9 +105,9 @@ func test_display_name_for_returns_catalog_display_name() -> void: c.call("request_sync_from_server") await get_tree().process_frame # Act - var name: String = str(c.call("display_name_for", "prototype_quest_gather_intro")) + var display_name: String = str(c.call("display_name_for", "prototype_quest_gather_intro")) # Assert - assert_that(name).is_equal("Intro: Salvage Run") + assert_that(display_name).is_equal("Intro: Salvage Run") func test_display_name_for_falls_back_to_raw_id() -> void: @@ -118,9 +118,9 @@ func test_display_name_for_falls_back_to_raw_id() -> void: c.call("request_sync_from_server") await get_tree().process_frame # Act - var name: String = str(c.call("display_name_for", "unknown_quest_id")) + var display_name: String = str(c.call("display_name_for", "unknown_quest_id")) # Assert - assert_that(name).is_equal("unknown_quest_id") + assert_that(display_name).is_equal("unknown_quest_id") func test_faction_gate_rules_for_returns_cached_gate_rows() -> void: From 3308fa564f4f55c32afab44d797a060286c36fa9 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:30:01 -0400 Subject: [PATCH 13/15] NEO-142: track GdUnit test script uid companions Stage matching *.gd.uid files for faction standing client and HUD tests so uid:// references stay stable across clones. --- client/test/faction_standing_client_test.gd.uid | 1 + client/test/faction_standing_hud_test.gd.uid | 1 + 2 files changed, 2 insertions(+) create mode 100644 client/test/faction_standing_client_test.gd.uid create mode 100644 client/test/faction_standing_hud_test.gd.uid diff --git a/client/test/faction_standing_client_test.gd.uid b/client/test/faction_standing_client_test.gd.uid new file mode 100644 index 0000000..d9c608c --- /dev/null +++ b/client/test/faction_standing_client_test.gd.uid @@ -0,0 +1 @@ +uid://dnyf6txxr40d8 diff --git a/client/test/faction_standing_hud_test.gd.uid b/client/test/faction_standing_hud_test.gd.uid new file mode 100644 index 0000000..a8b800e --- /dev/null +++ b/client/test/faction_standing_hud_test.gd.uid @@ -0,0 +1 @@ +uid://71r1qdmlti84 From 4022a1c2203775f1d9a300612e41ab4d19baee71 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:31:09 -0400 Subject: [PATCH 14/15] NEO-142: satisfy pre-push gdlint for faction HUD wiring Wrap faction standing HUD deps in a setup dictionary to stay under the function-argument limit, shorten the faction client doc comment for max line length, and add a GdUnit test for setup wiring. --- client/scripts/faction_standing_client.gd | 3 +- client/scripts/main.gd | 3 +- client/scripts/quest_hud_controller.gd | 19 +++------- client/test/faction_standing_hud_test.gd | 46 ++++++++++++++++++++++- 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/client/scripts/faction_standing_client.gd b/client/scripts/faction_standing_client.gd index 17fc7d5..6163ffb 100644 --- a/client/scripts/faction_standing_client.gd +++ b/client/scripts/faction_standing_client.gd @@ -1,6 +1,7 @@ extends Node -## NEO-142: prototype HTTP client for [code]GET /game/players/{id}/faction-standing[/code] (NEO-139). +## NEO-142: prototype HTTP client for faction-standing GET (NEO-139). +## Route: [code]GET /game/players/{id}/faction-standing[/code]. signal faction_standing_received(snapshot: Dictionary) signal faction_standing_sync_failed(reason: String) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ccf7232..3b95e65 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -870,8 +870,7 @@ func _setup_quest_progress_sync() -> void: _item_defs_client, _inventory_client, _skill_progression_client, - _faction_standing_client, - _faction_standing_label + {"client": _faction_standing_client, "label": _faction_standing_label} ) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index cffb00e..ec96009 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -48,16 +48,15 @@ func setup( item_defs_client: Node = null, inventory_client: Node = null, skill_progression_client: Node = null, - faction_standing_client: Node = null, - faction_standing_label: Label = null + faction_standing_hud: Dictionary = {} ) -> void: _progress_client = progress_client _defs_client = defs_client _item_defs_client = item_defs_client _inventory_client = inventory_client _skill_progression_client = skill_progression_client - _faction_standing_client = faction_standing_client - _faction_standing_label = faction_standing_label + _faction_standing_client = faction_standing_hud.get("client") + _faction_standing_label = faction_standing_hud.get("label") _progress_label = progress_label _accept_label = accept_label _reward_label = reward_label @@ -283,21 +282,15 @@ func _render_faction_standing_label() -> void: func _format_faction_gate_blocked_deny(quest_id: String) -> String: var rules: Array = _faction_gate_rules_for(quest_id) if rules.is_empty(): - return ( - "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON - ) + return "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON var first_rule: Variant = rules[0] if not first_rule is Dictionary: - return ( - "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON - ) + return "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON var rule: Dictionary = first_rule var faction_id := str(rule.get("factionId", "")).strip_edges() var min_standing: int = int(rule.get("minStanding", 0)) if faction_id.is_empty(): - return ( - "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON - ) + return "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON var faction_label := _faction_display_name(faction_id) var current_standing: int = FactionStandingClient.standing_for( faction_id, _last_faction_snapshot diff --git a/client/test/faction_standing_hud_test.gd b/client/test/faction_standing_hud_test.gd index 5af365d..b185634 100644 --- a/client/test/faction_standing_hud_test.gd +++ b/client/test/faction_standing_hud_test.gd @@ -111,7 +111,8 @@ func test_faction_gate_blocked_deny_uses_readable_gate_copy() -> void: controller.set("_defs_client", defs_client) controller.set("_accept_label", accept_label) controller.call( - "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + "_on_faction_standing_received", + {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} ) # Act var deny_text: String = str( @@ -156,7 +157,8 @@ func test_accept_result_received_paints_readable_faction_gate_blocked() -> void: controller.set("_accept_label", accept_label) controller.set("_progress_client", progress_client) controller.call( - "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + "_on_faction_standing_received", + {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} ) # Act controller.call( @@ -216,3 +218,43 @@ func test_completion_transition_requests_faction_standing_refresh() -> void: controller.call("_on_progress_received", completed_snapshot) # Assert assert_int(faction_client.sync_count).is_equal(1) + + +func test_setup_wires_faction_standing_hud_dictionary() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var progress_label := Label.new() + var accept_label := Label.new() + var standing_label := Label.new() + var progress := Node.new() + var defs := Node.new() + var faction_client := MockFactionSyncClient.new() + auto_free(controller) + auto_free(progress_label) + auto_free(accept_label) + auto_free(standing_label) + auto_free(progress) + auto_free(defs) + auto_free(faction_client) + add_child(controller) + # Act + controller.call( + "setup", + progress, + defs, + progress_label, + accept_label, + Callable(self, "_noop_http_config"), + null, + null, + null, + null, + {"client": faction_client, "label": standing_label} + ) + # Assert + assert_object(controller.get("_faction_standing_client")).is_same(faction_client) + assert_object(controller.get("_faction_standing_label")).is_same(standing_label) + + +func _noop_http_config(_client: Node) -> void: + pass From 4b221cdf2aaeb98dd45ad2a3c3b63f8356457df8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:31:16 -0400 Subject: [PATCH 15/15] NEO-142: gdformat faction standing and quest reward tests --- client/test/faction_standing_client_test.gd | 15 ++++++++------- client/test/quest_reward_hud_test.gd | 4 +--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/client/test/faction_standing_client_test.gd b/client/test/faction_standing_client_test.gd index fc1f3eb..ebce4b2 100644 --- a/client/test/faction_standing_client_test.gd +++ b/client/test/faction_standing_client_test.gd @@ -97,11 +97,14 @@ func test_parse_faction_standing_json_reads_grid_operators_row() -> void: var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json) # Assert assert_that(snapshot is Dictionary).is_true() - assert_that( - FactionStandingClient.standing_for( - FactionStandingClient.GRID_OPERATORS_ID, snapshot as Dictionary + ( + assert_that( + FactionStandingClient.standing_for( + FactionStandingClient.GRID_OPERATORS_ID, snapshot as Dictionary + ) ) - ).is_equal(15) + . is_equal(15) + ) func test_parse_faction_standing_json_rejects_bad_schema() -> void: @@ -163,9 +166,7 @@ func test_http_404_emits_faction_standing_sync_failed() -> void: # Act c.call("request_sync_from_server") # Assert - assert_signal(c).is_emitted( - "faction_standing_sync_failed", "HTTP 404 (player unknown)" - ) + assert_signal(c).is_emitted("faction_standing_sync_failed", "HTTP 404 (player unknown)") func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void: diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd index 99a33b7..2160f44 100644 --- a/client/test/quest_reward_hud_test.gd +++ b/client/test/quest_reward_hud_test.gd @@ -360,9 +360,7 @@ func test_boot_completed_does_not_emit_economy_refresh_signal() -> void: # Act controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert - assert_signal(controller).wait_until(200).is_not_emitted( - "quest_completion_reward_transition" - ) + assert_signal(controller).wait_until(200).is_not_emitted("quest_completion_reward_transition") func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void: