NEO-153: fix stale issue patch merge and address review findings.

Clear _issue_contract_patch when GET includes the instance row (NEO-122
precedent); wire faction_standing_received for reward re-paint; add regression
test; update E7M4 decomposition docs and review strikethroughs.
pull/195/head
VinPropane 2026-06-28 14:56:12 -04:00
parent 051aae695b
commit 6a48315ba5
7 changed files with 58 additions and 39 deletions

View File

@ -62,6 +62,11 @@ func setup(
_contract_client.connect("contract_issue_failed", Callable(self, "_on_issue_failed"))
if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"):
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
if is_instance_valid(_faction_standing_client):
if _faction_standing_client.has_signal("faction_standing_received"):
_faction_standing_client.connect(
"faction_standing_received", Callable(self, "_on_faction_standing_received")
)
_render_active_label()
_render_issue_feedback_label(ISSUE_IDLE_HINT)
_render_reward_label()
@ -155,6 +160,11 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
_render_reward_label()
func _on_faction_standing_received(_snapshot: Dictionary) -> void:
if not _last_reward_instance_id.is_empty():
_render_reward_label()
func _mark_contract_active_in_session(instance_id: String) -> void:
var iid := instance_id.strip_edges()
if not iid.is_empty():
@ -192,9 +202,16 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void:
func _reapply_issue_contract_patch_if_needed() -> void:
if _issue_contract_patch.is_empty():
return
var patch_id := str(_issue_contract_patch.get("contractInstanceId", "")).strip_edges()
if patch_id.is_empty():
return
var row: Dictionary = ContractClient.contract_row(patch_id, _last_snapshot)
if row.is_empty():
_last_snapshot = ContractClient.merge_contract_row_into_snapshot(
_last_snapshot, _issue_contract_patch
)
else:
_issue_contract_patch = {}
func _refresh_economy_hud() -> void:

View File

@ -218,3 +218,18 @@ func test_reward_label_populates_on_active_to_completed_transition() -> void:
# Assert
assert_str(built["reward_label"].text).contains("Contract rewards:")
assert_str(built["reward_label"].text).contains("Salvage +15 XP")
func test_completed_get_clears_stale_issue_patch_on_active_label() -> void:
# Arrange — simulate successful issue patch still held when encounter-complete GET arrives
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
var issue_row: Dictionary = (_active_snapshot()["contracts"] as Array)[0] as Dictionary
built["controller"].set("_issue_contract_patch", issue_row.duplicate(true))
built["controller"].set("_tracked_instance_id", INSTANCE_ID)
# Act
built["controller"].call("_on_contracts_received", _completed_snapshot())
# Assert
assert_str(built["active_label"].text).contains("completed")
assert_str(built["active_label"].text).not_contains(": active —")

View File

@ -71,6 +71,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Contract telemetry hooks (NEO-152):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** anchor sites in generator/completion ops + shared reward router rollback; [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152); plan [NEO-152](../../plans/NEO-152-implementation-plan.md).
**Contract client HUD (NEO-153):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **`ContractActiveLabel`** / **`ContractIssueFeedbackLabel`** / **`ContractRewardDeliveryLabel`** — **Shift+C** prototype issue, encounter objective display, completion reward transition; [client README — Contract issue + progress HUD (NEO-153)](../../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../../manual-qa/NEO-153.md); plan [NEO-153](../../plans/NEO-153-implementation-plan.md).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -333,10 +333,12 @@ Working backlog for **Epic 7 — Slice 4** ([contract generator](../decompositio
**Acceptance criteria**
- [ ] Active contract visible with encounter objective id after issue.
- [ ] Completed contract shows reward summary line on HUD.
- [x] Active contract visible with encounter objective id after issue.
- [x] Completed contract shows reward summary line on HUD.
**Server counterpart:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152).
**Landed (NEO-153):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **Shift+C** prototype issue, contract HUD labels; [client README — Contract issue + progress HUD (NEO-153)](../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../manual-qa/NEO-153.md); plan [NEO-153](../../plans/NEO-153-implementation-plan.md).
**Server counterpart:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152).
---

View File

@ -58,9 +58,9 @@ Player can **issue** and **track** an active contract in Godot without Bruno —
## Implementation reconciliation (shipped)
- **`contract_client.gd`:** GET/POST parse v1; **`active_contract_row`**, **`completion_reward_summary`**; separate sync/issue HTTP guards.
- **`contract_hud_controller.gd`:** **Shift+C** prototype issue; active/completed label with **`encounterTemplateId`**; deny copy; in-session reward transition + economy refresh.
- **`contract_hud_controller.gd`:** **Shift+C** prototype issue; active/completed label with **`encounterTemplateId`**; deny copy; in-session reward transition + economy refresh; issue patch cleared when GET row exists (NEO-122 accept-patch precedent); faction-standing re-paint on reward label.
- **`main.tscn` / `main.gd`:** **`ContractClient`** node + three HUD labels; encounter-complete contract refresh hook.
- **Tests:** `contract_client_test.gd` (7 cases), `contract_hud_controller_test.gd` (5 cases) — GdUnit green.
- **Tests:** `contract_client_test.gd` (7 cases), `contract_hud_controller_test.gd` (6 cases) — GdUnit green.
- **Docs:** `client/README.md` contract HUD section; `docs/manual-qa/NEO-153.md`.
## Technical approach
@ -222,4 +222,4 @@ No new **C#** tests (client-only; server covered by **`ContractApiTests`**). No
| Duplicate reward formatter vs shared utility | **Duplicate minimal grant line helpers** inside **`contract_hud_controller.gd`** — avoid coupling to quest controller private methods | `adopted` |
| Issue while encounter already completed (server v1 edge) | **Document in manual QA** — prototype flow is issue-then-clear; HUD still shows **`active`** until server completes contract on a *future* matching clear (NEO-149 README edge case) | `adopted` |
| Concurrent GET + issue | Separate **`_sync_busy`** / **`_issue_busy`** guards; coalesce pending sync like quest client | `adopted` |
| Grant display names before item/faction defs load | Re-render contract reward label on **`definitions_ready`** / faction standing received when summary cached (NEO-122 item-defs re-paint precedent) | `adopted` |
| Grant display names before item/faction defs load | Re-render contract reward label on **`definitions_ready`** / **`faction_standing_received`** when summary cached (NEO-122 item-defs re-paint precedent) | `adopted` |

View File

@ -4,10 +4,11 @@
**Scope:** Branch `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` — commits `9d756c2`..`365e577` (6 commits)
**Base:** `main`
**Issue:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — E7M4-10 client contract issue + progress HUD (Godot)
**Follow-up:** Review findings addressed in follow-up commits (issue-patch merge fix, docs, faction-standing re-paint, regression test).
## Verdict
**Request changes**
~~**Request changes**~~ **Addressed** — blocking patch bug fixed; decomposition docs updated; follow-up tests green.
## Summary
@ -20,9 +21,9 @@ Overall risk is moderate: client-only, but **`_reapply_issue_contract_patch_if_n
| Document | Result |
|----------|--------|
| `docs/plans/NEO-153-implementation-plan.md` | **Matches** — shipped reconciliation aligns with code; AC checklist marked complete; kickoff decisions (separate controller, fixed dev seed, encounter-complete refresh) reflected |
| `docs/plans/E7M4-pre-production-backlog.md` | **Partially matches** — E7M4-10 section still has unchecked AC `[ ]`; no **Landed (NEO-153)** note (contrast E7M4-09 / NEO-152) |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Partially matches** — server slices through NEO-152 documented; **no client HUD line** for E7M4-10 / NEO-153 (NEO-122 precedent on E7.M1) |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M4 row ends at E7M4-09 / NEO-152; **E7M4-10 / NEO-153 not in body or trailing refs** |
| `docs/plans/E7M4-pre-production-backlog.md` | ~~**Partially matches**~~ **Matches** — E7M4-10 AC checked; **Landed (NEO-153)** note added **Done.** |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | ~~**Partially matches**~~ **Matches****Contract client HUD (NEO-153)** line added **Done.** |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches**~~ **Matches** — E7M4-10 / NEO-153 in E7.M4 row + trailing refs **Done.** |
| `docs/decomposition/modules/contracts.md` | **N/A** — policy doc; no client HUD surface |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays server snapshots; no client-side issue authority |
| `docs/manual-qa/NEO-153.md` | **Matches** — Godot-only checklist; edge cases documented |
@ -32,39 +33,21 @@ Register/tracking table and E7.M4 module page **should be updated** after merge
## Blocking issues
1. **Stale `_issue_contract_patch` overwrites completed contract row** — In `contract_hud_controller.gd`, `_on_contracts_received` calls `_reapply_issue_contract_patch_if_needed()` after copying the GET snapshot. That helper **always** merges `_issue_contract_patch` (set on successful issue with `status: active`) and **never clears** it. On encounter-complete refresh, the server returns `status: completed`, but the merge replaces that row with the stale active patch. `ContractActiveLabel` then shows **`active`** instead of **`completed`** (AC failure). Quest HUD avoids this in `_reapply_accept_quest_patch_if_needed`: merge only while the server row is still `not_started`, else **`_accept_quest_patch = {}`**. Contract HUD should adopt the same pattern — e.g. merge only when the instance id is **missing** from the GET snapshot, otherwise clear the patch:
```gdscript
func _reapply_issue_contract_patch_if_needed() -> void:
if _issue_contract_patch.is_empty():
return
var patch_id := str(_issue_contract_patch.get("contractInstanceId", "")).strip_edges()
if patch_id.is_empty():
return
var row: Dictionary = ContractClient.contract_row(patch_id, _last_snapshot)
if row.is_empty():
_last_snapshot = ContractClient.merge_contract_row_into_snapshot(
_last_snapshot, _issue_contract_patch
)
else:
_issue_contract_patch = {}
```
Add a GdUnit case: issue patch present → second `_on_contracts_received` with **completed** snapshot → active label contains **`completed`**, not **`active`**.
1. ~~**Stale `_issue_contract_patch` overwrites completed contract row**~~**Done.** `_reapply_issue_contract_patch_if_needed` now merges only when the instance id is **missing** from the GET snapshot; otherwise clears `_issue_contract_patch` (NEO-122 accept-patch precedent). Regression test `test_completed_get_clears_stale_issue_patch_on_active_label` in `contract_hud_controller_test.gd`.
## Suggestions
1. **Update decomposition docs for landed client slice** — After fixing the patch bug: add **E7M4-10 / NEO-153** to `E7_M4_ContractMissionGenerator.md` (client HUD + README/manual QA links, mirroring NEO-151/152 server lines); extend E7.M4 row in `documentation_and_implementation_alignment.md` with `[NEO-153 plan]` in trailing refs; check E7M4-10 AC and add **Landed (NEO-153)** in `E7M4-pre-production-backlog.md`.
1. ~~**Update decomposition docs for landed client slice**~~**Done.** `E7_M4_ContractMissionGenerator.md`, `documentation_and_implementation_alignment.md`, `E7M4-pre-production-backlog.md` updated.
2. **Faction-standing reward re-paint (plan § Open questions)** — Plan adopted re-render on `definitions_ready` **and** faction standing received when summary is cached. Controller connects `definitions_ready` but **`_faction_standing_client` is stored and never wired** (rep grant display names would stay raw if a future contract bundle includes `reputationGrants`). Low impact for prototype bundle (no rep), but align with plan or note deferral in plan reconciliation.
2. ~~**Faction-standing reward re-paint (plan § Open questions)**~~**Done.** `contract_hud_controller.gd` connects `faction_standing_received` to re-render reward label when a cached summary exists.
3. **Split or label the `chore:` commit**`fea9928` (warnings-as-errors, dotnet format CI/hooks) is unrelated to NEO-153. Harmless on branch, but consider a separate PR or explicit note in merge description so reviewers do not conflate infra with HUD work.
3. **Split or label the `chore:` commit**`fea9928` (warnings-as-errors, dotnet format CI/hooks) is unrelated to NEO-153. Harmless on branch, but consider a separate PR or explicit note in merge description so reviewers do not conflate infra with HUD work. *(Deferred — merge-description note only.)*
## Nits
- Nit: `_faction_standing_client` field is assigned in `setup()` but unused — only `FactionStandingClient.display_name_for` static is called; remove the field or connect standing signals per suggestion 2.
- ~~Nit: `_faction_standing_client` field is assigned in `setup()` but unused~~**Done.** wired for reward label re-paint.
- Nit: Branch includes a one-line server `CompareOrdinal` sort tie-break in `InMemoryContractInstanceStore.cs` — fine as hygiene; no NEO-153 client impact.
- Nit: `test_reward_label_populates_on_active_to_completed_transition` bypasses the issue-patch path; extend after blocking fix so regression is covered.
- ~~Nit: `test_reward_label_populates_on_active_to_completed_transition` bypasses the issue-patch path; extend after blocking fix so regression is covered.~~**Done.** `test_completed_get_clears_stale_issue_patch_on_active_label` added.
## Verification