Merge pull request #149 from ViPro-Technologies/NEO-110-client-encounter-progress-loot-feedback-hud

NEO-110: Client encounter progress + loot feedback HUD
pull/150/head
VinPropane 2026-05-31 19:51:31 -04:00 committed by GitHub
commit e87064f43f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 859 additions and 65 deletions

View File

@ -59,7 +59,7 @@ CI also enforces **`gdlint`** and **`gdformat`** (see `gdlintrc` and `.github/wo
`gdformat --check client/scripts client/test` `gdformat --check client/scripts client/test`
- If formatting fails, run: - If formatting fails, run:
`gdformat client/scripts client/test` `gdformat client/scripts client/test`
- Install the repos local **pre-push** hook from the repo root to enforce those checks automatically: - Install the repos 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` `./scripts/install-git-hooks.sh`
## GdUnit test layout (AAA) ## GdUnit test layout (AAA)

View File

@ -52,3 +52,19 @@ Connection (dev): host `localhost`, port `5432`, database `neon_sprawl`, user `n
### Run the client ### Run the client
Open the [`client/`](client/) folder in **Godot 4.6** and run the main scene (see [`client/README.md`](client/README.md)). 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).

View File

@ -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). 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) ## 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). - **`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).
@ -326,24 +338,31 @@ 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. **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 repos 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 **`pre-commit`** / **`pre-push`** scripts (bash 3.2+ compatible):
```bash ```bash
./scripts/install-git-hooks.sh ./scripts/install-git-hooks.sh
``` ```
On **Windows** (PowerShell), same hook: On **Windows** (PowerShell):
```powershell ```powershell
pwsh -File scripts/install-git-hooks.ps1 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-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`). **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`).

View File

@ -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/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/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/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="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"] [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] [node name="GigProgressionClient" type="Node" parent="." unique_id=2500011]
script = ExtResource("21_gig_prog") 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] [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
script = ExtResource("17_recipe_defs") script = ExtResource("17_recipe_defs")
@ -1199,6 +1203,27 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3 autowrap_mode = 3
text = "Player HP: —" 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] [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3

View File

@ -0,0 +1,105 @@
extends Node
## NEO-110: HTTP client for per-player encounter-progress GET (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)

View File

@ -0,0 +1 @@
uid://bneo110encprog01

View File

@ -54,6 +54,8 @@ const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
const SALVAGE_SKILL_ID := "salvage" const SALVAGE_SKILL_ID := "salvage"
const REFINE_SKILL_ID := "refine" const REFINE_SKILL_ID := "refine"
const BREACH_GIG_ID := "breach" 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. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@ -104,6 +106,8 @@ var _last_progression_snapshot: Dictionary = {}
var _progression_error: String = "" var _progression_error: String = ""
var _last_gig_snapshot: Dictionary = {} var _last_gig_snapshot: Dictionary = {}
var _gig_error: String = "" var _gig_error: String = ""
var _last_encounter_progress_snapshot: Dictionary = {}
var _encounter_progress_error: String = ""
var _gather_pre_scrap_qty: int = 0 var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = "" var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false 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 _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel
@onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel @onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel @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 _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel @onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel @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 _item_defs_client: Node = $ItemDefinitionsClient
@onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _skill_progression_client: Node = $SkillProgressionClient
@onready var _gig_progression_client: Node = $GigProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient
@onready var _encounter_progress_client: Node = $EncounterProgressClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient @onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient @onready var _target_client: Node = $TargetSelectionClient
@ -189,6 +196,7 @@ func _ready() -> void:
_setup_inventory_sync() _setup_inventory_sync()
_setup_skill_progression_sync() _setup_skill_progression_sync()
_setup_gig_progression_sync() _setup_gig_progression_sync()
_setup_encounter_progress_sync()
_setup_gather_interact_feedback() _setup_gather_interact_feedback()
_setup_craft_ui() _setup_craft_ui()
@ -471,10 +479,10 @@ func _apply_authority_http_config_to_client(client: Node) -> void:
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
_populate_craft_recipe_panel_if_ready() _populate_craft_recipe_panel_if_ready()
if not _inventory_error.is_empty(): if _inventory_error.is_empty() and not _last_inventory_snapshot.is_empty():
return
if not _last_inventory_snapshot.is_empty():
_render_inventory_label() _render_inventory_label()
if _encounter_progress_error.is_empty() and not _last_encounter_progress_snapshot.is_empty():
_render_encounter_progress_labels()
func _on_inventory_received(snapshot: Dictionary) -> void: func _on_inventory_received(snapshot: Dictionary) -> void:
@ -705,6 +713,135 @@ func _request_gig_progression_refresh() -> void:
_gig_progression_client.call("request_sync_from_server") _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 = _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)")
_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: func _render_gather_feedback_label(text: String = "Gather: —") -> void:
if is_instance_valid(_gather_feedback_label): if is_instance_valid(_gather_feedback_label):
_gather_feedback_label.text = text _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() _request_npc_combat_sync()
if bool(resolution.get("targetDefeated", false)): if bool(resolution.get("targetDefeated", false)):
_request_gig_progression_refresh() _request_gig_progression_refresh()
_request_encounter_progress_refresh()
return return
var rc := reason_code.strip_edges() var rc := reason_code.strip_edges()
if rc.is_empty(): if rc.is_empty():

View File

@ -0,0 +1,168 @@
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)")
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"
)

View File

@ -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). **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 ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.

File diff suppressed because one or more lines are too long

View File

@ -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-92NEO-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.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-92NEO-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 ### Epic 6 — PvP Security

View File

@ -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 18 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**.

View File

@ -337,9 +337,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). - [x] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets).
- [ ] Completion label shows grant summary matching server GET. - [x] Completion label shows grant summary matching server GET.
- [ ] Manual QA checklist exercisable without Bruno. - [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**). **Client counterpart:** this issue (**NEO-110**).

View File

@ -0,0 +1,159 @@
# 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
- [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
### 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). | **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** |

View File

@ -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
**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, 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
| 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` | **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()` now calls **`_inventory_item_label(item_id)`**. Done.
## Suggestions
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**~~**NEO-110** landed + client README / manual QA pointers; **NEO-111** capstone outstanding. Done.
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
- 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.

View File

@ -4,7 +4,10 @@ set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)" repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root" 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 if [[ ${#staged_files[@]} -eq 0 ]]; then
exit 0 exit 0
fi fi

View File

@ -11,6 +11,69 @@ if [[ -n "$(git status --porcelain)" ]]; then
exit 1 exit 1
fi 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
range=""
if [[ "$remote_sha" == "$zero_sha" ]]; then
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
head_sha="$(git rev-parse HEAD)"
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="" gdlint_bin=""
gdformat_bin="" gdformat_bin=""
@ -38,6 +101,9 @@ if [[ -z "$gdlint_bin" || -z "$gdformat_bin" ]]; then
exit 1 exit 1
fi 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" echo "pre-push: running gdlint on client/scripts and client/test"
"$gdlint_bin" client/scripts client/test "$gdlint_bin" client/scripts client/test

View File

@ -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 # Run from repo root: pwsh -File scripts/install-git-hooks.ps1
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$repoRoot = (git rev-parse --show-toplevel).Trim() $repoRoot = (git rev-parse --show-toplevel).Trim()
$hookDir = Join-Path $repoRoot ".git/hooks" Set-Location $repoRoot
$preCommitHookPath = Join-Path $hookDir "pre-commit" git config core.hooksPath scripts/git-hooks
$hookPath = Join-Path $hookDir "pre-push" Write-Host "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)"
New-Item -ItemType Directory -Force -Path $hookDir | Out-Null Write-Host "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push."
$unixRoot = $repoRoot -replace "\\", "/" Write-Host ""
$preCommitLines = @( Write-Host "Install gdtoolkit (required when pushing GDScript changes):"
"#!/bin/sh", Write-Host " python -m venv .venv-gd"
"set -e", Write-Host " .venv-gd/Scripts/pip install `"gdtoolkit==4.5.0`""
"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"

View File

@ -2,29 +2,16 @@
set -euo pipefail set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)" repo_root="$(git rev-parse --show-toplevel)"
hook_dir="$repo_root/.git/hooks" cd "$repo_root"
pre_commit_hook_path="$hook_dir/pre-commit"
hook_path="$hook_dir/pre-push"
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' git config core.hooksPath scripts/git-hooks
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)" echo "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)"
exec "$repo_root/scripts/git-hooks/pre-commit" "$@" echo "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push."
EOF echo ""
echo "Install gdtoolkit (required when pushing GDScript changes):"
cat >"$hook_path" <<'EOF' echo " python3 -m venv .venv-gd"
#!/usr/bin/env bash echo " .venv-gd/bin/pip install \"gdtoolkit==4.5.0\""
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"