Merge pull request #122 from ViPro-Technologies/NEO-86-playable-combat-capstone
NEO-86: Playable tab-target combat capstone (Godot)pull/123/head
commit
dab67df0ce
|
|
@ -149,6 +149,31 @@ Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cas
|
|||
|
||||
Full checklist: [`docs/manual-qa/NEO-85.md`](../docs/manual-qa/NEO-85.md).
|
||||
|
||||
## End-to-end combat loop (NEO-86)
|
||||
|
||||
Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility **in Godot** without Bruno.
|
||||
|
||||
**Flow:** boot hotbar sync (auto **`prototype_pulse`** bind on slot 0 when empty) → walk to orange **Dummy α** → **Tab** lock → digit **1** cast until defeat → verify combat HUD + **`breach`** gig XP refresh.
|
||||
|
||||
| Step | Input / trigger | HUD / server outcome |
|
||||
|------|-----------------|----------------------|
|
||||
| Boot | Godot **F5** + server running | Hotbar slot 0 binds **`prototype_pulse`**; **`GigXpLabel`** hydrates **`breach: L1 · 0 xp`** |
|
||||
| Lock | **Tab** on **`prototype_target_alpha`** | Dummy brightens; **`CombatTargetHpLabel`** shows **`100/100`** |
|
||||
| Cast ×4 | **1** (respect ~3s cooldown) | **`CastFeedbackLabel`** steps damage; HP label tracks server snapshot |
|
||||
| Defeat | 4th pulse accept | **`Cast: … — defeated!`**; HP **`0/100 (defeated)`**; **`GigXpLabel`** → **`25 xp`** |
|
||||
| Deny | **1** on defeated target | **`ability_cast_denied: target_defeated`** |
|
||||
| Clear | **Esc** | HP label **`— (no lock)`** |
|
||||
|
||||
**Cross-links:** [NEO-23](../docs/plans/NEO-23-implementation-plan.md) targeting · [NEO-28](../docs/manual-qa/NEO-28.md) cast deny HUD · [NEO-31](../docs/manual-qa/NEO-31.md) cast POST · [NEO-85](../docs/manual-qa/NEO-85.md) combat feedback + HP · [NEO-44](../docs/manual-qa/NEO-44.md) server gig grant.
|
||||
|
||||
**Scripts:** `ability_cast_client.gd`, `combat_targets_client.gd`, `gig_progression_client.gd` — wired from `main.gd`.
|
||||
|
||||
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
|
||||
|
||||
**Preconditions:** **Server restart** before capstone run resets in-memory dummy HP and gig XP baseline.
|
||||
|
||||
Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.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).
|
||||
|
|
@ -257,7 +282,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
|
|||
|
||||
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
|
||||
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
|
||||
|
||||
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
[ext_resource type="Script" path="res://scripts/craft_client.gd" id="18_craft"]
|
||||
[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"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||
geometry_collision_mask = 1
|
||||
|
|
@ -1105,6 +1106,9 @@ script = ExtResource("15_inventory")
|
|||
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
|
||||
script = ExtResource("16_skill_prog")
|
||||
|
||||
[node name="GigProgressionClient" type="Node" parent="." unique_id=2500011]
|
||||
script = ExtResource("21_gig_prog")
|
||||
|
||||
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
|
|
@ -1269,6 +1273,18 @@ autowrap_mode = 3
|
|||
text = "Skills:
|
||||
Loading…"
|
||||
|
||||
[node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000018]
|
||||
custom_minimum_size = Vector2(512, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.88, 0.78, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_font_sizes/font_size = 14
|
||||
autowrap_mode = 3
|
||||
text = "Gig XP:
|
||||
Loading…"
|
||||
|
||||
[node name="CraftRecipePanel" type="Control" parent="UICanvas/EconomyHudSection/Body" unique_id=9000011]
|
||||
custom_minimum_size = Vector2(512, 280)
|
||||
layout_mode = 2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
extends Node
|
||||
|
||||
## NEO-86: prototype HTTP client for [code]GET /game/players/{id}/gig-progression[/code] (NEO-44).
|
||||
|
||||
signal progression_received(snapshot: Dictionary)
|
||||
signal progression_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/gig-progression" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("GigProgressionClient: %s" % reason)
|
||||
_busy = false
|
||||
progression_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func gig_row(gig_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var gigs: Variant = snapshot.get("gigs", null)
|
||||
if gigs == null or not gigs is Array:
|
||||
return {}
|
||||
for row_variant in gigs as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == gig_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_progression_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 gigs: Variant = root.get("gigs", null)
|
||||
if gigs == null or not gigs is Array:
|
||||
return null
|
||||
if str(root.get("mainGigId", "")).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("GigProgressionClient: %s" % reason)
|
||||
progression_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("GigProgressionClient: %s" % reason404)
|
||||
progression_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("GigProgressionClient: %s" % reason_code)
|
||||
progression_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_progression_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("GigProgressionClient: %s" % reason_json)
|
||||
progression_sync_failed.emit(reason_json)
|
||||
return
|
||||
progression_received.emit(snapshot as Dictionary)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo86gigprog01
|
||||
|
|
@ -47,6 +47,7 @@ const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse"
|
|||
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
|
||||
const SALVAGE_SKILL_ID := "salvage"
|
||||
const REFINE_SKILL_ID := "refine"
|
||||
const BREACH_GIG_ID := "breach"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -86,6 +87,8 @@ var _inventory_error: String = ""
|
|||
var _interactables_catalog: Array = []
|
||||
var _last_progression_snapshot: Dictionary = {}
|
||||
var _progression_error: String = ""
|
||||
var _last_gig_snapshot: Dictionary = {}
|
||||
var _gig_error: String = ""
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
|
|
@ -113,10 +116,12 @@ var _dev_pulse_bootstrap_attempted: bool = false
|
|||
@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel
|
||||
@onready
|
||||
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
|
||||
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel")
|
||||
@onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/CraftRecipePanel")
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _gig_progression_client: Node = $GigProgressionClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
|
|
@ -163,6 +168,7 @@ func _ready() -> void:
|
|||
_setup_hotbar_loadout_sync()
|
||||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gig_progression_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_setup_craft_ui()
|
||||
|
||||
|
|
@ -501,6 +507,22 @@ func _setup_skill_progression_sync() -> void:
|
|||
_skill_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_gig_progression_sync() -> void:
|
||||
# NEO-86: breach gig XP HUD row; boot hydrate + refresh after defeat cast.
|
||||
_apply_authority_http_config_to_client(_gig_progression_client)
|
||||
if _gig_progression_client.has_signal("progression_received"):
|
||||
_gig_progression_client.connect(
|
||||
"progression_received", Callable(self, "_on_gig_progression_received")
|
||||
)
|
||||
if _gig_progression_client.has_signal("progression_sync_failed"):
|
||||
_gig_progression_client.connect(
|
||||
"progression_sync_failed", Callable(self, "_on_gig_progression_sync_failed")
|
||||
)
|
||||
_render_gig_xp_label()
|
||||
if _gig_progression_client.has_method("request_sync_from_server"):
|
||||
_gig_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_gather_interact_feedback() -> void:
|
||||
if not is_instance_valid(_interaction_client):
|
||||
return
|
||||
|
|
@ -564,6 +586,53 @@ func _skill_row(skill_id: String) -> Dictionary:
|
|||
return row as Dictionary
|
||||
|
||||
|
||||
func _on_gig_progression_received(snapshot: Dictionary) -> void:
|
||||
_gig_error = ""
|
||||
_last_gig_snapshot = snapshot.duplicate(true)
|
||||
_render_gig_xp_label()
|
||||
|
||||
|
||||
func _on_gig_progression_sync_failed(reason: String) -> void:
|
||||
_gig_error = reason
|
||||
_last_gig_snapshot = {}
|
||||
_render_gig_xp_label()
|
||||
|
||||
|
||||
func _render_gig_xp_label() -> void:
|
||||
if not is_instance_valid(_gig_xp_label):
|
||||
return
|
||||
var header := "Gig XP:"
|
||||
if not _gig_error.is_empty():
|
||||
_gig_xp_label.text = "%s\nerror — %s" % [header, _gig_error]
|
||||
return
|
||||
if _last_gig_snapshot.is_empty():
|
||||
_gig_xp_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var breach_row: Dictionary = _gig_row(BREACH_GIG_ID)
|
||||
if breach_row.is_empty():
|
||||
_gig_xp_label.text = "%s\nbreach: — (missing row)" % header
|
||||
return
|
||||
var level: int = int(breach_row.get("level", 0))
|
||||
var xp: int = int(breach_row.get("xp", 0))
|
||||
_gig_xp_label.text = "%s\nbreach: L%d · %d xp" % [header, level, xp]
|
||||
|
||||
|
||||
func _gig_row(gig_id: String) -> Dictionary:
|
||||
if not is_instance_valid(_gig_progression_client):
|
||||
return {}
|
||||
if not _gig_progression_client.has_method("gig_row"):
|
||||
return {}
|
||||
var row: Variant = _gig_progression_client.call("gig_row", gig_id, _last_gig_snapshot)
|
||||
return row as Dictionary
|
||||
|
||||
|
||||
func _request_gig_progression_refresh() -> void:
|
||||
if not is_instance_valid(_gig_progression_client):
|
||||
return
|
||||
if _gig_progression_client.has_method("request_sync_from_server"):
|
||||
_gig_progression_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
|
||||
|
|
@ -859,6 +928,8 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D
|
|||
if _cooldown_client.has_method("request_sync_from_server"):
|
||||
_cooldown_client.call("request_sync_from_server")
|
||||
_request_combat_targets_refresh()
|
||||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
return
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-86: defeat cast accept triggers gig-progression GET refresh only.
|
||||
|
||||
const CastClient := preload("res://scripts/ability_cast_client.gd")
|
||||
|
||||
|
||||
class MockCastTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class SpyGigProgressionClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class GigRefreshHarness:
|
||||
extends Node
|
||||
var gig_sync_calls: int = 0
|
||||
var _cast: Node
|
||||
var _gig: SpyGigProgressionClient
|
||||
|
||||
func setup(parent: Node, cast_transport: Node) -> void:
|
||||
_gig = SpyGigProgressionClient.new()
|
||||
_cast = CastClient.new()
|
||||
_cast.set("injected_http", cast_transport)
|
||||
parent.add_child(_cast)
|
||||
parent.add_child(_gig)
|
||||
_cast.connect("cast_result_received", Callable(self, "_on_cast_result"))
|
||||
|
||||
func _on_cast_result(accepted: bool, _reason_code: String, resolution: Dictionary) -> void:
|
||||
if not accepted:
|
||||
return
|
||||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
|
||||
func _request_gig_progression_refresh() -> void:
|
||||
gig_sync_calls += 1
|
||||
_gig.request_sync_from_server()
|
||||
|
||||
func request_cast() -> void:
|
||||
_cast.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha")
|
||||
|
||||
|
||||
func test_defeat_cast_triggers_gig_progression_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":0,"targetDefeated":true}}'
|
||||
)
|
||||
var harness := GigRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any())
|
||||
assert_that(harness.gig_sync_calls).is_equal(1)
|
||||
assert_that(harness._gig.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_non_defeat_cast_skips_gig_progression_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}'
|
||||
)
|
||||
var harness := GigRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any())
|
||||
assert_that(harness.gig_sync_calls).is_equal(0)
|
||||
|
||||
|
||||
func test_denied_cast_skips_gig_progression_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
transport.body_json = ('{"schemaVersion":1,"accepted":false,"reasonCode":"target_defeated"}')
|
||||
var harness := GigRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted(
|
||||
"cast_result_received", false, "target_defeated", any()
|
||||
)
|
||||
assert_that(harness.gig_sync_calls).is_equal(0)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo86gigrefresh1
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-86: `GigProgressionClient` GET parse + failure signals.
|
||||
|
||||
const GigProgressionClient := preload("res://scripts/gig_progression_client.gd")
|
||||
|
||||
var _progression_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_progression(snapshot: Dictionary) -> void:
|
||||
_progression_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 _breach_progression_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","mainGigId":"breach",'
|
||||
+ '"gigs":[{"id":"breach","xp":25,"level":1}]}'
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = GigProgressionClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_progression_json_reads_breach_row() -> void:
|
||||
# Arrange
|
||||
var json := _breach_progression_json()
|
||||
# Act
|
||||
var snapshot: Variant = GigProgressionClient.parse_progression_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = GigProgressionClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("gig_row", "breach", snapshot as Dictionary)
|
||||
assert_that(int(row.get("xp", -1))).is_equal(25)
|
||||
assert_that(int(row.get("level", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_request_sync_emits_progression_received() -> void:
|
||||
# Arrange
|
||||
_progression_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _breach_progression_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("progression_received", Callable(self, "_capture_progression"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("progression_received", any())
|
||||
assert_that(_progression_capture.get("mainGigId", "")).is_equal("breach")
|
||||
assert_that(transport.last_url).contains("/gig-progression")
|
||||
|
||||
|
||||
func test_http_404_emits_progression_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("progression_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_invalid_schema_emits_progression_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":2,"mainGigId":"breach","gigs":[]}'
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted(
|
||||
"progression_sync_failed", "non-JSON body or schemaVersion mismatch"
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo86gigprogtest1
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E5.M1 |
|
||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09 [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) combat telemetry hook sites landed; E5M1-10 [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat landed; E5M1-11 [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client combat HUD landed; E5M1-12 pending (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | Ready — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09 [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) combat telemetry hook sites landed; E5M1-10 [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat landed; E5M1-11 [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client combat HUD landed; E5M1-12 [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) playable combat capstone landed — Epic 5 Slice 1 client complete (see [dependency register](module_dependency_register.md)) |
|
||||
| **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) |
|
||||
|
||||
## Purpose
|
||||
|
|
@ -66,6 +66,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a
|
|||
|
||||
**E5M1-11 (NEO-85):** client combat HUD — **`ability_cast_client.gd`** parses nested **`combatResolution`** on accept; **`combat_targets_client.gd`** + **`CombatTargetHpLabel`**; **`CastFeedbackLabel`** damage/defeat copy; event-driven **`GET /game/world/combat-targets`** refresh on cast accept and lock change ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85).
|
||||
|
||||
**E5M1-12 (NEO-86):** playable combat capstone — **`gig_progression_client.gd`** + **`GigXpLabel`**; defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). Epic 5 Slice 1 client capstone complete.
|
||||
|
||||
## Linear backlog (decomposed)
|
||||
|
||||
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned |
|
||||
| E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned |
|
||||
|
||||
**E5.M1 note:** Epic 5 **Slice 1** 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-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). Pending client capstone: **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86).
|
||||
**E5.M1 note:** Epic 5 **Slice 1** 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-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 client capstone complete.**
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
# NEO-86 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-86 |
|
||||
| Title | E5M1-12: Playable tab-target combat capstone (Godot) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-86/e5m1-12-playable-tab-target-combat-capstone-godot |
|
||||
| Plan | `docs/plans/NEO-86-implementation-plan.md` |
|
||||
| Branch | `NEO-86-playable-combat-capstone` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** dummy HP and gig XP reset.
|
||||
- **No Bruno/curl seeding** for this checklist — hotbar pulse bind is automatic (NEO-85 dev bootstrap).
|
||||
- NEO-85 combat HUD and NEO-44 gig progression GET landed on the same branch or `main`.
|
||||
|
||||
## Combat math
|
||||
|
||||
| Cast # | Expected `CastFeedbackLabel` | `CombatTargetHpLabel` |
|
||||
|--------|------------------------------|------------------------|
|
||||
| 1 | `Cast: 25 dmg → 75 HP` | `75/100` |
|
||||
| 2 | `Cast: 25 dmg → 50 HP` | `50/100` |
|
||||
| 3 | `Cast: 25 dmg → 25 HP` | `25/100` |
|
||||
| 4 | `Cast: 25 dmg → 0 HP — defeated!` | `0/100 (defeated)` |
|
||||
| 5 | `ability_cast_denied: target_defeated` | unchanged defeated |
|
||||
|
||||
Respect ~3s **`prototype_pulse`** cooldown between casts.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`GigXpLabel`** shows **`breach: L1 · 0 xp`** after boot hydrate.
|
||||
3. Walk to orange **Dummy α** west of spawn **`(-3, -3)`** (flat lock ring on ground).
|
||||
4. Press **Tab** to lock **`prototype_target_alpha`** — dummy **brightens**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_target_alpha 100/100`** (or **`…`** briefly until GET completes).
|
||||
5. Press **1** (hotbar slot 1 → index 0) four times with cooldown between hits. Verify cast feedback and HP label step per table above.
|
||||
6. After the lethal hit, **`GigXpLabel`** updates to **`breach: L1 · 25 xp`** (event-driven GET refresh).
|
||||
7. Press **1** again on the defeated dummy: **`CastFeedbackLabel`** shows **`ability_cast_denied: target_defeated`**; gig XP stays **25**.
|
||||
8. Press **Esc** to clear lock: **`CombatTargetHpLabel`** shows **`Target HP: — (no lock)`**; combat feedback labels remain readable.
|
||||
9. Uncheck **`Economy HUD`** — gig row hides with inventory/skills/recipes; check again — gig row repopulates.
|
||||
10. Regression: cast without lock still shows **`ability_cast_denied: invalid_target`** on **`CastFeedbackLabel`** (NEO-28).
|
||||
|
||||
## Notes
|
||||
|
||||
- Shared Postgres dev DB may retain prior gig XP totals — capstone baseline prefers **server restart** on in-memory mode.
|
||||
- Optional mid-session dummy-only reset (Development host): `POST /game/__dev/combat-targets-fixture` — see [NEO-85 manual QA](NEO-85.md).
|
||||
- Component-level combat HUD regression: [NEO-85 manual QA](NEO-85.md).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Steps 1–10 completable in one session without Bruno/curl.
|
||||
- [ ] **`prototype_target_alpha`** shows defeated state in HUD after step 5.
|
||||
- [ ] **`breach`** gig XP **25** visible in Godot after step 6.
|
||||
- [ ] No silent cast failures — every deny shows readable reason on **`CastFeedbackLabel`**.
|
||||
|
|
@ -366,9 +366,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes script with server + client; **`prototype_target_alpha`** shows defeated state in HUD.
|
||||
- [ ] Epic 5 Slice 1 AC: combat readable in fixed isometric camera; no silent cast failures.
|
||||
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
- [x] Human completes script with server + client; **`prototype_target_alpha`** shows defeated state in HUD.
|
||||
- [x] Epic 5 Slice 1 AC: combat readable in fixed isometric camera; no silent cast failures.
|
||||
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
|
||||
**Landed ([NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)):** **`gig_progression_client.gd`** + **`GigXpLabel`**; defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../manual-qa/NEO-86.md); plan [NEO-86-implementation-plan.md](NEO-86-implementation-plan.md); `client/README.md` end-to-end combat loop section. Epic 5 Slice 1 client capstone complete.
|
||||
|
||||
**Client counterpart:** this issue (**NEO-86**).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
# NEO-86 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-86 |
|
||||
| **Title** | E5M1-12: Playable tab-target combat capstone (Godot) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-86/e5m1-12-playable-tab-target-combat-capstone-godot |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-12** |
|
||||
| **Branch** | `NEO-86-playable-combat-capstone` |
|
||||
| **Server deps** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast + `combatResolution` (**Done**); [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET (**Done**); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat + GET (**Done**) |
|
||||
| **Client deps** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) combat feedback + target HP HUD (**Done**); E1.M4 cast funnel (NEO-28/31/32) (**Done**) |
|
||||
| **Pattern** | Capstone integration — [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) docs + manual QA primary; minimal client gap-fill for gig visibility |
|
||||
| **Server counterpart** | NEO-44 gig progression GET; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Gig XP visibility** | Include gig XP in the Godot capstone? | **`GigProgressionClient` + `GigXpLabel`** in economy HUD; refresh after defeat cast — NEO-44 landed; NEO-75 capstone is Godot-only (no Bruno/curl). | **User:** Godot gig label + client. |
|
||||
| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; add gig client wiring; fix integration gaps only if capstone QA fails on `main`. | **User:** docs-first; code for gig row or QA failures. |
|
||||
| **Session baseline** | Fresh server vs fixture POST only? | **Fresh server restart** before Godot **F5** — resets in-memory dummy HP + gig XP baseline (NEO-75 / NEO-44 manual QA precedent). | **Adopted** (kickoff default). |
|
||||
|
||||
No other blocking decisions — combat loop HUD, hotbar pulse bootstrap, and event-driven combat-target refresh are landed in NEO-85; field names follow NEO-82 / NEO-44 server vocabulary.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Prove Epic 5 Slice 1 acceptance **in Godot**: tab-target lock, cast abilities, readable deny reasons, defeat **`prototype_target_alpha`**, and visible gig XP after defeat — without Bruno.
|
||||
|
||||
**In scope (from Linear + [E5M1-12](E5M1-prototype-backlog.md#e5m1-12--playable-tab-target-combat-capstone-godot)):**
|
||||
|
||||
- **`docs/manual-qa/NEO-86.md`**: numbered **single-session** capstone script — fresh dev player, Tab lock alpha, digit cast **`prototype_pulse`** until defeat, verify combat HUD + **`breach`** gig XP — **no Bruno/curl** steps.
|
||||
- **`client/README.md`**: **End-to-end combat loop** section — integration checklist (boot → Tab lock → cast → defeat → gig XP refresh chain); cross-links NEO-23/28/31/85.
|
||||
- **`GigProgressionClient`** + **`GigXpLabel`** — mirror **`SkillProgressionClient`** (NEO-37); boot hydrate + refresh when cast accept carries **`targetDefeated: true`**.
|
||||
- **Module alignment** (on story completion): update [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M1 row + E5M1-12 backlog checkboxes; note Epic 5 Slice 1 client capstone complete.
|
||||
- **Integration fixes only if capstone QA fails** on current `main` wiring (unexpected deny, refresh gap, etc.).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- NPC AI (E5.M2), encounters/loot (E5.M3), group scaling.
|
||||
- Final combat VFX, floating damage numbers, player HP bar.
|
||||
- Full nine-gig catalog, hub swap, gig level curves.
|
||||
- Bruno-only verification as prototype-complete proof.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Human completes **`docs/manual-qa/NEO-86.md`** with server + client; **`prototype_target_alpha`** shows defeated state in HUD.
|
||||
- [x] Epic 5 Slice 1 AC: combat readable in fixed isometric camera; no silent cast failures on deny.
|
||||
- [x] **`breach`** gig XP visible in Godot after defeat (≥ **25** from fresh-server baseline).
|
||||
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Client:** `gig_progression_client.gd` GET snapshot + **`gig_row`**; **`GigXpLabel`** under economy HUD; defeat-triggered refresh in **`_on_cast_result_received`**.
|
||||
- **Tests:** `gig_progression_client_test.gd`, `gig_feedback_refresh_test.gd` (5 cases pass).
|
||||
- **Docs:** [`NEO-86` manual QA](../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section; E5M1 backlog + module alignment rows updated.
|
||||
- **Integration:** No capstone QA gaps on `main` beyond gig client wiring.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Capstone manual QA script (`docs/manual-qa/NEO-86.md`)
|
||||
|
||||
**Preconditions:** Stop any running server; start fresh (`dotnet run`) so in-memory **`dev-local-1`** dummy HP and gig XP reset. Godot **F5** with **no** prior curl/Bruno seeding.
|
||||
|
||||
**Combat math** (from NEO-85 / NEO-82):
|
||||
|
||||
| Cast # | Expected cast feedback | Target HP label |
|
||||
|--------|------------------------|-----------------|
|
||||
| 1 | `Cast: 25 dmg → 75 HP` | `100/100` → `75/100` |
|
||||
| 2 | `→ 50 HP` | `50/100` |
|
||||
| 3 | `→ 25 HP` | `25/100` |
|
||||
| 4 | `→ 0 HP — defeated!` | `0/100 (defeated)` |
|
||||
| 5 | `ability_cast_denied: target_defeated` | unchanged defeated |
|
||||
|
||||
**Checklist outline:**
|
||||
|
||||
1. Start server + client; confirm **`Gig XP:`** row shows **`breach`** at **0** xp (boot hydrate).
|
||||
2. Walk to orange **Dummy α** near spawn; **Tab** lock **`prototype_target_alpha`** — dummy brightens; **`CombatTargetHpLabel`** shows **`100/100`**.
|
||||
3. Press **1** four times (respect ~3s pulse cooldown); verify **`CastFeedbackLabel`** + HP label step as above.
|
||||
4. Verify **`GigXpLabel`** shows **`breach`** **`xp` 25** after lethal hit (event-driven GET refresh).
|
||||
5. Press **1** again — deny feedback; gig XP unchanged.
|
||||
6. **Esc** clear lock — HP label **`— (no lock)`**; combat feedback labels remain readable.
|
||||
7. Regression spot-check: cast without lock → **`invalid_target`** deny on **`CastFeedbackLabel`**.
|
||||
|
||||
Document optional **`POST /game/__dev/combat-targets-fixture`** for mid-session dummy-only reset (NEO-85) — capstone baseline still prefers **server restart**.
|
||||
|
||||
### 2. Gig progression client (`gig_progression_client.gd`)
|
||||
|
||||
Mirror **`skill_progression_client.gd`**:
|
||||
|
||||
- **`GET {base}/game/players/{id}/gig-progression`** — injectable HTTP, **`_busy`** guard.
|
||||
- **`parse_progression_json(text)`** static — require **`schemaVersion` 1**, **`mainGigId`**, **`gigs`** array.
|
||||
- Signals: **`progression_received(snapshot: Dictionary)`**, **`progression_sync_failed(reason: String)`**.
|
||||
- **`gig_row(gig_id: String, snapshot: Dictionary = {}) -> Dictionary`** — lookup by **`id`**; uses cached snapshot when second arg omitted.
|
||||
|
||||
### 3. Gig XP HUD (`main.gd` + `main.tscn`)
|
||||
|
||||
- Add **`GigProgressionClient`** node (scene or runtime like **`CombatTargetsClient`**).
|
||||
- Add **`GigXpLabel`** under **`UICanvas/EconomyHudSection/Body/`** below **`SkillProgressionLabel`**, above **`CraftRecipePanel`** — respects NEO-75 collapse toggle.
|
||||
- **`_render_gig_xp_label()`** — format **`Gig XP:\nbreach: L1 · 25 xp`** (prototype fixed level **1** from server); **`Loading…`** / error lines match skill label pattern.
|
||||
- **Refresh triggers (event-driven):**
|
||||
- Boot: **`request_sync_from_server()`** in setup (parallel to skill progression).
|
||||
- **`cast_result_received`** when **`accepted`** and **`resolution.targetDefeated === true`** → gig progression sync (NEO-44 grant is server-side on that accept; GET reconciles HUD).
|
||||
- Do **not** refresh gig XP on every cast accept — only boot + defeat transition (minimize HTTP chatter).
|
||||
|
||||
### 4. README integration checklist
|
||||
|
||||
New **`## End-to-end combat loop (NEO-86)`** section after NEO-85 combat HUD subsection:
|
||||
|
||||
- Flow in prose: boot hotbar sync (auto pulse bind) → **Tab** lock dummy → digit cast → cast feedback + HP label refresh → defeat → gig XP refresh.
|
||||
- Cross-links: NEO-23 (targeting), NEO-28/31 (cast), NEO-85 (combat HUD), NEO-44 (server gig grant).
|
||||
- Pointer to **`docs/manual-qa/NEO-86.md`** as authoritative capstone script.
|
||||
- Note **`Economy HUD`** toggle hides gig row with other economy labels.
|
||||
|
||||
### 5. Module alignment (implementation batch or story end)
|
||||
|
||||
When acceptance criteria pass:
|
||||
|
||||
- **`documentation_and_implementation_alignment.md`**: E5.M1 — note **NEO-86 landed**, Epic 5 Slice 1 client capstone complete.
|
||||
- **`E5M1-prototype-backlog.md`**: E5M1-12 checkboxes + landed note.
|
||||
- **`E5_M1_CombatRulesEngine.md`**: E5M1-12 row if not already marked.
|
||||
|
||||
### 6. Integration fixes (conditional)
|
||||
|
||||
Run capstone QA on **`main`** wiring before gig client work; if any step fails, fix minimal **`main.gd`** / client bug in this branch and note in plan **Decisions**. Expected path: gig client + docs only.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-86-implementation-plan.md` | This plan. |
|
||||
| `docs/manual-qa/NEO-86.md` | Capstone single-session manual QA (zero Bruno/curl). |
|
||||
| `client/scripts/gig_progression_client.gd` | GET gig-progression snapshot; parse v1; **`gig_row`** helper. |
|
||||
| `client/scripts/gig_progression_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/gig_progression_client_test.gd` | GdUnit: parse snapshot; **`gig_row("breach")`**; HTTP error path. AAA layout. |
|
||||
| `client/test/gig_feedback_refresh_test.gd` | GdUnit: cast accept with **`targetDefeated`** triggers gig sync (spy harness). AAA layout. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/main.gd` | Wire **`GigProgressionClient`**; **`_render_gig_xp_label`**; defeat-triggered gig refresh in **`_on_cast_result_received`**. |
|
||||
| `client/scenes/main.tscn` | Add **`GigProgressionClient`** node + **`GigXpLabel`** under economy HUD body. |
|
||||
| `client/README.md` | End-to-end combat loop section; gig XP row docs; capstone manual QA link. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — NEO-86 landed + Slice 1 client capstone complete (on story completion). |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | E5M1-12 acceptance checkboxes + landed note (on story completion). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `client/test/gig_progression_client_test.gd` | **Add:** GET parse v1 with **`breach`** row; invalid schema → failure signal; 404 path. |
|
||||
| `client/test/gig_feedback_refresh_test.gd` | **Add:** cast accept with **`targetDefeated: true`** triggers sync; non-defeat accept and deny do not. |
|
||||
|
||||
**Manual verification:** [`docs/manual-qa/NEO-86.md`](manual-qa/NEO-86.md) — server + Godot; full defeat spine + gig XP visibility without curl.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| **Gig refresh on every accept** | Refresh only on **`targetDefeated`** + boot — avoids redundant GETs on non-lethal hits. | **adopted** |
|
||||
| **Gig label inside economy collapse** | Place under **`EconomyHudSection/Body`** — hidden when economy HUD collapsed (NEO-75); combat labels stay visible. | **adopted** |
|
||||
| **Overlap with NEO-85 manual QA** | NEO-86 supersedes as capstone script; keep NEO-85 as component-level regression reference. | **adopted** |
|
||||
| **Postgres dev DB retains gig XP** | Capstone preconditions require **server restart**; document that shared Postgres may show prior totals. | **adopted** |
|
||||
| **Integration gap on `main`** | Run capstone dry-run first; fix only if QA fails. | **adopted** — no logic gaps beyond gig client |
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Code review — NEO-86 playable tab-target combat capstone (Godot)
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-86-playable-combat-capstone` · commits `8190614`–`b97cace` vs `origin/main`
|
||||
**Follow-up:** Code review suggestions 1–4 addressed (alignment **Ready**, README link, two GdUnit cases).
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-86 closes **E5M1-12** and Epic 5 Slice 1 client acceptance: a capstone manual QA script, README integration checklist, **`GigProgressionClient`** mirroring **`SkillProgressionClient`**, **`GigXpLabel`** under the economy HUD, and defeat-triggered **`GET …/gig-progression`** refresh wired from **`_on_cast_result_received`**. No server changes; gig XP remains server-authoritative (NEO-44 grant + GET reconcile). Five new GdUnit cases pass locally. Residual risk is low — thin-client wiring atop NEO-85 combat HUD; brief lag between defeat cast feedback and gig label update until GET completes is an adopted trade-off (same pattern as combat-target refresh).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-86-implementation-plan.md`](../plans/NEO-86-implementation-plan.md) | **Matches** — kickoff decisions (Godot gig row, docs-first + gap-fill, fresh-server baseline), scope, acceptance checklist, and reconciliation align with shipped code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-12** | **Matches** — AC checked; landed note cites gig client, manual QA, README. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status **Ready**; **E5M1-12 (NEO-86)** slice added; Slice 1 client capstone complete. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note cites **NEO-86 landed** + capstone manual QA link. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M1** | **Matches** — row updated with NEO-86 landed + Slice 1 client capstone note; **Status** **Ready** (aligned with `E5_M1_CombatRulesEngine.md`). |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — gig XP from server GET only; defeat refresh triggered by wire **`targetDefeated`**, not client-side XP math. |
|
||||
| [`client/README.md`](../../client/README.md) | **Matches** — end-to-end combat loop section and test scope list; NEO-85 cross-link uses `../docs/manual-qa/NEO-85.md`. |
|
||||
| [`docs/manual-qa/NEO-86.md`](../manual-qa/NEO-86.md) | **Matches** — single-session Godot script, combat math table, economy HUD toggle step, no Bruno/curl; acceptance boxes intentionally unchecked pre-human QA. |
|
||||
| Full-stack epic decomposition | **Matches** — client capstone with Godot manual QA; not Bruno-only prototype proof. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E5.M1 alignment status vs module doc** — `E5_M1_CombatRulesEngine.md` header is **Ready** with E5M1-01–12 landed; `documentation_and_implementation_alignment.md` E5.M1 row still **In Progress**. Promote to **Ready** (or add a one-line note why server slice remains in progress) so register, module page, and alignment table stay consistent.~~ **Done.** E5.M1 **Status** column promoted to **Ready**.
|
||||
|
||||
2. ~~**Broken NEO-85 README link** — In `client/README.md` § End-to-end combat loop, `[NEO-85](NEO-85.md)` resolves relative to `client/` and 404s. Use `[NEO-85](../docs/manual-qa/NEO-85.md)` like the NEO-28/NEO-31 links in the same line.~~ **Done.**
|
||||
|
||||
3. ~~**Schema-mismatch HTTP test gap** — Plan §Tests lists invalid schema → **`progression_sync_failed`** for `gig_progression_client_test.gd`; implementation covers parse unit test + 404 only. Add a mock 200 with `schemaVersion: 2` (or missing `mainGigId`) asserting **`progression_sync_failed`** with the schema mismatch reason — parity with `combat_targets_client_test.gd` **`test_invalid_schema_emits_sync_failed`**.~~ **Done.** `test_invalid_schema_emits_progression_sync_failed`.
|
||||
|
||||
4. ~~**Deny-path refresh test gap** — Plan §Tests lists deny cast must not trigger gig sync; `gig_feedback_refresh_test.gd` covers defeat accept and non-defeat accept only. Add a third case with `accepted: false` and assert **`gig_sync_calls == 0`**.~~ **Done.** `test_denied_cast_skips_gig_progression_sync`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`GigRefreshHarness`** duplicates defeat-gated refresh logic from `main.gd` rather than testing `main.gd` directly — matches **`combat_feedback_refresh_test.gd`** / **`craft_feedback_refresh_test.gd`** precedent; acceptable for prototype scope.
|
||||
|
||||
- Nit: Plan describes **`gig_row`** using a cached snapshot when the second arg is omitted; client has no internal cache — `main.gd` passes **`_last_gig_snapshot`** explicitly. Behavior is correct; plan wording could note cache lives in `main.gd`.
|
||||
|
||||
- Nit: Between defeat cast accept and gig GET completion, **`CastFeedbackLabel`** shows authoritative defeat copy while **`GigXpLabel`** may still show pre-defeat XP until GET lands — same event-driven lag as combat HP label; optional future polish could show interim copy from cast resolution if UX matters.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/client
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/gig_progression_client_test.gd \
|
||||
-a res://test/gig_feedback_refresh_test.gd
|
||||
```
|
||||
|
||||
**GdUnit (local run):** 7/7 passed (4 + 3 suites).
|
||||
|
||||
**Manual:** [`docs/manual-qa/NEO-86.md`](../manual-qa/NEO-86.md) — fresh server restart, Godot **F5**, Tab lock **`prototype_target_alpha`**, four **`prototype_pulse`** casts, verify **`GigXpLabel`** **`breach: L1 · 25 xp`**, deny on defeated target, economy HUD collapse step 9.
|
||||
Loading…
Reference in New Issue