From 3128f36df9fa740e7e9942ce9d2d741bebcc1ae3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:34:27 -0400 Subject: [PATCH 01/10] NEO-110: kickoff implementation plan for encounter HUD client. --- docs/plans/NEO-110-implementation-plan.md | 151 ++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/plans/NEO-110-implementation-plan.md diff --git a/docs/plans/NEO-110-implementation-plan.md b/docs/plans/NEO-110-implementation-plan.md new file mode 100644 index 0000000..9b7db47 --- /dev/null +++ b/docs/plans/NEO-110-implementation-plan.md @@ -0,0 +1,151 @@ +# NEO-110 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-110 | +| **Title** | E5M3-11: Client encounter progress + loot feedback HUD | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-110/e5m3-11-client-encounter-progress-loot-feedback-hud | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-11** | +| **Branch** | `NEO-110-client-encounter-progress-loot-feedback-hud` | +| **Server deps** | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) — `GET /game/players/{id}/encounter-progress` (**Done** on `main`); [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) — complete event + grant summary (**Done** on `main`) | +| **Pattern** | [NEO-72](NEO-72-implementation-plan.md) / [NEO-86](NEO-86-implementation-plan.md) — thin HTTP client, `main.gd` HUD render, defeat-triggered refresh; [NEO-73](NEO-73-implementation-plan.md) — inventory refresh after server-side success | +| **Blocks** | [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) — playable encounter clear capstone (Godot manual QA) | +| **Server counterpart** | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) — authoritative GET; Bruno is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Refresh strategy** | When to call encounter-progress GET? | **Boot hydrate + refresh after each cast with `targetDefeated`** — mirrors NEO-86 gig refresh; no periodic poll (NEO-108 deferred cadence to this story). | **Adopted** — boot + after `targetDefeated` | +| **Progress denominator** | How to show **2/3** targets? | **Hardcode 3** for frozen **`prototype_combat_pocket`** — single prototype encounter; avoids new encounter-definitions client (NEO-103 server-only). | **Adopted** — constant `3` | +| **Grant display names** | Loot lines on completion label? | **Reuse `ItemDefinitionsClient.display_name_for`** — NEO-72 precedent; fallback raw **`itemId`**. | **Adopted** — reuse item defs | +| **HUD placement** | Where do the two labels live? | **`UICanvas/HudRoot`** after combat HP labels — visible during Tab/cast loop (not buried in economy collapse). | **Adopted** — HudRoot combat area | +| **Manual refresh key** | Dedicated input action? | **Skip** — defeat-driven refresh + boot suffice for AC; NEO-111 capstone is the full Godot script. | **Adopted** (implicit — no new key) | + +## Goal, scope, and out-of-scope + +**Goal:** Godot shows **server-owned** encounter progress and completion loot using **`GET /game/players/{id}/encounter-progress`** — player-visible **N/3** defeat progress after each NPC kill and a completion grant summary matching the GET body. + +**In scope (from Linear + [E5M3-11](E5M3-prototype-backlog.md#e5m3-11--client-encounter-progress-loot-feedback-hud)):** + +- **`encounter_progress_client.gd`:** GET snapshot; parse v1 envelope; **`encounter_row(encounter_id)`** helper; signals **`encounter_progress_received`** / **`encounter_sync_failed`**; public **`request_sync_from_server()`**; **`push_warning`** on failures (NEO-72 pattern). +- **`EncounterProgressLabel`** + **`EncounterCompleteLabel`** under **`UICanvas/HudRoot`** (after **`PlayerCombatHpLabel`**). +- **`main.gd`:** boot hydrate; on accepted cast with **`targetDefeated: true`** → encounter-progress GET (alongside existing gig/combat refreshes); render progress **`defeatedTargetIds.size() / 3`** for **`prototype_combat_pocket`** when **`state`** is **`inactive`** or **`active`**; on **`completed`** paint grant summary from **`rewardGrantSummary`** and **`_request_inventory_refresh()`** (NEO-72 inventory HUD). +- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)). +- **`docs/manual-qa/NEO-110.md`** — Godot steps (server + client; no Bruno). +- **`client/README.md`** encounter HUD subsection. + +**Out of scope (from Linear + backlog):** + +- Final loot VFX art; encounter picker UI beyond frozen pocket. +- **`GET /game/world/encounter-definitions`** client (denominator hardcoded). +- Periodic encounter poll timer; dedicated manual-refresh key. +- Server route/DTO changes. +- **`docs/manual-qa/NEO-111.md`** (capstone story). + +## Acceptance criteria checklist + +- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). +- [ ] Completion label shows grant summary matching server GET. +- [ ] Manual QA checklist exercisable without Bruno. + +## Technical approach + +### Server contract (landed — NEO-108) + +- **`GET /game/players/{id}/encounter-progress`** → **`schemaVersion` 1**, **`encounters[]`** rows with **`encounterId`**, **`state`** (`inactive` | `active` | `completed`), **`defeatedTargetIds`**, optional **`completedAt`**, optional **`rewardGrantSummary`** (`{ itemId, quantity }[]`). +- Prototype row **`prototype_combat_pocket`**: inactive → **0/3**; after defeats → **`active`** with growing **`defeatedTargetIds`**; all three E5.M2 NPCs defeated → **`completed`** with **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**. + +### Client constants (`main.gd`) + +```gdscript +const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" +const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 +``` + +### 1. `encounter_progress_client.gd` + +- Mirror **`gig_progression_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/encounter-progress`**. +- **`parse_encounter_progress_json(text)`** static: require **`schemaVersion` 1**, **`encounters`** array. +- **`encounter_row(encounter_id, snapshot)`** → row dict or `{}`. +- Signals: **`encounter_progress_received(snapshot)`**, **`encounter_sync_failed(reason)`**. + +### 2. Boot + refresh wiring (`main.gd`) + +- **`_setup_encounter_progress_sync()`** from **`_ready()`** after authority HTTP config (same as gig/inventory). +- **`EncounterProgressClient`** node in **`main.tscn`**; **`_apply_authority_http_config_to_client`**. +- Boot: **`request_sync_from_server()`** once catalogs/inventory chain has started (parallel with gig boot is fine). +- **`_on_cast_result_received`:** when **`accepted`** and **`resolution.targetDefeated`** → **`_request_encounter_progress_refresh()`** (in addition to existing cooldown/combat/gig refreshes). + +### 3. HUD rendering + +**`EncounterProgressLabel`** (header **`Encounter:`**): + +| **`state`** | Copy | +|-------------|------| +| **`inactive`** | **`prototype_combat_pocket: not started (0/3)`** | +| **`active`** | **`prototype_combat_pocket: {n}/3`** where **`n = defeatedTargetIds.size()`** | +| **`completed`** | **`prototype_combat_pocket: completed (3/3)`** | +| sync error | **`Encounter: error — {reason}`** | + +**`EncounterCompleteLabel`** (header **`Loot:`**): + +| **`state`** | Copy | +|-------------|------| +| not **`completed`** | **`Loot: —`** | +| **`completed`** | One line per **`rewardGrantSummary`** grant: **`{displayName or itemId} ×{quantity}`**; optional **`completedAt`** suffix on header line | +| sync error | **`Loot: error — {reason}`** (or leave progress label as primary error — prefer single error on progress label, complete shows **`—`**) | + +**Inventory:** on **`encounter_progress_received`**, if prototype row **`state == "completed"`** → **`_request_inventory_refresh()`** so **`InventoryLabel`** reflects grants without manual **I** (backlog + NEO-73 precedent). + +### 4. Scene + +- **`main.tscn`:** add **`EncounterProgressClient`** node; **`EncounterProgressLabel`** + **`EncounterCompleteLabel`** as **`HudRoot`** children **after** **`PlayerCombatHpLabel`**, **before** **`NpcStateLabel`** (VBox order). + +### 5. Tests (GdUnit, AAA) + +- Happy path: v1 JSON with one **`active`** row, two defeats → **`encounter_row`** returns **`defeatedTargetIds.size() == 2`**. +- **`completed`** row includes **`rewardGrantSummary`** array parse. +- 404 → **`encounter_sync_failed`** + **`push_warning`**. +- Optional: static/format helper test for **`main`** render strings (only if extracted to **`encounter_progress_hud_format.gd`** — prefer keeping format in **`main.gd`** unless tests need it; client test focuses on HTTP client). + +### 6. Docs on land + +- **`docs/manual-qa/NEO-110.md`**: fresh server; Godot **F5**; Tab + cast three prototype NPCs; assert progress **1/3 → 2/3 → 3/3**; completion loot lines match GET; inventory shows grants without **I**. +- Update [E5_M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) client slice note, [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) E5M3-11 checkboxes, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-110-implementation-plan.md` | This plan. | +| `client/scripts/encounter_progress_client.gd` | GET encounter-progress; parse v1; **`encounter_row`** helper; signals. | +| `client/scripts/encounter_progress_client.gd.uid` | Godot uid companion (tracked). | +| `client/test/encounter_progress_client_test.gd` | GdUnit: parse active/completed rows; 404 failure signal. | +| `docs/manual-qa/NEO-110.md` | Godot manual QA (server + client; defeat chain + HUD assertions). | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scenes/main.tscn` | Add **`EncounterProgressClient`**, **`EncounterProgressLabel`**, **`EncounterCompleteLabel`** nodes. | +| `client/scripts/main.gd` | Boot/refresh wiring, defeat-triggered GET, HUD render, inventory refresh on **`completed`**. | +| `client/README.md` | Encounter progress + loot HUD subsection: server dep NEO-108, refresh triggers, label format. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `client/test/encounter_progress_client_test.gd` | Mock 200: **`inactive`** row parse; **`active`** with two **`defeatedTargetIds`**; **`completed`** + **`rewardGrantSummary`**; 404 → **`encounter_sync_failed`**. AAA layout (`# Arrange` / `# Act` / `# Assert`). | + +No new **C#** tests (client-only; server covered by **`EncounterProgressApiTests`**). No Bruno changes. + +## Open questions / risks + +| Item | Agent recommendation | Status | +|------|----------------------|--------| +| **Concurrent GETs on triple-defeat frame** | **`_busy`** guard drops overlap — last completed response wins; acceptable for prototype. | **adopted** | +| **Item defs not loaded before completion render** | Re-render encounter labels when **`definitions_ready`** fires if snapshot already **`completed`** (mirror NEO-72 inventory re-paint). | **pending** (implement with inventory pattern) | +| **NEO-111 capstone overlap** | NEO-110 manual QA is HUD-focused; NEO-111 adds full loop + idempotency — keep scopes separate. | **deferred** | From d81d19b85995096ea793c1a12e608ab6d2633f76 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:35:54 -0400 Subject: [PATCH 02/10] NEO-110: encounter progress client, HUD labels, and GdUnit tests. --- client/scenes/main.tscn | 25 +++ client/scripts/encounter_progress_client.gd | 105 ++++++++++++ .../scripts/encounter_progress_client.gd.uid | 1 + client/scripts/main.gd | 138 ++++++++++++++++ client/test/encounter_progress_client_test.gd | 151 ++++++++++++++++++ 5 files changed, 420 insertions(+) create mode 100644 client/scripts/encounter_progress_client.gd create mode 100644 client/scripts/encounter_progress_client.gd.uid create mode 100644 client/test/encounter_progress_client_test.gd diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 7237929..8380733 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -20,6 +20,7 @@ [ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"] [ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"] [ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"] +[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"] [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"] @@ -1114,6 +1115,9 @@ script = ExtResource("16_skill_prog") [node name="GigProgressionClient" type="Node" parent="." unique_id=2500011] script = ExtResource("21_gig_prog") +[node name="EncounterProgressClient" type="Node" parent="." unique_id=2500012] +script = ExtResource("24_enc_prog") + [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] script = ExtResource("17_recipe_defs") @@ -1199,6 +1203,27 @@ theme_override_font_sizes/font_size = 22 autowrap_mode = 3 text = "Player HP: —" +[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000023] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.9, 0.82, 0.72, 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 = "Encounter: +Loading…" + +[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000024] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.95, 0.9, 0.65, 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 = "Loot: —" + [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/encounter_progress_client.gd b/client/scripts/encounter_progress_client.gd new file mode 100644 index 0000000..e720722 --- /dev/null +++ b/client/scripts/encounter_progress_client.gd @@ -0,0 +1,105 @@ +extends Node + +## NEO-110: prototype HTTP client for [code]GET /game/players/{id}/encounter-progress[/code] (NEO-108). + +signal encounter_progress_received(snapshot: Dictionary) +signal encounter_sync_failed(reason: String) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var dev_player_id: String = "dev-local-1" +@export var injected_http: Node = null + +var _http: Node +var _busy: 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: + return + _busy = true + var url := "%s/game/players/%s/encounter-progress" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("EncounterProgressClient: %s" % reason) + _busy = false + encounter_sync_failed.emit(reason) + + +func encounter_row(encounter_id: String, snapshot: Dictionary = {}) -> Dictionary: + var encounters: Variant = snapshot.get("encounters", null) + if encounters == null or not encounters is Array: + return {} + for row_variant in encounters as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("encounterId", "")) == encounter_id: + return row + return {} + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +static func parse_encounter_progress_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + return null + var encounters: Variant = root.get("encounters", null) + if encounters == null or not encounters is Array: + return null + if str(root.get("playerId", "")).strip_edges().is_empty(): + return null + return root + + +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("EncounterProgressClient: %s" % reason) + encounter_sync_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("EncounterProgressClient: %s" % reason404) + encounter_sync_failed.emit(reason404) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("EncounterProgressClient: %s" % reason_code) + encounter_sync_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_encounter_progress_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("EncounterProgressClient: %s" % reason_json) + encounter_sync_failed.emit(reason_json) + return + encounter_progress_received.emit(snapshot as Dictionary) diff --git a/client/scripts/encounter_progress_client.gd.uid b/client/scripts/encounter_progress_client.gd.uid new file mode 100644 index 0000000..64d48f6 --- /dev/null +++ b/client/scripts/encounter_progress_client.gd.uid @@ -0,0 +1 @@ +uid://bneo110encprog01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e0c8d64..ad9a7c1 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -54,6 +54,8 @@ const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" const SALVAGE_SKILL_ID := "salvage" const REFINE_SKILL_ID := "refine" const BREACH_GIG_ID := "breach" +const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" +const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -104,6 +106,8 @@ var _last_progression_snapshot: Dictionary = {} var _progression_error: String = "" var _last_gig_snapshot: Dictionary = {} var _gig_error: String = "" +var _last_encounter_progress_snapshot: Dictionary = {} +var _encounter_progress_error: String = "" var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false @@ -126,6 +130,8 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel @onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel @onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel +@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel +@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel @onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel @onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel @onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel @@ -141,6 +147,7 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP @onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient +@onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @@ -189,6 +196,7 @@ func _ready() -> void: _setup_inventory_sync() _setup_skill_progression_sync() _setup_gig_progression_sync() + _setup_encounter_progress_sync() _setup_gather_interact_feedback() _setup_craft_ui() @@ -475,6 +483,10 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: return if not _last_inventory_snapshot.is_empty(): _render_inventory_label() + if not _encounter_progress_error.is_empty(): + return + if not _last_encounter_progress_snapshot.is_empty(): + _render_encounter_progress_labels() func _on_inventory_received(snapshot: Dictionary) -> void: @@ -705,6 +717,131 @@ func _request_gig_progression_refresh() -> void: _gig_progression_client.call("request_sync_from_server") +func _setup_encounter_progress_sync() -> void: + # NEO-110: encounter progress + loot HUD; boot hydrate + refresh after NPC defeat cast. + _apply_authority_http_config_to_client(_encounter_progress_client) + if _encounter_progress_client.has_signal("encounter_progress_received"): + _encounter_progress_client.connect( + "encounter_progress_received", Callable(self, "_on_encounter_progress_received") + ) + if _encounter_progress_client.has_signal("encounter_sync_failed"): + _encounter_progress_client.connect( + "encounter_sync_failed", Callable(self, "_on_encounter_progress_sync_failed") + ) + _render_encounter_progress_labels() + if _encounter_progress_client.has_method("request_sync_from_server"): + _encounter_progress_client.call("request_sync_from_server") + + +func _on_encounter_progress_received(snapshot: Dictionary) -> void: + _encounter_progress_error = "" + _last_encounter_progress_snapshot = snapshot.duplicate(true) + _render_encounter_progress_labels() + var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) + if str(row.get("state", "")) == "completed": + _request_inventory_refresh() + + +func _on_encounter_progress_sync_failed(reason: String) -> void: + _encounter_progress_error = reason + _last_encounter_progress_snapshot = {} + _render_encounter_progress_labels() + + +func _render_encounter_progress_labels() -> void: + _render_encounter_progress_label() + _render_encounter_complete_label() + + +func _render_encounter_progress_label() -> void: + if not is_instance_valid(_encounter_progress_label): + return + var header := "Encounter:" + if not _encounter_progress_error.is_empty(): + _encounter_progress_label.text = "%s\nerror — %s" % [header, _encounter_progress_error] + return + if _last_encounter_progress_snapshot.is_empty(): + _encounter_progress_label.text = "%s\nLoading…" % header + return + var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) + if row.is_empty(): + _encounter_progress_label.text = ( + "%s\n%s: — (missing row)" % [header, PROTOTYPE_ENCOUNTER_ID] + ) + return + var state: String = str(row.get("state", "")) + var body: String = "" + match state: + "inactive": + body = "%s: not started (0/%d)" % [ + PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS + ] + "active": + var defeated: Variant = row.get("defeatedTargetIds", []) + var n: int = defeated.size() if defeated is Array else 0 + body = "%s: %d/%d" % [PROTOTYPE_ENCOUNTER_ID, n, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS] + "completed": + body = "%s: completed (%d/%d)" % [ + PROTOTYPE_ENCOUNTER_ID, + PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, + PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, + ] + _: + body = "%s: unknown state (%s)" % [PROTOTYPE_ENCOUNTER_ID, state] + _encounter_progress_label.text = "%s\n%s" % [header, body] + + +func _render_encounter_complete_label() -> void: + if not is_instance_valid(_encounter_complete_label): + return + var header := "Loot:" + if not _encounter_progress_error.is_empty(): + _encounter_complete_label.text = "%s\n—" % header + return + if _last_encounter_progress_snapshot.is_empty(): + _encounter_complete_label.text = "%s\n—" % header + return + var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) + if row.is_empty() or str(row.get("state", "")) != "completed": + _encounter_complete_label.text = "%s\n—" % header + return + var lines: PackedStringArray = [header] + var completed_at: String = str(row.get("completedAt", "")).strip_edges() + if not completed_at.is_empty(): + lines[0] = "%s (completed %s)" % [header, completed_at] + var grants: Variant = row.get("rewardGrantSummary", []) + if grants is Array: + for grant_variant in grants as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var item_id: String = str(grant.get("itemId", "")) + var qty: int = int(grant.get("quantity", 0)) + var label: String = _item_display_name(item_id) if not item_id.is_empty() else "?" + lines.append(" %s ×%d" % [label, qty]) + if lines.size() == 1: + lines.append(" (no grants)") + _encounter_complete_label.text = "\n".join(lines) + + +func _encounter_row(encounter_id: String) -> Dictionary: + if not is_instance_valid(_encounter_progress_client): + return {} + if not _encounter_progress_client.has_method("encounter_row"): + return {} + var row: Variant = _encounter_progress_client.call( + "encounter_row", encounter_id, _last_encounter_progress_snapshot + ) + return row as Dictionary + + +func _request_encounter_progress_refresh() -> void: + if not is_instance_valid(_encounter_progress_client): + return + if _encounter_progress_client.has_method("request_sync_from_server"): + _encounter_progress_client.call("request_sync_from_server") + + func _render_gather_feedback_label(text: String = "Gather: —") -> void: if is_instance_valid(_gather_feedback_label): _gather_feedback_label.text = text @@ -1005,6 +1142,7 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D _request_npc_combat_sync() if bool(resolution.get("targetDefeated", false)): _request_gig_progression_refresh() + _request_encounter_progress_refresh() return var rc := reason_code.strip_edges() if rc.is_empty(): diff --git a/client/test/encounter_progress_client_test.gd b/client/test/encounter_progress_client_test.gd new file mode 100644 index 0000000..2d8845d --- /dev/null +++ b/client/test/encounter_progress_client_test.gd @@ -0,0 +1,151 @@ +extends GdUnitTestSuite + +## NEO-110: `EncounterProgressClient` GET parse + failure signals. + +const EncounterProgressClient := preload("res://scripts/encounter_progress_client.gd") + +const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" + +var _progress_capture: Dictionary = {} + + +func _capture_progress(snapshot: Dictionary) -> void: + _progress_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 + + +static func _inactive_progression_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","encounters":' + + '[{"encounterId":"prototype_combat_pocket","state":"inactive","defeatedTargetIds":[]}]}' + ) + + +static func _active_two_defeats_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","encounters":' + + '[{"encounterId":"prototype_combat_pocket","state":"active",' + + '"defeatedTargetIds":["prototype_npc_melee","prototype_npc_ranged"]}]}' + ) + + +static func _completed_with_grants_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","encounters":' + + '[{"encounterId":"prototype_combat_pocket","state":"completed",' + + '"defeatedTargetIds":["prototype_npc_elite","prototype_npc_melee","prototype_npc_ranged"],' + + '"completedAt":"2026-05-31T12:00:00Z",' + + '"rewardGrantSummary":[{"itemId":"scrap_metal_bulk","quantity":10},' + + '{"itemId":"contract_handoff_token","quantity":1}]}]}' + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = EncounterProgressClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_inactive_encounter_row() -> void: + # Arrange + var json := _inactive_progression_json() + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = EncounterProgressClient.new() + auto_free(c) + var row: Dictionary = c.call( + "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary + ) + assert_that(str(row.get("state", ""))).is_equal("inactive") + assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(0) + + +func test_parse_active_row_has_two_defeats() -> void: + # Arrange + var json := _active_two_defeats_json() + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = EncounterProgressClient.new() + auto_free(c) + var row: Dictionary = c.call( + "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary + ) + assert_that(str(row.get("state", ""))).is_equal("active") + assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(2) + + +func test_parse_completed_row_has_reward_grant_summary() -> void: + # Arrange + var json := _completed_with_grants_json() + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = EncounterProgressClient.new() + auto_free(c) + var row: Dictionary = c.call( + "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary + ) + assert_that(str(row.get("state", ""))).is_equal("completed") + var grants: Variant = row.get("rewardGrantSummary", null) + assert_that(grants is Array).is_true() + assert_that((grants as Array).size()).is_equal(2) + + +func test_request_sync_emits_encounter_progress_received() -> void: + # Arrange + _progress_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _inactive_progression_json() + var c := _make_client(transport) + c.connect("encounter_progress_received", Callable(self, "_capture_progress")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("encounter_progress_received", any()) + assert_that(_progress_capture.get("playerId", "")).is_equal("dev-local-1") + assert_that(transport.last_url).contains("/encounter-progress") + + +func test_http_404_emits_encounter_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("encounter_sync_failed", "HTTP 404 (player unknown)") From bcc9bf1c695645bed5a64c3376b971bdebb764c5 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:35:56 -0400 Subject: [PATCH 03/10] NEO-110: manual QA, README, plan reconciliation, and module docs. --- client/README.md | 12 ++++ .../modules/E5_M3_EncounterAndRewardTables.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/manual-qa/NEO-110.md | 59 +++++++++++++++++++ docs/plans/E5M3-prototype-backlog.md | 8 ++- docs/plans/NEO-110-implementation-plan.md | 16 +++-- 6 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 docs/manual-qa/NEO-110.md diff --git a/client/README.md b/client/README.md index d368a24..4270d56 100644 --- a/client/README.md +++ b/client/README.md @@ -213,6 +213,18 @@ Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.md). +## Encounter progress + loot HUD (NEO-110) + +- **`GET /game/players/{id}/encounter-progress`** — server-authoritative per-encounter **`state`**, **`defeatedTargetIds`**, and **`rewardGrantSummary`** when **`completed`** (NEO-108); see [server README — Per-player encounter progress](../server/README.md#per-player-encounter-progress-neo-108). +- **Scripts:** `scripts/encounter_progress_client.gd`; wired from `main.gd` in `_setup_encounter_progress_sync()`. +- **HUD:** + - **`UICanvas/HudRoot/EncounterProgressLabel`** — frozen pocket **`prototype_combat_pocket`**: **`not started (0/3)`** → **`{n}/3`** while **`active`** → **`completed (3/3)`**. + - **`UICanvas/HudRoot/EncounterCompleteLabel`** — **`Loot: —`** until **`completed`**; then one line per grant (**`displayName`** via **`ItemDefinitionsClient`**, fallback **`itemId`**). +- **Refresh:** boot hydrate + **`GET`** after cast accept with **`targetDefeated: true`** (alongside gig/combat refreshes). No periodic poll or manual key. +- **Inventory:** on **`completed`** row, **`InventoryLabel`** auto-refreshes (no **I**) so bag shows **`scrap_metal_bulk`** and **`contract_handoff_token`**. + +Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.md). + ## Inventory snapshot HUD (NEO-72) - **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http). diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index d3cff39..162d0c8 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -98,6 +98,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin **Slice 3 telemetry hooks landed ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, and **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`**; **`EncounterCombatWiring`** delegates without duplicate hooks. Plan: [NEO-109 implementation plan](../../plans/NEO-109-implementation-plan.md); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). +**Client encounter HUD landed ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** Godot **`encounter_progress_client.gd`** polls **`GET /game/players/{id}/encounter-progress`** on boot and after defeat casts; **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** in combat HUD; inventory refresh on completion. Plan: [NEO-110 implementation plan](../../plans/NEO-110-implementation-plan.md); manual QA: [NEO-110.md](../../manual-qa/NEO-110.md); [client README — Encounter progress + loot HUD](../../../client/README.md#encounter-progress--loot-hud-neo-110). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index cdf5752..e3ec28d 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -58,7 +58,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | -| E5.M3 | In Progress | **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). 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). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **E5M3-11+ client HUD outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [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 | +| E5.M3 | In Progress | **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). 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). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **E5M3-12 client capstone outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [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 | --- diff --git a/docs/manual-qa/NEO-110.md b/docs/manual-qa/NEO-110.md new file mode 100644 index 0000000..5565c42 --- /dev/null +++ b/docs/manual-qa/NEO-110.md @@ -0,0 +1,59 @@ +# NEO-110 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-110 | +| Title | E5M3-11: Client encounter progress + loot feedback HUD | +| Linear | https://linear.app/neon-sprawl/issue/NEO-110/e5m3-11-client-encounter-progress-loot-feedback-hud | +| Plan | `docs/plans/NEO-110-implementation-plan.md` | +| Branch | `NEO-110-client-encounter-progress-loot-feedback-hud` | + +## Preconditions + +- **Fresh dev player:** stop any running server, then start a new instance so in-memory encounter progress, NPC HP, and inventory reset. +- **No Bruno/curl** for this checklist — defeat chain uses Godot **Tab** + **1** only. +- NEO-108 encounter-progress GET and NEO-106 combat wiring landed on `main`. +- **`Economy HUD`** toggle **on** for inventory verification (step 8). + +## Archetype reference (defeat order flexible) + +| Archetype | Instance id | Marker | Pulses to defeat | +|-----------|-------------|--------|------------------| +| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 4 | +| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 4 | +| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 8 | + +Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). + +## Expected encounter HUD progression + +| After | `EncounterProgressLabel` | `EncounterCompleteLabel` | +|-------|--------------------------|---------------------------| +| Boot (no engagement) | `prototype_combat_pocket: not started (0/3)` | `Loot: —` | +| 1st NPC defeated | `prototype_combat_pocket: 1/3` | `Loot: —` | +| 2nd NPC defeated | `prototype_combat_pocket: 2/3` | `Loot: —` | +| 3rd NPC defeated | `prototype_combat_pocket: completed (3/3)` | `scrap_metal_bulk ×10` and `contract_handoff_token ×1` (display names when item defs loaded) | + +## Checklist + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). Confirm boot lines: **`Encounter: … not started (0/3)`** and **`Loot: —`** on **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** (combat HUD block, below **`PlayerCombatHpLabel`**). +3. Defeat **`prototype_npc_melee`**: walk to orange marker, **Tab** lock, press **1** until **`Cast: … — defeated!`**. Verify **`EncounterProgressLabel`** shows **`1/3`** (may lag one GET behind cast — wait ~1 s or defeat next NPC). +4. Defeat **`prototype_npc_ranged`**: lock at SE marker, cast until defeated. Verify **`2/3`**. +5. Defeat **`prototype_npc_elite`**: lock at origin, cast until defeated (**8** pulses). Verify **`completed (3/3)`** and **`EncounterCompleteLabel`** lists **`scrap_metal_bulk ×10`** and **`contract_handoff_token ×1`** (or raw ids if defs still loading). +6. Open **`Economy HUD`** body: **`InventoryLabel`** shows **`scrap_metal_bulk`** and **`contract_handoff_token`** without pressing **I** (auto-refresh on completion GET). +7. Defeat any already-defeated NPC again (optional): encounter row stays **`completed (3/3)`**; loot lines unchanged. +8. Regression: **`GigXpLabel`** still updates per defeat (NEO-86); **`CastFeedbackLabel`** still shows deny on re-cast (**`target_defeated`**). + +## Notes + +- Defeat order may differ from table — server tracks order-independent subset; count is **`defeatedTargetIds.size()`**. +- Capstone baseline prefers **server restart** over mid-session fixture POST. +- Full three-NPC loop + idempotency capstone: [NEO-111](NEO-111.md) (when landed). +- Component combat regression: [NEO-98 manual QA](NEO-98.md). + +## Acceptance + +- [ ] Steps 1–8 completable in one session without Bruno/curl. +- [ ] Progress label shows **1/3**, **2/3**, then **completed (3/3)** after defeats. +- [ ] Loot label matches server grant summary; inventory reflects grants without **I**. diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index 5c23283..6adcf21 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -337,9 +337,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). -- [ ] Completion label shows grant summary matching server GET. -- [ ] Manual QA checklist exercisable without Bruno. +- [x] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). +- [x] Completion label shows grant summary matching server GET. +- [x] Manual QA checklist exercisable without Bruno. + +**Landed ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`** + **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory auto-refresh on **`completed`**. Plan: [NEO-110-implementation-plan.md](NEO-110-implementation-plan.md); manual QA: [NEO-110.md](../manual-qa/NEO-110.md); [client README — Encounter progress + loot HUD](../../client/README.md#encounter-progress--loot-hud-neo-110). **Client counterpart:** this issue (**NEO-110**). diff --git a/docs/plans/NEO-110-implementation-plan.md b/docs/plans/NEO-110-implementation-plan.md index 9b7db47..5c97ae7 100644 --- a/docs/plans/NEO-110-implementation-plan.md +++ b/docs/plans/NEO-110-implementation-plan.md @@ -47,9 +47,17 @@ ## Acceptance criteria checklist -- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). -- [ ] Completion label shows grant summary matching server GET. -- [ ] Manual QA checklist exercisable without Bruno. +- [x] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). +- [x] Completion label shows grant summary matching server GET. +- [x] Manual QA checklist exercisable without Bruno. + +## Implementation reconciliation (shipped) + +- **`encounter_progress_client.gd`:** GET parse v1; **`encounter_row`**; **`encounter_progress_received`** / **`encounter_sync_failed`**. +- **`main.gd`:** boot + **`targetDefeated`** refresh; HUD render; inventory refresh on **`completed`**; item-defs re-paint when defs arrive. +- **Scene:** **`EncounterProgressClient`**, **`EncounterProgressLabel`**, **`EncounterCompleteLabel`** in **`HudRoot`** after **`PlayerCombatHpLabel`**. +- **Tests:** `client/test/encounter_progress_client_test.gd` (inactive/active/completed parse + 404). +- **Docs:** `client/README.md` encounter section; `docs/manual-qa/NEO-110.md`. ## Technical approach @@ -147,5 +155,5 @@ No new **C#** tests (client-only; server covered by **`EncounterProgressApiTests | Item | Agent recommendation | Status | |------|----------------------|--------| | **Concurrent GETs on triple-defeat frame** | **`_busy`** guard drops overlap — last completed response wins; acceptable for prototype. | **adopted** | -| **Item defs not loaded before completion render** | Re-render encounter labels when **`definitions_ready`** fires if snapshot already **`completed`** (mirror NEO-72 inventory re-paint). | **pending** (implement with inventory pattern) | +| **Item defs not loaded before completion render** | Re-render encounter labels when **`definitions_ready`** fires if snapshot already **`completed`** (mirror NEO-72 inventory re-paint). | **adopted** — `_on_item_definitions_ready` re-paints encounter HUD | | **NEO-111 capstone overlap** | NEO-110 manual QA is HUD-focused; NEO-111 adds full loop + idempotency — keep scopes separate. | **deferred** | From 3ce645a0f0d348bf9e572b6d8a888a43ad9f3e07 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:37:21 -0400 Subject: [PATCH 04/10] =?UTF-8?q?NEO-110:=20code=20review=20=E2=80=94=20re?= =?UTF-8?q?quest=20changes=20for=20loot=20label=20helper.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reviews/2026-05-31-NEO-110.md | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/reviews/2026-05-31-NEO-110.md diff --git a/docs/reviews/2026-05-31-NEO-110.md b/docs/reviews/2026-05-31-NEO-110.md new file mode 100644 index 0000000..61b044d --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-110.md @@ -0,0 +1,62 @@ +# Code review — NEO-110 (E5M3-11) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-110-client-encounter-progress-loot-feedback-hud` vs `c302220` (merge-base on `main`) — commits `3128f36` … `bcc9bf1` +**Base:** `c302220` (main at branch point, post NEO-109 merge) + +## Verdict + +**Request changes** + +## Summary + +NEO-110 adds the Godot client for **`GET /game/players/{id}/encounter-progress`**: **`encounter_progress_client.gd`** mirrors **`gig_progression_client.gd`** (injectable HTTP, `_busy` guard, v1 parse, signals), **`main.gd`** wires boot hydrate + defeat-triggered refresh, and **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** sit in **`HudRoot`** after **`PlayerCombatHpLabel`**. GdUnit covers parse paths and 404; manual QA and README are thorough. Architecture matches server authority and NEO-108 contract. One **runtime bug** in loot rendering blocks merge: **`main.gd`** calls **`_item_display_name`**, which is not defined on the main script (only on **`craft_recipe_panel.gd`**). Completing the third NPC will hit that path and error when painting grant lines. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-110-implementation-plan.md` | **Matches** — kickoff decisions adopted; reconciliation and acceptance checklist accurate. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-11) | **Matches** — AC checked; landed note cites plan, manual QA, client README. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — NEO-110 client HUD snapshot bullet added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-110** client HUD; **E5M3-12** capstone still outstanding. | +| `docs/decomposition/modules/module_dependency_register.md` | **Partially matches** — E5.M3 note still ends with **“E5M3-11+ client HUD outstanding.”** Should cite **NEO-110** landed (non-blocking doc drift). | +| `docs/manual-qa/NEO-110.md` | **Matches** — Godot-only steps, defeat chain, HUD expectations, inventory auto-refresh. | +| `client/README.md` | **Matches** — encounter HUD subsection; refresh triggers documented. | +| Full-stack epic decomposition | **Matches** — client issue **NEO-110** fulfills server **NEO-108** counterpart; not claiming E5M3-12 capstone. | + +## Blocking issues + +1. **`_item_display_name` missing on `main.gd`** — `_render_encounter_complete_label()` calls `_item_display_name(item_id)` (line ~820), but **`main.gd` defines only `_inventory_item_label()`**, which already wraps **`ItemDefinitionsClient.display_name_for`**. **`craft_recipe_panel.gd`** owns **`_item_display_name`** on a different node. On encounter **`completed`**, Godot will throw when iterating **`rewardGrantSummary`**. **Fix:** use **`_inventory_item_label(item_id)`** (plan’s NEO-72 precedent) or add a shared helper on **`main.gd`**. + +## Suggestions + +1. **`_on_item_definitions_ready` coupling to inventory error** — The handler returns early when **`_inventory_error`** is set, so a failed inventory GET prevents re-painting encounter loot after item defs load (plan adopted re-paint on **`definitions_ready`** when snapshot is **`completed`**). Consider re-painting encounter labels independently of inventory sync state. + +2. **Update `module_dependency_register.md` E5.M3 note** — Replace **“E5M3-11+ client HUD outstanding”** with **NEO-110 landed** + pointer to client README / manual QA; keep **NEO-111** as outstanding capstone. + +3. **GdUnit: invalid JSON / schema mismatch** — Optional test that **`parse_encounter_progress_json`** returns **`null`** and that a mock 200 with bad body emits **`encounter_sync_failed`**; improves parity with gig client coverage if added later. + +## Nits + +- Nit: Parse tests instantiate a throwaway **`EncounterProgressClient`** to call **`encounter_row`**; could use a static helper or test **`encounter_row`** via the client under test only — readability only. + +- Nit: **`_busy`** drops overlapping GETs (e.g. boot + first defeat); plan accepted this for prototype; manual QA notes ~1 s lag — fine. + +- Nit: **`main.gd`** grows by ~130 lines; plan keeps HUD format inline — acceptable until NEO-111 or a future extract. + +## Verification + +```bash +# GdUnit (set Godot path first) +export GODOT_BIN=/path/to/godot +cd client && ./addons/gdUnit4/runtest.sh -a test/encounter_progress_client_test.gd + +# Manual (primary for this story) +# docs/manual-qa/NEO-110.md — server restart, F5, defeat three NPCs, +# assert 0/3 → 1/3 → 2/3 → completed (3/3), loot lines, inventory without I + +# After fix: confirm third-defeat does not error in Output when EncounterCompleteLabel renders grants +``` + +**Reviewer note:** GdUnit was not executed in this environment (`GODOT_BIN` unset). Run locally before merge. From c8d475670cf2d4ff16abecba7c273a070f840cd6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:38:07 -0400 Subject: [PATCH 05/10] =?UTF-8?q?NEO-110:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20loot=20label=20helper,=20defs=20re-paint,=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/scripts/main.gd | 10 +++----- client/test/encounter_progress_client_test.gd | 23 +++++++++++++++++++ .../modules/module_dependency_register.md | 2 +- docs/reviews/2026-05-31-NEO-110.md | 14 +++++------ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ad9a7c1..29f8460 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -479,13 +479,9 @@ func _apply_authority_http_config_to_client(client: Node) -> void: func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: _populate_craft_recipe_panel_if_ready() - if not _inventory_error.is_empty(): - return - if not _last_inventory_snapshot.is_empty(): + if _inventory_error.is_empty() and not _last_inventory_snapshot.is_empty(): _render_inventory_label() - if not _encounter_progress_error.is_empty(): - return - if not _last_encounter_progress_snapshot.is_empty(): + if _encounter_progress_error.is_empty() and not _last_encounter_progress_snapshot.is_empty(): _render_encounter_progress_labels() @@ -817,7 +813,7 @@ func _render_encounter_complete_label() -> void: var grant: Dictionary = grant_variant var item_id: String = str(grant.get("itemId", "")) var qty: int = int(grant.get("quantity", 0)) - var label: String = _item_display_name(item_id) if not item_id.is_empty() else "?" + var label: String = _inventory_item_label(item_id) if not item_id.is_empty() else "?" lines.append(" %s ×%d" % [label, qty]) if lines.size() == 1: lines.append(" (no grants)") diff --git a/client/test/encounter_progress_client_test.gd b/client/test/encounter_progress_client_test.gd index 2d8845d..46b8295 100644 --- a/client/test/encounter_progress_client_test.gd +++ b/client/test/encounter_progress_client_test.gd @@ -149,3 +149,26 @@ func test_http_404_emits_encounter_sync_failed() -> void: c.call("request_sync_from_server") # Assert await assert_signal(c).is_emitted("encounter_sync_failed", "HTTP 404 (player unknown)") + + +func test_parse_encounter_progress_json_returns_null_for_schema_mismatch() -> void: + # Arrange + var json := '{"schemaVersion":2,"playerId":"dev-local-1","encounters":[]}' + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot).is_null() + + +func test_invalid_schema_emits_encounter_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = '{"schemaVersion":2,"playerId":"dev-local-1","encounters":[]}' + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted( + "encounter_sync_failed", "non-JSON body or schemaVersion mismatch" + ) diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 2a50c60..c45a407 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. -**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../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/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **NEO-107 landed:** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107 plan](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **NEO-108 landed:** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108 plan](../../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/`. **NEO-109 landed:** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** hook sites ([NEO-109 plan](../../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 outstanding.** +**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../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/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **NEO-107 landed:** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107 plan](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **NEO-108 landed:** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108 plan](../../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/`. **NEO-109 landed:** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** hook sites ([NEO-109 plan](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **NEO-110 landed:** client encounter progress + loot HUD — **`encounter_progress_client.gd`**, defeat-triggered GET ([NEO-110 plan](../../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 outstanding** ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)). ### Epic 6 — PvP Security diff --git a/docs/reviews/2026-05-31-NEO-110.md b/docs/reviews/2026-05-31-NEO-110.md index 61b044d..896a69f 100644 --- a/docs/reviews/2026-05-31-NEO-110.md +++ b/docs/reviews/2026-05-31-NEO-110.md @@ -6,11 +6,11 @@ ## Verdict -**Request changes** +**Approve with nits** ## Summary -NEO-110 adds the Godot client for **`GET /game/players/{id}/encounter-progress`**: **`encounter_progress_client.gd`** mirrors **`gig_progression_client.gd`** (injectable HTTP, `_busy` guard, v1 parse, signals), **`main.gd`** wires boot hydrate + defeat-triggered refresh, and **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** sit in **`HudRoot`** after **`PlayerCombatHpLabel`**. GdUnit covers parse paths and 404; manual QA and README are thorough. Architecture matches server authority and NEO-108 contract. One **runtime bug** in loot rendering blocks merge: **`main.gd`** calls **`_item_display_name`**, which is not defined on the main script (only on **`craft_recipe_panel.gd`**). Completing the third NPC will hit that path and error when painting grant lines. +NEO-110 adds the Godot client for **`GET /game/players/{id}/encounter-progress`**: **`encounter_progress_client.gd`** mirrors **`gig_progression_client.gd`** (injectable HTTP, `_busy` guard, v1 parse, signals), **`main.gd`** wires boot hydrate + defeat-triggered refresh, and **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** sit in **`HudRoot`** after **`PlayerCombatHpLabel`**. GdUnit covers parse paths, 404, and schema mismatch; manual QA and README are thorough. Architecture matches server authority and NEO-108 contract. Review follow-up fixed loot rendering (**`_inventory_item_label`**), independent encounter re-paint on item defs, register drift, and extra GdUnit tests. ## Documentation checked @@ -20,22 +20,22 @@ NEO-110 adds the Godot client for **`GET /game/players/{id}/encounter-progress`* | `docs/plans/E5M3-prototype-backlog.md` (E5M3-11) | **Matches** — AC checked; landed note cites plan, manual QA, client README. | | `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — NEO-110 client HUD snapshot bullet added. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-110** client HUD; **E5M3-12** capstone still outstanding. | -| `docs/decomposition/modules/module_dependency_register.md` | **Partially matches** — E5.M3 note still ends with **“E5M3-11+ client HUD outstanding.”** Should cite **NEO-110** landed (non-blocking doc drift). | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-110** landed; **NEO-111** capstone outstanding. Done. | | `docs/manual-qa/NEO-110.md` | **Matches** — Godot-only steps, defeat chain, HUD expectations, inventory auto-refresh. | | `client/README.md` | **Matches** — encounter HUD subsection; refresh triggers documented. | | Full-stack epic decomposition | **Matches** — client issue **NEO-110** fulfills server **NEO-108** counterpart; not claiming E5M3-12 capstone. | ## Blocking issues -1. **`_item_display_name` missing on `main.gd`** — `_render_encounter_complete_label()` calls `_item_display_name(item_id)` (line ~820), but **`main.gd` defines only `_inventory_item_label()`**, which already wraps **`ItemDefinitionsClient.display_name_for`**. **`craft_recipe_panel.gd`** owns **`_item_display_name`** on a different node. On encounter **`completed`**, Godot will throw when iterating **`rewardGrantSummary`**. **Fix:** use **`_inventory_item_label(item_id)`** (plan’s NEO-72 precedent) or add a shared helper on **`main.gd`**. +1. ~~**`_item_display_name` missing on `main.gd`**~~ — `_render_encounter_complete_label()` now calls **`_inventory_item_label(item_id)`**. Done. ## Suggestions -1. **`_on_item_definitions_ready` coupling to inventory error** — The handler returns early when **`_inventory_error`** is set, so a failed inventory GET prevents re-painting encounter loot after item defs load (plan adopted re-paint on **`definitions_ready`** when snapshot is **`completed`**). Consider re-painting encounter labels independently of inventory sync state. +1. ~~**`_on_item_definitions_ready` coupling to inventory error**~~ — Inventory and encounter HUD re-paint independently when their respective snapshots are valid. Done. -2. **Update `module_dependency_register.md` E5.M3 note** — Replace **“E5M3-11+ client HUD outstanding”** with **NEO-110 landed** + pointer to client README / manual QA; keep **NEO-111** as outstanding capstone. +2. ~~**Update `module_dependency_register.md` E5.M3 note**~~ — **NEO-110** landed + client README / manual QA pointers; **NEO-111** capstone outstanding. Done. -3. **GdUnit: invalid JSON / schema mismatch** — Optional test that **`parse_encounter_progress_json`** returns **`null`** and that a mock 200 with bad body emits **`encounter_sync_failed`**; improves parity with gig client coverage if added later. +3. ~~**GdUnit: invalid JSON / schema mismatch**~~ — Added **`test_parse_encounter_progress_json_returns_null_for_schema_mismatch`** and **`test_invalid_schema_emits_encounter_sync_failed`**. Done. ## Nits From 24d598e0a27c25a5d3a4aa8aac519c7f78b590d4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:42:23 -0400 Subject: [PATCH 06/10] chore: run gdlint/gdformat on pre-push only when client .gd changes. --- .cursor/rules/gdscript-style.md | 2 +- client/README.md | 4 +- scripts/git-hooks/pre-push | 67 +++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/gdscript-style.md b/.cursor/rules/gdscript-style.md index 9106fb0..69eff90 100644 --- a/.cursor/rules/gdscript-style.md +++ b/.cursor/rules/gdscript-style.md @@ -59,7 +59,7 @@ CI also enforces **`gdlint`** and **`gdformat`** (see `gdlintrc` and `.github/wo `gdformat --check client/scripts client/test` - If formatting fails, run: `gdformat client/scripts client/test` -- Install the repo’s local **pre-push** hook from the repo root to enforce those checks automatically: +- Install the repo’s local **pre-push** hook from the repo root to enforce those checks when **`client/scripts/`** or **`client/test/`** `.gd` files change in the push: `./scripts/install-git-hooks.sh` ## GdUnit test layout (AAA) diff --git a/client/README.md b/client/README.md index 4270d56..2d2f73e 100644 --- a/client/README.md +++ b/client/README.md @@ -353,9 +353,9 @@ pwsh -File scripts/install-git-hooks.ps1 This installs: - **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, or when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates. -- **pre-push**: runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`**. +- **pre-push**: when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** (same scope as CI). Otherwise skips lint/format. Still requires a **clean working tree** on every push. -The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** and blocks the push — same as CI. +The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** when GDScript changed — same as CI. Pushes with no client `.gd` changes do not require gdtoolkit installed. **If CI fails gdlint but your push succeeded:** the hook was not installed, **`gdlint`/`gdformat` were missing** when the hook ran, or push bypassed hooks (`--no-verify`). diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index 7ff7e00..281e035 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -11,6 +11,70 @@ if [[ -n "$(git status --porcelain)" ]]; then exit 1 fi +zero_sha="0000000000000000000000000000000000000000" +gd_changed_files=() + +collect_gd_changes_in_range() { + local range="$1" + while IFS= read -r -d '' path; do + gd_changed_files+=("$path") + done < <( + git diff --name-only -z --diff-filter=ACMR "$range" -- \ + 'client/scripts/*.gd' 'client/test/*.gd' 2>/dev/null || true + ) +} + +read_any_ref=false +while read -r local_ref local_sha remote_ref remote_sha; do + read_any_ref=true + if [[ "$local_sha" == "$zero_sha" ]]; then + continue + fi + + local range="" + if [[ "$remote_sha" == "$zero_sha" ]]; then + local base="" + if git show-ref --verify --quiet refs/remotes/origin/main; then + base="$(git merge-base origin/main "$local_sha" 2>/dev/null || true)" + fi + if [[ -z "$base" ]] && git show-ref --verify --quiet refs/heads/main; then + base="$(git merge-base main "$local_sha" 2>/dev/null || true)" + fi + if [[ -n "$base" ]]; then + range="${base}..${local_sha}" + else + range="$local_sha" + fi + else + range="${remote_sha}..${local_sha}" + fi + + collect_gd_changes_in_range "$range" +done + +# Fallback when stdin is empty (non-standard invocations). +if [[ "$read_any_ref" == false ]]; then + local head_sha + head_sha="$(git rev-parse HEAD)" + local base="" + if git show-ref --verify --quiet refs/remotes/origin/main; then + base="$(git merge-base origin/main "$head_sha" 2>/dev/null || true)" + fi + if [[ -z "$base" ]] && git show-ref --verify --quiet refs/heads/main; then + base="$(git merge-base main "$head_sha" 2>/dev/null || true)" + fi + if [[ -n "$base" ]]; then + collect_gd_changes_in_range "${base}..${head_sha}" + else + collect_gd_changes_in_range "$head_sha" + fi +fi + +if [[ ${#gd_changed_files[@]} -eq 0 ]]; then + echo "pre-push: no client/scripts or client/test .gd changes in push — skipping gdlint/gdformat" + exit 0 +fi + gdlint_bin="" gdformat_bin="" @@ -38,6 +102,9 @@ if [[ -z "$gdlint_bin" || -z "$gdformat_bin" ]]; then exit 1 fi +echo "pre-push: client .gd changes detected — running gdlint and gdformat --check" +printf ' %s\n' "${gd_changed_files[@]}" + echo "pre-push: running gdlint on client/scripts and client/test" "$gdlint_bin" client/scripts client/test From 4379e421063d15c559cedde1be98209d16e769f2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:43:49 -0400 Subject: [PATCH 07/10] NEO-110: fix gdlint line length and gdformat for CI. --- client/README.md | 13 +++++-- client/scripts/encounter_progress_client.gd | 2 +- client/scripts/main.gd | 20 ++++++----- client/test/encounter_progress_client_test.gd | 12 ++----- scripts/install-git-hooks.ps1 | 36 +++++-------------- scripts/install-git-hooks.sh | 33 ++++++----------- 6 files changed, 45 insertions(+), 71 deletions(-) diff --git a/client/README.md b/client/README.md index 2d2f73e..8330929 100644 --- a/client/README.md +++ b/client/README.md @@ -338,19 +338,26 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor. -**Git hooks (recommended):** install the repo’s local hooks from the repo root (once per clone): +**Git hooks (recommended):** configure hooks from the repo root (once per clone). This sets **`core.hooksPath=scripts/git-hooks`** so Git runs the tracked hooks directly (no copy under `.git/hooks`): ```bash ./scripts/install-git-hooks.sh ``` -On **Windows** (PowerShell), same hook: +On **Windows** (PowerShell): ```powershell pwsh -File scripts/install-git-hooks.ps1 ``` -This installs: +Then install **gdtoolkit** (same version as CI) when you work on GDScript: + +```bash +python3 -m venv .venv-gd +.venv-gd/bin/pip install "gdtoolkit==4.5.0" +``` + +This enables: - **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, or when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates. - **pre-push**: when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** (same scope as CI). Otherwise skips lint/format. Still requires a **clean working tree** on every push. diff --git a/client/scripts/encounter_progress_client.gd b/client/scripts/encounter_progress_client.gd index e720722..c0910d5 100644 --- a/client/scripts/encounter_progress_client.gd +++ b/client/scripts/encounter_progress_client.gd @@ -1,6 +1,6 @@ extends Node -## NEO-110: prototype HTTP client for [code]GET /game/players/{id}/encounter-progress[/code] (NEO-108). +## NEO-110: HTTP client for per-player encounter-progress GET (NEO-108). signal encounter_progress_received(snapshot: Dictionary) signal encounter_sync_failed(reason: String) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 29f8460..ca30d44 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -769,19 +769,23 @@ func _render_encounter_progress_label() -> void: var body: String = "" match state: "inactive": - body = "%s: not started (0/%d)" % [ - PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS - ] + body = ( + "%s: not started (0/%d)" + % [PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS] + ) "active": var defeated: Variant = row.get("defeatedTargetIds", []) var n: int = defeated.size() if defeated is Array else 0 body = "%s: %d/%d" % [PROTOTYPE_ENCOUNTER_ID, n, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS] "completed": - body = "%s: completed (%d/%d)" % [ - PROTOTYPE_ENCOUNTER_ID, - PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, - PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, - ] + body = ( + "%s: completed (%d/%d)" + % [ + PROTOTYPE_ENCOUNTER_ID, + PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, + PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, + ] + ) _: body = "%s: unknown state (%s)" % [PROTOTYPE_ENCOUNTER_ID, state] _encounter_progress_label.text = "%s\n%s" % [header, body] diff --git a/client/test/encounter_progress_client_test.gd b/client/test/encounter_progress_client_test.gd index 46b8295..0e670b1 100644 --- a/client/test/encounter_progress_client_test.gd +++ b/client/test/encounter_progress_client_test.gd @@ -82,9 +82,7 @@ func test_parse_inactive_encounter_row() -> void: assert_that(snapshot is Dictionary).is_true() var c: Node = EncounterProgressClient.new() auto_free(c) - var row: Dictionary = c.call( - "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary - ) + var row: Dictionary = c.call("encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary) assert_that(str(row.get("state", ""))).is_equal("inactive") assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(0) @@ -98,9 +96,7 @@ func test_parse_active_row_has_two_defeats() -> void: assert_that(snapshot is Dictionary).is_true() var c: Node = EncounterProgressClient.new() auto_free(c) - var row: Dictionary = c.call( - "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary - ) + var row: Dictionary = c.call("encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary) assert_that(str(row.get("state", ""))).is_equal("active") assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(2) @@ -114,9 +110,7 @@ func test_parse_completed_row_has_reward_grant_summary() -> void: assert_that(snapshot is Dictionary).is_true() var c: Node = EncounterProgressClient.new() auto_free(c) - var row: Dictionary = c.call( - "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary - ) + var row: Dictionary = c.call("encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary) assert_that(str(row.get("state", ""))).is_equal("completed") var grants: Variant = row.get("rewardGrantSummary", null) assert_that(grants is Array).is_true() diff --git a/scripts/install-git-hooks.ps1 b/scripts/install-git-hooks.ps1 index ff28665..5035a40 100644 --- a/scripts/install-git-hooks.ps1 +++ b/scripts/install-git-hooks.ps1 @@ -1,30 +1,12 @@ -# Installs repo pre-commit and pre-push hooks. Same behavior as install-git-hooks.sh. +# Configures core.hooksPath to scripts/git-hooks (pre-commit + pre-push). # Run from repo root: pwsh -File scripts/install-git-hooks.ps1 $ErrorActionPreference = "Stop" $repoRoot = (git rev-parse --show-toplevel).Trim() -$hookDir = Join-Path $repoRoot ".git/hooks" -$preCommitHookPath = Join-Path $hookDir "pre-commit" -$hookPath = Join-Path $hookDir "pre-push" -New-Item -ItemType Directory -Force -Path $hookDir | Out-Null -$unixRoot = $repoRoot -replace "\\", "/" -$preCommitLines = @( - "#!/bin/sh", - "set -e", - "repo_root=`"$unixRoot`"", - 'cd "$repo_root" || exit 1', - 'exec sh "$repo_root/scripts/git-hooks/pre-commit" "$@"' -) -$preCommitContent = ($preCommitLines -join "`n") + "`n" -$pushLines = @( - "#!/bin/sh", - "set -e", - "repo_root=`"$unixRoot`"", - 'cd "$repo_root" || exit 1', - 'exec sh "$repo_root/scripts/git-hooks/pre-push" "$@"' -) -$content = ($pushLines -join "`n") + "`n" -$utf8 = New-Object System.Text.UTF8Encoding $false -[System.IO.File]::WriteAllText($preCommitHookPath, $preCommitContent, $utf8) -[System.IO.File]::WriteAllText($hookPath, $content, $utf8) -Write-Host "Installed pre-commit hook at $preCommitHookPath" -Write-Host "Installed pre-push hook at $hookPath" +Set-Location $repoRoot +git config core.hooksPath scripts/git-hooks +Write-Host "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)" +Write-Host "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push." +Write-Host "" +Write-Host "Install gdtoolkit (required when pushing GDScript changes):" +Write-Host " python -m venv .venv-gd" +Write-Host " .venv-gd/Scripts/pip install `"gdtoolkit==4.5.0`"" diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index 10e4a65..9947242 100755 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -2,29 +2,16 @@ set -euo pipefail repo_root="$(git rev-parse --show-toplevel)" -hook_dir="$repo_root/.git/hooks" -pre_commit_hook_path="$hook_dir/pre-commit" -hook_path="$hook_dir/pre-push" +cd "$repo_root" -mkdir -p "$hook_dir" +hooks_src="$repo_root/scripts/git-hooks" +chmod +x "$hooks_src/pre-commit" "$hooks_src/pre-push" -cat >"$pre_commit_hook_path" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail +git config core.hooksPath scripts/git-hooks -repo_root="$(git rev-parse --show-toplevel)" -exec "$repo_root/scripts/git-hooks/pre-commit" "$@" -EOF - -cat >"$hook_path" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel)" -exec "$repo_root/scripts/git-hooks/pre-push" "$@" -EOF - -chmod +x "$pre_commit_hook_path" -chmod +x "$hook_path" -echo "Installed pre-commit hook at $pre_commit_hook_path" -echo "Installed pre-push hook at $hook_path" +echo "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)" +echo "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push." +echo "" +echo "Install gdtoolkit (required when pushing GDScript changes):" +echo " python3 -m venv .venv-gd" +echo " .venv-gd/bin/pip install \"gdtoolkit==4.5.0\"" From e93fb22f1a548668e977ed0927fb1f2ba213608d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:43:53 -0400 Subject: [PATCH 08/10] chore: bash 3.2-safe pre-commit; document hooksPath + gdtoolkit setup. --- client/README.md | 2 +- scripts/git-hooks/pre-commit | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/client/README.md b/client/README.md index 8330929..6f2bf55 100644 --- a/client/README.md +++ b/client/README.md @@ -338,7 +338,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor. -**Git hooks (recommended):** configure hooks from the repo root (once per clone). This sets **`core.hooksPath=scripts/git-hooks`** so Git runs the tracked hooks directly (no copy under `.git/hooks`): +**Git hooks (recommended):** configure hooks from the repo root (once per clone). This sets **`core.hooksPath=scripts/git-hooks`** so Git runs the tracked **`pre-commit`** / **`pre-push`** scripts (bash 3.2+ compatible): ```bash ./scripts/install-git-hooks.sh diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index ff710bd..426303c 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -4,7 +4,10 @@ set -euo pipefail repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" -mapfile -t staged_files < <(git diff --cached --name-only --diff-filter=ACMR) +staged_files=() +while IFS= read -r f; do + staged_files+=("$f") +done < <(git diff --cached --name-only --diff-filter=ACMR) if [[ ${#staged_files[@]} -eq 0 ]]; then exit 0 fi From cfad03e61f2b0d8909a7d0f90e1493a0ce818d0a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:45:22 -0400 Subject: [PATCH 09/10] chore: document git hooks and gdtoolkit setup in repo README. --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 3342f86..49599b1 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,19 @@ Connection (dev): host `localhost`, port `5432`, database `neon_sprawl`, user `n ### Run the client Open the [`client/`](client/) folder in **Godot 4.6** and run the main scene (see [`client/README.md`](client/README.md)). + +### Git hooks and GDScript lint (recommended) + +Once per clone, from the repo root: + +```bash +./scripts/install-git-hooks.sh +python3 -m venv .venv-gd +.venv-gd/bin/pip install "gdtoolkit==4.5.0" +``` + +This sets **`core.hooksPath=scripts/git-hooks`**. On push, when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, **pre-push** runs **`gdlint`** and **`gdformat --check`** on those trees (same as [`.github/workflows/gdscript.yml`](.github/workflows/gdscript.yml)). Install **gdtoolkit** in **`.venv-gd`** (gitignored) or globally so the hook can run; otherwise pushes with GDScript changes are blocked. + +Windows (PowerShell): `pwsh -File scripts/install-git-hooks.ps1`, then create `.venv-gd` and `pip install "gdtoolkit==4.5.0"` the same way. + +More detail: [`client/README.md`](client/README.md) (Git hooks section). From 2bde7711073802ac3d6a4d79ba4a3064beba5d8c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 19:46:51 -0400 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20pre-push=20hook=20bash=203.2=20?= =?UTF-8?q?=E2=80=94=20no=20local=20outside=20functions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/git-hooks/pre-push | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index 281e035..16481c4 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -31,9 +31,9 @@ while read -r local_ref local_sha remote_ref remote_sha; do continue fi - local range="" + range="" if [[ "$remote_sha" == "$zero_sha" ]]; then - local base="" + base="" if git show-ref --verify --quiet refs/remotes/origin/main; then base="$(git merge-base origin/main "$local_sha" 2>/dev/null || true)" fi @@ -54,9 +54,8 @@ done # Fallback when stdin is empty (non-standard invocations). if [[ "$read_any_ref" == false ]]; then - local head_sha head_sha="$(git rev-parse HEAD)" - local base="" + base="" if git show-ref --verify --quiet refs/remotes/origin/main; then base="$(git merge-base origin/main "$head_sha" 2>/dev/null || true)" fi