Merge pull request #149 from ViPro-Technologies/NEO-110-client-encounter-progress-loot-feedback-hud
NEO-110: Client encounter progress + loot feedback HUDpull/150/head
commit
e87064f43f
|
|
@ -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)
|
||||
|
|
|
|||
16
README.md
16
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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
||||
**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 **`pre-commit`** / **`pre-push`** scripts (bash 3.2+ compatible):
|
||||
|
||||
```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**: 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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo110encprog01
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -471,10 +479,10 @@ 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 _encounter_progress_error.is_empty() and not _last_encounter_progress_snapshot.is_empty():
|
||||
_render_encounter_progress_labels()
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
@ -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**).
|
||||
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,69 @@ 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
|
||||
|
||||
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=""
|
||||
gdformat_bin=""
|
||||
|
||||
|
|
@ -38,6 +101,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`""
|
||||
|
|
|
|||
|
|
@ -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\""
|
||||
|
|
|
|||
Loading…
Reference in New Issue