Compare commits
46 Commits
8e04cfc67b
...
dda0dadf56
| Author | SHA1 | Date |
|---|---|---|
|
|
dda0dadf56 | |
|
|
bfb0eb9306 | |
|
|
c3df7fc8d0 | |
|
|
b060b9dffd | |
|
|
e729996f47 | |
|
|
61bed05298 | |
|
|
94b6c67b07 | |
|
|
8397e74ec1 | |
|
|
44e0056b37 | |
|
|
374b4b5a53 | |
|
|
83d1dde4ea | |
|
|
603cc525ad | |
|
|
0e5bbbff2e | |
|
|
73d7708c7c | |
|
|
b9644a0453 | |
|
|
bd4912004a | |
|
|
8bafec9d79 | |
|
|
91fab3e398 | |
|
|
fd2ffee089 | |
|
|
42ea2598c3 | |
|
|
7c2697e4cc | |
|
|
619089ca40 | |
|
|
07444e45ce | |
|
|
37b14f9fe3 | |
|
|
866f962583 | |
|
|
6a71e9b6aa | |
|
|
9b11a521d8 | |
|
|
bafaacb963 | |
|
|
510d71113b | |
|
|
7d8bbc722f | |
|
|
a38ab660b4 | |
|
|
7695527a79 | |
|
|
cf0ef2d23e | |
|
|
977bcc3b27 | |
|
|
025d6e529d | |
|
|
fbae3148d2 | |
|
|
6867ab4e55 | |
|
|
a8cedcbd64 | |
|
|
d6d95ec438 | |
|
|
f939132f38 | |
|
|
501834279e | |
|
|
1533687aa8 | |
|
|
2fec42f8f6 | |
|
|
9b6abf093b | |
|
|
c16b610468 | |
|
|
dff58adfe8 |
|
|
@ -0,0 +1,51 @@
|
|||
meta {
|
||||
name: POST cast unknown ability deny
|
||||
type: http
|
||||
seq: 9
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-79: unknown ability ids deny with reasonCode unknown_ability (catalog-backed IAbilityDefinitionRegistry).
|
||||
Pre-request binds slot 0 so cast validation reaches ability allowlist (not slot_unbound).
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId") || "dev-local-1";
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slotIndex": 0,
|
||||
"abilityId": "not_a_real_ability",
|
||||
"targetId": null
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("accepted false with unknown_ability", function () {
|
||||
const body = res.getBody();
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("unknown_ability");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (ability catalog boot NEO-77)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-77 loads content/abilities/*_abilities.json at startup (fail-fast). No ability HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: ability-catalog
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
meta {
|
||||
name: GET ability definitions
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/world/ability-definitions
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1 and abilities array", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
expect(res.getHeader("content-type")).to.contain("application/json");
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.abilities).to.be.an("array");
|
||||
expect(body.abilities.length).to.equal(4);
|
||||
});
|
||||
|
||||
test("abilities are ascending by id (ordinal)", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.abilities.map((x) => x.id);
|
||||
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
expect(ids).to.eql(sorted);
|
||||
});
|
||||
|
||||
test("frozen prototype four matches registry id order", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.abilities.map((x) => x.id);
|
||||
expect(ids).to.eql([
|
||||
"prototype_burst",
|
||||
"prototype_dash",
|
||||
"prototype_guard",
|
||||
"prototype_pulse",
|
||||
]);
|
||||
});
|
||||
|
||||
test("prototype_pulse row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.abilities.find((x) => x.id === "prototype_pulse");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Prototype Pulse");
|
||||
expect(row.baseDamage).to.equal(25);
|
||||
expect(row.cooldownSeconds).to.equal(3);
|
||||
expect(row.abilityKind).to.equal("attack");
|
||||
});
|
||||
|
||||
test("prototype_burst row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.abilities.find((x) => x.id === "prototype_burst");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Prototype Burst");
|
||||
expect(row.baseDamage).to.equal(40);
|
||||
expect(row.cooldownSeconds).to.equal(5);
|
||||
expect(row.abilityKind).to.equal("attack");
|
||||
});
|
||||
|
||||
test("prototype_guard row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.abilities.find((x) => x.id === "prototype_guard");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.abilityKind).to.equal("utility");
|
||||
expect(row.baseDamage).to.equal(0);
|
||||
expect(row.cooldownSeconds).to.equal(6);
|
||||
});
|
||||
|
||||
test("prototype_dash row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.abilities.find((x) => x.id === "prototype_dash");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.abilityKind).to.equal("movement");
|
||||
expect(row.baseDamage).to.equal(0);
|
||||
expect(row.cooldownSeconds).to.equal(4);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: ability-definitions
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
meta {
|
||||
name: POST hotbar loadout unknown ability deny
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-79: unknown ability ids deny with reasonCode unknown_ability (catalog-backed IAbilityDefinitionRegistry).
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slots": [
|
||||
{
|
||||
"slotIndex": 2,
|
||||
"abilityId": "missing_ability"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("updated false with unknown_ability", function () {
|
||||
const body = res.getBody();
|
||||
expect(body.updated).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("unknown_ability");
|
||||
});
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cas
|
|||
- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http).
|
||||
- **`GET /game/world/item-definitions`** — cached on boot for **`displayName`** labels (NEO-53).
|
||||
- **Scripts:** `scripts/inventory_client.gd`, `scripts/item_definitions_client.gd`; wired from `main.gd` on boot and on **`inventory_refresh`** (**I**).
|
||||
- **HUD:** `UICanvas/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output.
|
||||
- **HUD:** `UICanvas/EconomyHudSection/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block.
|
||||
- **Refresh:** press **I** (or call **`InventoryClient.request_sync_from_server()`** from `main.gd` helpers — entrypoint for NEO-73 gather/craft refresh).
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
|
||||
|
|
@ -147,13 +147,42 @@ Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
|
|||
## Gather feedback on interact (NEO-73)
|
||||
|
||||
- **`POST /game/players/{id}/interact`** on **`resource_node`** kinds — server gather engine (NEO-63); no separate gather HTTP on client.
|
||||
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
|
||||
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
|
||||
- **Scripts:** `scripts/skill_progression_client.gd`, `scripts/prototype_interactable_picker.gd`; **`interaction_request_client.gd`** emits **`interaction_result_received`**; wired from `main.gd`.
|
||||
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`InventoryLabel`** refreshes automatically on successful gather (no **I**).
|
||||
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**).
|
||||
- **R binding:** nearest in-range **`resource_node`** by horizontal distance to catalog anchors (same radius rule as glow preview).
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
|
||||
|
||||
## Craft UI + recipe list (NEO-74)
|
||||
|
||||
- **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel inside **`EconomyHudSection`** (below **`SkillProgressionLabel`**).
|
||||
- **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md).
|
||||
- **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`.
|
||||
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes on craft success only (no **I**).
|
||||
- **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.md).
|
||||
|
||||
## End-to-end economy loop (NEO-75)
|
||||
|
||||
Capstone proof that Epic 3 prototype gather→craft works **in Godot** without Bruno.
|
||||
|
||||
**Flow (single session):**
|
||||
|
||||
1. **Boot** — server running; Godot **F5**. Clients hydrate item defs, inventory, skill progression, recipe defs (NEO-72–74).
|
||||
2. **Gather** — **WASD** to each resource anchor; **R** posts nearest in-range **`resource_node`** interact (NEO-63). On success: **`GatherFeedbackLabel`** delta, automatic inventory GET, **salvage** row refresh (NEO-73).
|
||||
3. **Refine** — scroll **`EconomyHudSection`** recipe panel; **Craft** **`refine_scrap_standard`** when **≥5** scrap (NEO-74). On success: **`CraftFeedbackLabel`**, inventory GET, **refine** row refresh.
|
||||
4. **Make** — **Craft** **`make_field_stim_mk0`** when **≥2** **`refined_plate_stock`** and **≥1** scrap. Verify **`field_stim_mk0`** in bag.
|
||||
|
||||
**Inputs:** **R** gather · **Craft** buttons (qty **1**) · **I** manual inventory refresh (optional) · **`Economy HUD`** checkbox collapses inventory/skills/recipe block (NEO-75).
|
||||
|
||||
**Material minimum:** **11** scrap from one **R** at each of four prototype nodes (+1, +2, +3, +5) → two **`refine_scrap_standard`** → one **`make_field_stim_mk0`**.
|
||||
|
||||
**HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`).
|
||||
|
||||
Full capstone checklist: [`docs/manual-qa/NEO-75.md`](../docs/manual-qa/NEO-75.md).
|
||||
|
||||
### Manual check (NEO-24)
|
||||
|
||||
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
|
||||
|
|
@ -213,7 +242,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), **`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`**, **`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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
[ext_resource type="Script" path="res://scripts/item_definitions_client.gd" id="14_item_defs"]
|
||||
[ext_resource type="Script" path="res://scripts/inventory_client.gd" id="15_inventory"]
|
||||
[ext_resource type="Script" path="res://scripts/skill_progression_client.gd" id="16_skill_prog"]
|
||||
[ext_resource type="Script" path="res://scripts/recipe_definitions_client.gd" id="17_recipe_defs"]
|
||||
[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"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||
geometry_collision_mask = 1
|
||||
|
|
@ -1101,6 +1105,12 @@ script = ExtResource("15_inventory")
|
|||
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
|
||||
script = ExtResource("16_skill_prog")
|
||||
|
||||
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
[node name="CraftClient" type="Node" parent="." unique_id=2500010]
|
||||
script = ExtResource("18_craft")
|
||||
|
||||
[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
|
||||
|
||||
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
|
||||
|
|
@ -1174,11 +1184,25 @@ theme_override_font_sizes/font_size = 14
|
|||
autowrap_mode = 3
|
||||
text = "Gather: —"
|
||||
|
||||
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010]
|
||||
offset_left = 8.0
|
||||
offset_top = 404.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 430.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 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 = "Craft: —"
|
||||
|
||||
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
|
||||
offset_left = 8.0
|
||||
offset_top = 408.0
|
||||
offset_top = 436.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 484.0
|
||||
offset_bottom = 512.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
|
||||
|
|
@ -1188,13 +1212,29 @@ theme_override_font_sizes/font_size = 14
|
|||
autowrap_mode = 3
|
||||
text = "Cooldowns: —"
|
||||
|
||||
[node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007]
|
||||
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
|
||||
offset_left = 8.0
|
||||
offset_top = 496.0
|
||||
offset_top = 524.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 644.0
|
||||
offset_bottom = 1056.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
script = ExtResource("20_economy_hud")
|
||||
|
||||
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000015]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ToggleButton" type="CheckButton" parent="UICanvas/EconomyHudSection/HeaderRow" unique_id=9000016]
|
||||
layout_mode = 2
|
||||
text = "Economy HUD"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000017]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="InventoryLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000007]
|
||||
custom_minimum_size = Vector2(512, 148)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
|
|
@ -1203,13 +1243,10 @@ autowrap_mode = 3
|
|||
text = "Inventory: (refresh: I)
|
||||
Loading…"
|
||||
|
||||
[node name="SkillProgressionLabel" type="Label" parent="UICanvas" unique_id=9000009]
|
||||
offset_left = 8.0
|
||||
offset_top = 656.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 720.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000009]
|
||||
custom_minimum_size = Vector2(512, 84)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.82, 0.9, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
|
|
@ -1217,3 +1254,18 @@ theme_override_font_sizes/font_size = 14
|
|||
autowrap_mode = 3
|
||||
text = "Skills:
|
||||
Loading…"
|
||||
|
||||
[node name="CraftRecipePanel" type="Control" parent="UICanvas/EconomyHudSection/Body" unique_id=9000011]
|
||||
custom_minimum_size = Vector2(512, 280)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("19_craft_panel")
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/EconomyHudSection/Body/CraftRecipePanel" unique_id=9000012]
|
||||
layout_mode = 0
|
||||
offset_right = 512.0
|
||||
offset_bottom = 280.0
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/EconomyHudSection/Body/CraftRecipePanel/ScrollContainer" unique_id=9000013]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
extends Node
|
||||
|
||||
## NEO-74: prototype HTTP client for [code]POST /game/players/{id}/craft[/code] (NEO-70).
|
||||
|
||||
signal craft_result_received(recipe_id: String, result: Dictionary)
|
||||
signal craft_request_failed(recipe_id: String, 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
|
||||
var _current_recipe_id: String = ""
|
||||
|
||||
|
||||
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 is_busy() -> bool:
|
||||
return _busy
|
||||
|
||||
|
||||
## Returns [code]true[/code] when the HTTP POST was queued.
|
||||
func request_craft(recipe_id: String, quantity: int = 1) -> bool:
|
||||
if _busy:
|
||||
return false
|
||||
var rid := recipe_id.strip_edges()
|
||||
if rid.is_empty():
|
||||
return false
|
||||
_current_recipe_id = rid
|
||||
_busy = true
|
||||
var payload: Dictionary = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"recipeId": rid,
|
||||
"quantity": quantity,
|
||||
}
|
||||
var url := "%s/game/players/%s/craft" % [_base_root(), _player_path_segment()]
|
||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
_busy = false
|
||||
_current_recipe_id = ""
|
||||
push_warning("CraftClient: POST failed to start (%s)" % err)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_craft_response_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
|
||||
if not root.has("success"):
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
var recipe_echo: String = _current_recipe_id
|
||||
_current_recipe_id = ""
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("CraftClient: %s" % reason)
|
||||
craft_request_failed.emit(recipe_echo, reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("CraftClient: %s" % reason_code)
|
||||
craft_request_failed.emit(recipe_echo, reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = parse_craft_response_json(text)
|
||||
if parsed == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("CraftClient: %s" % reason_json)
|
||||
craft_request_failed.emit(recipe_echo, reason_json)
|
||||
return
|
||||
var data: Dictionary = parsed as Dictionary
|
||||
var success: bool = bool(data.get("success", false))
|
||||
if not success:
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason_str: String = reason_variant as String if reason_variant is String else ""
|
||||
push_warning("craft_denied reasonCode=%s" % reason_str)
|
||||
craft_result_received.emit(recipe_echo, data.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftclient01
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
extends Control
|
||||
|
||||
## NEO-74: scrollable prototype recipe list with per-row Craft buttons.
|
||||
|
||||
signal craft_requested(recipe_id: String)
|
||||
|
||||
var _item_defs_client: Node = null
|
||||
var _craft_buttons: Array[Button] = []
|
||||
|
||||
@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer
|
||||
|
||||
|
||||
func setup(item_defs_client: Node) -> void:
|
||||
_item_defs_client = item_defs_client
|
||||
|
||||
|
||||
func populate(recipes: Array) -> void:
|
||||
if not is_instance_valid(_vbox):
|
||||
return
|
||||
for child in _vbox.get_children():
|
||||
child.queue_free()
|
||||
_craft_buttons.clear()
|
||||
for row_variant in recipes:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var recipe: Dictionary = row_variant
|
||||
var recipe_id: String = str(recipe.get("id", ""))
|
||||
if recipe_id.is_empty():
|
||||
continue
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var info := Label.new()
|
||||
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
info.text = _format_recipe_line(recipe)
|
||||
row.add_child(info)
|
||||
var craft_btn := Button.new()
|
||||
craft_btn.text = "Craft"
|
||||
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
|
||||
row.add_child(craft_btn)
|
||||
_vbox.add_child(row)
|
||||
_craft_buttons.append(craft_btn)
|
||||
|
||||
|
||||
func set_craft_busy(busy: bool) -> void:
|
||||
for btn in _craft_buttons:
|
||||
if is_instance_valid(btn):
|
||||
btn.disabled = busy
|
||||
|
||||
|
||||
func _format_recipe_line(recipe: Dictionary) -> String:
|
||||
var recipe_id: String = str(recipe.get("id", ""))
|
||||
var display_name: String = str(recipe.get("displayName", recipe_id))
|
||||
var inputs_line: String = _format_inputs(recipe.get("inputs", []))
|
||||
return "%s (%s)\nneeds: %s" % [display_name, recipe_id, inputs_line]
|
||||
|
||||
|
||||
func _format_inputs(raw_inputs: Variant) -> String:
|
||||
if raw_inputs == null or not raw_inputs is Array:
|
||||
return "—"
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for row_variant in raw_inputs as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
var item_id: String = str(row.get("itemId", ""))
|
||||
var qty: int = int(row.get("quantity", 0))
|
||||
if item_id.is_empty() or qty <= 0:
|
||||
continue
|
||||
parts.append("%d× %s" % [qty, _item_display_name(item_id)])
|
||||
if parts.is_empty():
|
||||
return "—"
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
func _item_display_name(item_id: String) -> String:
|
||||
if is_instance_valid(_item_defs_client) and _item_defs_client.has_method("display_name_for"):
|
||||
return str(_item_defs_client.call("display_name_for", item_id))
|
||||
return item_id
|
||||
|
||||
|
||||
func _on_craft_pressed(recipe_id: String) -> void:
|
||||
craft_requested.emit(recipe_id)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftpanel01
|
||||
|
|
@ -45,6 +45,7 @@ const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
|
|||
const COOLDOWN_REASON_ON_CD := "on_cooldown"
|
||||
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
|
||||
const SALVAGE_SKILL_ID := "salvage"
|
||||
const REFINE_SKILL_ID := "refine"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -85,6 +86,8 @@ var _progression_error: String = ""
|
|||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
var _cached_recipe_rows: Array = []
|
||||
var _recipe_defs_error: String = ""
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -99,12 +102,18 @@ var _gather_awaiting_inventory_finalize: bool = false
|
|||
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
|
||||
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
|
||||
@onready var _inventory_label: Label = $UICanvas/InventoryLabel
|
||||
@onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
|
||||
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel")
|
||||
@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel
|
||||
@onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel
|
||||
@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel
|
||||
@onready
|
||||
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
|
||||
@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 _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
|
@ -149,6 +158,7 @@ func _ready() -> void:
|
|||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_setup_craft_ui()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
|
|
@ -358,6 +368,7 @@ 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():
|
||||
|
|
@ -504,12 +515,18 @@ func _render_skill_progression_label() -> void:
|
|||
_skill_progression_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID)
|
||||
if salvage_row.is_empty():
|
||||
_skill_progression_label.text = "%s\nsalvage: — (missing row)" % header
|
||||
var refine_row: Dictionary = _skill_row(REFINE_SKILL_ID)
|
||||
if salvage_row.is_empty() or refine_row.is_empty():
|
||||
_skill_progression_label.text = "%s\nsalvage/refine: — (missing row)" % header
|
||||
return
|
||||
var level: int = int(salvage_row.get("level", 0))
|
||||
var xp: int = int(salvage_row.get("xp", 0))
|
||||
_skill_progression_label.text = "%s\nsalvage: level %d, xp %d" % [header, level, xp]
|
||||
var salvage_level: int = int(salvage_row.get("level", 0))
|
||||
var salvage_xp: int = int(salvage_row.get("xp", 0))
|
||||
var refine_level: int = int(refine_row.get("level", 0))
|
||||
var refine_xp: int = int(refine_row.get("xp", 0))
|
||||
_skill_progression_label.text = (
|
||||
"%s\nsalvage: level %d, xp %d\nrefine: level %d, xp %d"
|
||||
% [header, salvage_level, salvage_xp, refine_level, refine_xp]
|
||||
)
|
||||
|
||||
|
||||
func _skill_row(skill_id: String) -> Dictionary:
|
||||
|
|
@ -528,6 +545,111 @@ func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
|||
_gather_feedback_label.text = text
|
||||
|
||||
|
||||
func _setup_craft_ui() -> void:
|
||||
# NEO-74: recipe defs + craft POST + scrollable recipe panel.
|
||||
_apply_authority_http_config_to_client(_recipe_defs_client)
|
||||
_apply_authority_http_config_to_client(_craft_client)
|
||||
if _craft_recipe_panel.has_method("setup"):
|
||||
_craft_recipe_panel.call("setup", _item_defs_client)
|
||||
if _recipe_defs_client.has_signal("recipes_ready"):
|
||||
_recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready"))
|
||||
if _recipe_defs_client.has_signal("recipes_sync_failed"):
|
||||
_recipe_defs_client.connect(
|
||||
"recipes_sync_failed", Callable(self, "_on_recipes_sync_failed")
|
||||
)
|
||||
if _craft_recipe_panel.has_signal("craft_requested"):
|
||||
_craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested"))
|
||||
if _craft_client.has_signal("craft_result_received"):
|
||||
_craft_client.connect("craft_result_received", Callable(self, "_on_craft_result_received"))
|
||||
if _craft_client.has_signal("craft_request_failed"):
|
||||
_craft_client.connect("craft_request_failed", Callable(self, "_on_craft_request_failed"))
|
||||
_render_craft_feedback_label()
|
||||
if _recipe_defs_client.has_method("request_sync_from_server"):
|
||||
_recipe_defs_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _on_recipes_ready(recipes: Array) -> void:
|
||||
if not _recipe_defs_error.is_empty():
|
||||
_render_craft_feedback_label()
|
||||
_recipe_defs_error = ""
|
||||
_cached_recipe_rows = recipes.duplicate(true)
|
||||
_populate_craft_recipe_panel_if_ready()
|
||||
|
||||
|
||||
func _on_recipes_sync_failed(reason: String) -> void:
|
||||
_recipe_defs_error = reason
|
||||
_cached_recipe_rows = []
|
||||
_render_craft_feedback_label("Recipes: error — %s" % reason)
|
||||
|
||||
|
||||
func _populate_craft_recipe_panel_if_ready() -> void:
|
||||
if _cached_recipe_rows.is_empty():
|
||||
return
|
||||
if is_instance_valid(_craft_recipe_panel) and _craft_recipe_panel.has_method("populate"):
|
||||
_craft_recipe_panel.call("populate", _cached_recipe_rows)
|
||||
|
||||
|
||||
func _on_craft_requested(recipe_id: String) -> void:
|
||||
if not is_instance_valid(_craft_client):
|
||||
return
|
||||
if _craft_client.has_method("is_busy") and bool(_craft_client.call("is_busy")):
|
||||
return
|
||||
if _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", true)
|
||||
if _craft_client.has_method("request_craft"):
|
||||
var started: bool = bool(_craft_client.call("request_craft", recipe_id, 1))
|
||||
if not started and _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", false)
|
||||
|
||||
|
||||
func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void:
|
||||
if _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", false)
|
||||
if bool(result.get("success", false)):
|
||||
_render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", [])))
|
||||
_request_inventory_refresh()
|
||||
if (
|
||||
is_instance_valid(_skill_progression_client)
|
||||
and _skill_progression_client.has_method("request_sync_from_server")
|
||||
):
|
||||
_skill_progression_client.call("request_sync_from_server")
|
||||
return
|
||||
var rc := str(result.get("reasonCode", "")).strip_edges()
|
||||
if rc.is_empty():
|
||||
_render_craft_feedback_label("Craft: denied (no reasonCode)")
|
||||
else:
|
||||
_render_craft_feedback_label("Craft: denied — %s" % rc)
|
||||
|
||||
|
||||
func _on_craft_request_failed(_recipe_id: String, reason: String) -> void:
|
||||
if _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", false)
|
||||
_render_craft_feedback_label("Craft: failed — %s" % reason)
|
||||
|
||||
|
||||
func _format_craft_success_line(raw_outputs: Variant) -> String:
|
||||
if raw_outputs == null or not raw_outputs is Array:
|
||||
return "Craft: ok (no outputsGranted)"
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for row_variant in raw_outputs as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
var item_id: String = str(row.get("itemId", ""))
|
||||
var qty: int = int(row.get("quantity", 0))
|
||||
if item_id.is_empty() or qty <= 0:
|
||||
continue
|
||||
parts.append("+%d %s" % [qty, _inventory_item_label(item_id)])
|
||||
if parts.is_empty():
|
||||
return "Craft: ok (empty outputsGranted)"
|
||||
return "Craft: %s" % ", ".join(parts)
|
||||
|
||||
|
||||
func _render_craft_feedback_label(text: String = "Craft: —") -> void:
|
||||
if is_instance_valid(_craft_feedback_label):
|
||||
_craft_feedback_label.text = text
|
||||
|
||||
|
||||
func _clear_gather_session() -> void:
|
||||
_gather_pending_interactable_id = ""
|
||||
_gather_awaiting_inventory_finalize = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
extends VBoxContainer
|
||||
|
||||
## NEO-75: collapsible economy HUD block (inventory, skills, craft panel).
|
||||
|
||||
@onready var _toggle: CheckButton = $HeaderRow/ToggleButton
|
||||
@onready var _body: VBoxContainer = $Body
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_toggle.button_pressed = true
|
||||
_body.visible = true
|
||||
_toggle.toggled.connect(_on_toggle_toggled)
|
||||
|
||||
|
||||
func _on_toggle_toggled(pressed: bool) -> void:
|
||||
_body.visible = pressed
|
||||
|
||||
|
||||
func set_expanded(expanded: bool) -> void:
|
||||
_toggle.button_pressed = expanded
|
||||
_body.visible = expanded
|
||||
|
||||
|
||||
func is_body_visible() -> bool:
|
||||
return _body.visible
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo75economyhud1
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
extends Node
|
||||
|
||||
## NEO-74: fetches [code]GET /game/world/recipe-definitions[/code] (NEO-68).
|
||||
## Caches ordered recipe rows for craft UI.
|
||||
|
||||
signal recipes_ready(recipes: Array)
|
||||
signal recipes_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _recipes: Array = []
|
||||
|
||||
|
||||
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/world/recipe-definitions" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason)
|
||||
_busy = false
|
||||
recipes_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
static func parse_recipes_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 recipes: Variant = root.get("recipes", null)
|
||||
if recipes == null or not recipes is Array:
|
||||
return null
|
||||
return recipes as Array
|
||||
|
||||
|
||||
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("RecipeDefinitionsClient: %s" % reason)
|
||||
recipes_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason_code)
|
||||
recipes_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var recipes: Variant = parse_recipes_json(text)
|
||||
if recipes == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason_json)
|
||||
recipes_sync_failed.emit(reason_json)
|
||||
return
|
||||
_recipes = (recipes as Array).duplicate(true)
|
||||
recipes_ready.emit(_recipes.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74recipedef01
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-74: `CraftClient` POST payload + success/deny parse (craft panel wiring tested separately).
|
||||
|
||||
const CraftClient := preload("res://scripts/craft_client.gd")
|
||||
|
||||
var _failed_reason: String = ""
|
||||
var _failed_recipe_id: String = ""
|
||||
|
||||
|
||||
func _capture_failed(recipe_id: String, reason: String) -> void:
|
||||
_failed_recipe_id = recipe_id
|
||||
_failed_reason = reason
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var last_body: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
last_body = request_data
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _success_craft_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"success":true,"reasonCode":null,'
|
||||
+ '"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],'
|
||||
+ '"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],'
|
||||
+ '"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"craft_refine"}}'
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = CraftClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_request_craft_posts_recipe_id_and_schema_version() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _success_craft_json()
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(transport.last_url).contains("/craft")
|
||||
var parsed: Variant = JSON.parse_string(transport.last_body)
|
||||
assert_that(parsed is Dictionary).is_true()
|
||||
var body: Dictionary = parsed as Dictionary
|
||||
assert_that(int(body.get("schemaVersion", -1))).is_equal(1)
|
||||
assert_that(str(body.get("recipeId", ""))).is_equal("refine_scrap_standard")
|
||||
assert_that(int(body.get("quantity", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_success_response_emits_craft_result_received() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _success_craft_json()
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect(
|
||||
"craft_result_received",
|
||||
func(recipe_id: String, result: Dictionary) -> void: got.append([recipe_id, result])
|
||||
)
|
||||
# Act
|
||||
c.call("request_craft", "refine_scrap_standard")
|
||||
# Assert
|
||||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(str(got[0][0])).is_equal("refine_scrap_standard")
|
||||
var result: Dictionary = got[0][1]
|
||||
assert_that(bool(result.get("success", false))).is_true()
|
||||
var outputs: Variant = result.get("outputsGranted", null)
|
||||
assert_that(outputs is Array).is_true()
|
||||
assert_that((outputs as Array).size()).is_equal(1)
|
||||
|
||||
|
||||
func test_deny_response_emits_craft_result_with_reason_code() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",'
|
||||
+ '"inputsConsumed":[],"outputsGranted":[]}'
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect(
|
||||
"craft_result_received",
|
||||
func(_recipe_id: String, result: Dictionary) -> void: got.append(result)
|
||||
)
|
||||
# Act
|
||||
c.call("request_craft", "refine_scrap_standard")
|
||||
# Assert
|
||||
var result: Dictionary = got[0]
|
||||
assert_that(bool(result.get("success", true))).is_false()
|
||||
assert_that(str(result.get("reasonCode", ""))).is_equal("insufficient_materials")
|
||||
|
||||
|
||||
func test_http_error_emits_craft_request_failed() -> void:
|
||||
# Arrange
|
||||
_failed_reason = ""
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
c.connect("craft_request_failed", Callable(self, "_capture_failed"))
|
||||
# Act
|
||||
c.call("request_craft", "refine_scrap_standard")
|
||||
# Assert
|
||||
assert_that(_failed_reason).contains("404")
|
||||
|
||||
|
||||
func test_request_craft_clears_busy_after_sync_mock_response() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _success_craft_json()
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(bool(c.call("is_busy"))).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftcltest
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-74: successful craft triggers inventory + skill refresh; deny skips refresh.
|
||||
|
||||
const CraftClient := preload("res://scripts/craft_client.gd")
|
||||
|
||||
|
||||
class MockCraftTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var body_json: String = '{"schemaVersion":1,"success":true,"outputsGranted":[]}'
|
||||
|
||||
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 SpySyncClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class CraftRefreshHarness:
|
||||
extends Node
|
||||
var inventory_sync_calls: int = 0
|
||||
var skill_sync_calls: int = 0
|
||||
var _inventory: SpySyncClient
|
||||
var _skill: SpySyncClient
|
||||
var _craft: Node
|
||||
|
||||
func setup(parent: Node, transport: Node) -> void:
|
||||
_inventory = SpySyncClient.new()
|
||||
_skill = SpySyncClient.new()
|
||||
_craft = CraftClient.new()
|
||||
_craft.set("injected_http", transport)
|
||||
parent.add_child(_craft)
|
||||
parent.add_child(_inventory)
|
||||
parent.add_child(_skill)
|
||||
_craft.connect("craft_result_received", Callable(self, "_on_craft_result"))
|
||||
|
||||
func _on_craft_result(_recipe_id: String, result: Dictionary) -> void:
|
||||
if not bool(result.get("success", false)):
|
||||
return
|
||||
_inventory.request_sync_from_server()
|
||||
_skill.request_sync_from_server()
|
||||
|
||||
func post_craft(recipe_id: String) -> void:
|
||||
_craft.call("request_craft", recipe_id)
|
||||
|
||||
|
||||
func test_successful_craft_triggers_both_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockCraftTransport.new()
|
||||
var harness := CraftRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._craft)
|
||||
# Act
|
||||
harness.post_craft("refine_scrap_standard")
|
||||
# Assert
|
||||
assert_signal(harness._craft).is_emitted("craft_result_received")
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_denied_craft_skips_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockCraftTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",'
|
||||
+ '"inputsConsumed":[],"outputsGranted":[]}'
|
||||
)
|
||||
var harness := CraftRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.post_craft("refine_scrap_standard")
|
||||
# Assert
|
||||
assert_that(harness._inventory.sync_calls).is_equal(0)
|
||||
assert_that(harness._skill.sync_calls).is_equal(0)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftfbtest
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-75: economy HUD collapse toggle shows/hides body container.
|
||||
## Covers economy HUD node paths via EconomyHudSection.get_node in main.gd.
|
||||
|
||||
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
|
||||
|
||||
|
||||
func _build_section() -> Node:
|
||||
var section: VBoxContainer = EconomyHudSection.new()
|
||||
var header := HBoxContainer.new()
|
||||
header.name = "HeaderRow"
|
||||
var toggle := CheckButton.new()
|
||||
toggle.name = "ToggleButton"
|
||||
toggle.text = "Economy HUD"
|
||||
header.add_child(toggle)
|
||||
var body := VBoxContainer.new()
|
||||
body.name = "Body"
|
||||
var inventory := Label.new()
|
||||
inventory.name = "InventoryLabel"
|
||||
body.add_child(inventory)
|
||||
section.add_child(header)
|
||||
section.add_child(body)
|
||||
return section
|
||||
|
||||
|
||||
func test_body_visible_when_expanded_by_default() -> void:
|
||||
# Arrange
|
||||
var section := _build_section()
|
||||
add_child(section)
|
||||
await await_idle_frame()
|
||||
# Act
|
||||
var visible: bool = section.call("is_body_visible")
|
||||
# Assert
|
||||
assert_bool(visible).is_true()
|
||||
|
||||
|
||||
func test_toggle_collapses_body() -> void:
|
||||
# Arrange
|
||||
var section := _build_section()
|
||||
add_child(section)
|
||||
await await_idle_frame()
|
||||
# Act
|
||||
section.get_node("HeaderRow/ToggleButton").button_pressed = false
|
||||
await await_idle_frame()
|
||||
# Assert
|
||||
assert_bool(section.call("is_body_visible")).is_false()
|
||||
|
||||
|
||||
func test_toggle_expands_body_again() -> void:
|
||||
# Arrange
|
||||
var section := _build_section()
|
||||
add_child(section)
|
||||
await await_idle_frame()
|
||||
section.call("set_expanded", false)
|
||||
await await_idle_frame()
|
||||
# Act
|
||||
section.call("set_expanded", true)
|
||||
await await_idle_frame()
|
||||
# Assert
|
||||
assert_bool(section.call("is_body_visible")).is_true()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo75economyhudtest1
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-74: `RecipeDefinitionsClient` GET parse + `recipes_ready` signal.
|
||||
## `main.gd` repopulates craft panel on item `definitions_ready` even when inventory GET failed.
|
||||
|
||||
const RecipeDefinitionsClient := preload("res://scripts/recipe_definitions_client.gd")
|
||||
|
||||
var _recipes_capture: Array = []
|
||||
|
||||
|
||||
func _capture_recipes(recipes: Array) -> void:
|
||||
_recipes_capture = recipes
|
||||
|
||||
|
||||
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 _two_recipes_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"recipes":['
|
||||
+ '{"id":"make_armor_quick","displayName":"Quick Armor","recipeKind":"make",'
|
||||
+ '"requiredSkillId":"refine","inputs":[],"outputs":[]},'
|
||||
+ '{"id":"refine_scrap_standard","displayName":"Refine Scrap (Standard)",'
|
||||
+ '"recipeKind":"process","requiredSkillId":"refine",'
|
||||
+ '"inputs":[{"itemId":"scrap_metal_bulk","quantity":5}],'
|
||||
+ '"outputs":[{"itemId":"refined_plate_stock","quantity":1}]}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = RecipeDefinitionsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_recipes_json_reads_array() -> void:
|
||||
# Arrange
|
||||
var json := _two_recipes_json()
|
||||
# Act
|
||||
var recipes: Variant = RecipeDefinitionsClient.parse_recipes_json(json)
|
||||
# Assert
|
||||
assert_that(recipes is Array).is_true()
|
||||
assert_that((recipes as Array).size()).is_equal(2)
|
||||
|
||||
|
||||
func test_request_sync_emits_recipes_ready_in_order() -> void:
|
||||
# Arrange
|
||||
_recipes_capture = []
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _two_recipes_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("recipes_ready", Callable(self, "_capture_recipes"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("recipes_ready")
|
||||
assert_that(_recipes_capture.size()).is_equal(2)
|
||||
assert_that(str(_recipes_capture[0].get("id", ""))).is_equal("make_armor_quick")
|
||||
assert_that(transport.last_url).contains("/recipe-definitions")
|
||||
|
||||
|
||||
func test_schema_mismatch_emits_recipes_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":99,"recipes":[]}'
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("recipes_sync_failed")
|
||||
|
||||
|
||||
func test_http_error_emits_recipes_sync_failed_with_status() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 503
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect("recipes_sync_failed", func(reason: String) -> void: got.append(reason))
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(str(got[0])).contains("503")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74recipedefst
|
||||
|
|
@ -9,6 +9,7 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
| [`mastery/`](mastery/) | Mastery catalogs; each file matches [`schemas/mastery-catalog.schema.json`](schemas/mastery-catalog.schema.json) — **stable `branchId` / `perkId`**, tier gates via skill level ([E2.M3](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md)) |
|
||||
| [`items/`](items/) | Item catalogs; each row matches [`schemas/item-def.schema.json`](schemas/item-def.schema.json) — **stable `id`**, **`stackMax`**, **`inventorySlotKind`** for [E3.M3](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) |
|
||||
| [`recipes/`](recipes/) | Recipe catalogs; each row matches [`schemas/recipe-def.schema.json`](schemas/recipe-def.schema.json) — **stable `id`**, **`recipeKind`**, **`requiredSkillId`**, inputs/outputs for [E3.M2](../docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) |
|
||||
| [`abilities/`](abilities/) | Combat ability catalogs; each row matches [`schemas/ability-def.schema.json`](schemas/ability-def.schema.json) — **stable `id`**, **`baseDamage`**, **`cooldownSeconds`** for [E5.M1](../docs/decomposition/modules/E5_M1_CombatRulesEngine.md) |
|
||||
| [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) |
|
||||
|
||||
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
|
||||
|
|
@ -19,6 +20,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
|
||||
**Prototype Slice 3 — recipes (E3M2-01):** CI expects **exactly eight** recipe ids aligned to [E3.M2 recipe freeze](../docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md#prototype-slice-3-freeze-e3m2-01) — at least one **`process`** and one **`make`** row; all **`requiredSkillId`** values **`refine`**; every input/output **`itemId`** from the frozen item catalog. **Do not rename** recipe `id` after ship. See [E3M2-prototype-backlog.md](../docs/plans/E3M2-prototype-backlog.md).
|
||||
|
||||
**Prototype E5 Slice 1 — abilities (NEO-76):** CI expects **exactly four** ability ids aligned to [E5.M1 ability freeze](../docs/decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01) — **`prototype_pulse`**, **`prototype_guard`**, **`prototype_dash`**, **`prototype_burst`**. **Do not rename** ability `id` after ship—change **`displayName`** only. See [E5M1-prototype-backlog.md](../docs/plans/E5M1-prototype-backlog.md) and [NEO-76 plan](../docs/plans/NEO-76-implementation-plan.md).
|
||||
|
||||
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).
|
||||
|
||||
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"abilities": [
|
||||
{
|
||||
"id": "prototype_pulse",
|
||||
"displayName": "Prototype Pulse",
|
||||
"baseDamage": 25,
|
||||
"cooldownSeconds": 3.0,
|
||||
"abilityKind": "attack"
|
||||
},
|
||||
{
|
||||
"id": "prototype_guard",
|
||||
"displayName": "Prototype Guard",
|
||||
"baseDamage": 0,
|
||||
"cooldownSeconds": 6.0,
|
||||
"abilityKind": "utility"
|
||||
},
|
||||
{
|
||||
"id": "prototype_dash",
|
||||
"displayName": "Prototype Dash",
|
||||
"baseDamage": 0,
|
||||
"cooldownSeconds": 4.0,
|
||||
"abilityKind": "movement"
|
||||
},
|
||||
{
|
||||
"id": "prototype_burst",
|
||||
"displayName": "Prototype Burst",
|
||||
"baseDamage": 40,
|
||||
"cooldownSeconds": 5.0,
|
||||
"abilityKind": "attack"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/ability-def.json",
|
||||
"title": "AbilityDef",
|
||||
"description": "Single combat ability row for catalogs (e.g. content/abilities/*_abilities.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M1_CombatRulesEngine.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "displayName", "baseDamage", "cooldownSeconds"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable ability key for hotbar bindings, cast requests, telemetry, and combat resolution. Never change after content ships; add a new id if the ability splits."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating character data."
|
||||
},
|
||||
"baseDamage": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Deterministic damage dealt on successful combat resolve in prototype Slice 1 (no miss/crit RNG)."
|
||||
},
|
||||
"cooldownSeconds": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Per-ability cooldown duration applied on successful resolve once E5.M1 wires catalog timing."
|
||||
},
|
||||
"abilityKind": {
|
||||
"type": "string",
|
||||
"description": "Optional UX grouping for HUD and tooling (attack vs utility vs movement).",
|
||||
"enum": ["attack", "utility", "movement"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +96,8 @@ Implement gathering from world nodes, item/inventory representation, recipe exec
|
|||
|
||||
**Linear backlog:** [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) — [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) → [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75).
|
||||
|
||||
**Status:** Slice 5 client capstone landed — [NEO-75](../../manual-qa/NEO-75.md) manual QA; NEO-72→NEO-75 complete; prototype gather→craft loop playable in Godot (not Bruno-only).
|
||||
|
||||
<a id="epic-3-slice-4"></a>
|
||||
|
||||
### Slice 4 - Sinks and economy policy (pre-production)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ Deliver tab-target combat resolution, NPC behaviors and telegraphs, encounter te
|
|||
- Combat readable in fixed isometric camera; no silent failures on ability deny.
|
||||
- Telemetry hooks: `encounter_start`, `ability_used`, `player_death`, `enemy_defeat`.
|
||||
|
||||
**Linear backlog:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) — [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44).
|
||||
|
||||
### Slice 2 - NPC archetypes and telegraphs
|
||||
|
||||
- Scope: E5.M2 for three archetypes: melee pressure, ranged control, elite mini-boss.
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
**Resource node catalogs ([NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)):** the same script validates `content/resource-nodes/*_resource_nodes.json` rows against [`content/schemas/resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json) and `*_resource_yields.json` rows against [`content/schemas/resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); rejects **duplicate `nodeDefId`**; (Slice 2) enforces the **frozen four-node** id set, **one row per `gatherLens`**, exactly **one yield row per node**, yield **`itemId`** cross-check against item catalogs, and **only `scrap_metal_bulk`** as yield item in prototype.
|
||||
|
||||
**Recipe catalogs ([NEO-65](https://linear.app/neon-sprawl/issue/NEO-65)):** the same script validates each row in `content/recipes/*_recipes.json` against [`content/schemas/recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) (I/O rows via [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json)), rejects **duplicate `id`** across files, cross-checks every input/output **`itemId`** against item catalogs and every **`requiredSkillId`** against skill catalogs, and (Slice 3) enforces the **frozen eight-recipe** id set, at least one **`process`** and one **`make`** row, and **`requiredSkillId: refine`** on every row. Extend the script when additional catalogs and schemas ship.
|
||||
**Recipe catalogs ([NEO-65](https://linear.app/neon-sprawl/issue/NEO-65)):** the same script validates each row in `content/recipes/*_recipes.json` against [`content/schemas/recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) (I/O rows via [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json)), rejects **duplicate `id`** across files, cross-checks every input/output **`itemId`** against item catalogs and every **`requiredSkillId`** against skill catalogs, and (Slice 3) enforces the **frozen eight-recipe** id set, at least one **`process`** and one **`make`** row, and **`requiredSkillId: refine`** on every row.
|
||||
|
||||
**Ability catalogs ([NEO-76](https://linear.app/neon-sprawl/issue/NEO-76)):** the same script validates each row in `content/abilities/*_abilities.json` against [`content/schemas/ability-def.schema.json`](../../../content/schemas/ability-def.schema.json), rejects **duplicate `id`** across files, and (E5 Slice 1) enforces the **frozen four-ability** id set (`prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`). Extend the script when additional catalogs and schemas ship.
|
||||
|
||||
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E3.M1 |
|
||||
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Ready — Slice 2 backlog [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)–[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) complete ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md); NEO-41 anchor below; see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | Ready — Slice 2 backlog [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)–[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) complete ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md); NEO-41 anchor below; see [dependency register](module_dependency_register.md)); Slice 5 client [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) gather feedback + capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) playable loop landed |
|
||||
| **Linear** | Label **`E3.M1`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |
|
||||
|
||||
## Purpose
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E3.M2 |
|
||||
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Ready — Slice 3 server backlog [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md): E3M2-01 [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) through E3M2-07 [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) landed (see [dependency register](module_dependency_register.md)); client craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) is Slice 5 |
|
||||
| **Status** | Ready — Slice 3 server backlog [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md): E3M2-01 [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) through E3M2-07 [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) landed; Slice 5 client [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI + capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) playable loop landed |
|
||||
|
||||
## Implementation snapshot
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ The **first shipped eight-recipe spine** under `content/recipes/*.json` is **fro
|
|||
|
||||
Epic 3 **Slice 3** — gather → refine → item used in combat or quest; telemetry `item_crafted`, `craft_failed`, time-to-first-craft.
|
||||
|
||||
**Linear backlog (decomposed):** [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-01** [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) (content + CI) through **E3M2-07** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) (telemetry hooks). **Client (Slice 5):** craft UI — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-03** [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74).
|
||||
**Linear backlog (decomposed):** [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-01** [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) (content + CI) through **E3M2-07** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) (telemetry hooks). **Client (Slice 5):** craft UI — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-03** [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) (**landed** — `recipe_definitions_client.gd`, `craft_client.gd`, `craft_recipe_panel.gd`, [`NEO-74` manual QA](../../manual-qa/NEO-74.md)).
|
||||
|
||||
## Risks and telemetry
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E3.M3 |
|
||||
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | In Progress — Slice 1 backlog [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)–[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) ([E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md); see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | In Progress — Slice 1 backlog [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)–[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) complete ([E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md); see [dependency register](module_dependency_register.md)); Slice 5 client [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) inventory HUD + capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) economy HUD collapse landed |
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
| **Module ID** | E5.M1 |
|
||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **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+ pending (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
|
||||
|
||||
|
|
@ -49,6 +50,42 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
|
||||
See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 abilities, cooldowns/resources; telemetry `encounter_start`, `ability_used`, `player_death`, `enemy_defeat`.
|
||||
|
||||
**E5M1-04 (NEO-78):** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`.
|
||||
|
||||
**E5M1-05 (NEO-80):** **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** — lazy HP init for **`PrototypeTargetRegistry`** dummies at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80).
|
||||
|
||||
## Linear backlog (decomposed)
|
||||
|
||||
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md).
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E5M1-01 | [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) |
|
||||
| E5M1-02 | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) |
|
||||
| E5M1-03 | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) |
|
||||
| E5M1-04 | [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) |
|
||||
| E5M1-05 | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) |
|
||||
| E5M1-06 | [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) |
|
||||
| E5M1-07 | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) |
|
||||
| E5M1-08 | [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) |
|
||||
| E5M1-09 | [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) |
|
||||
| E5M1-10 | [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) |
|
||||
| E5M1-11 | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) |
|
||||
| E5M1-12 | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) |
|
||||
|
||||
## Prototype Slice 1 freeze (E5M1-01)
|
||||
|
||||
The **first shipped four-ability combat spine** under `content/abilities/*.json` is **frozen** for hotbar bindings, cast validation, and combat resolution until a deliberate migration or follow-up issue expands the roster.
|
||||
|
||||
| `AbilityDef.id` | `displayName` | `baseDamage` | `cooldownSeconds` | `abilityKind` |
|
||||
|-----------------|---------------|--------------|-------------------|---------------|
|
||||
| **`prototype_pulse`** | Prototype Pulse | 25 | 3.0 | `attack` |
|
||||
| **`prototype_guard`** | Prototype Guard | 0 | 6.0 | `utility` |
|
||||
| **`prototype_dash`** | Prototype Dash | 0 | 4.0 | `movement` |
|
||||
| **`prototype_burst`** | Prototype Burst | 40 | 5.0 | `attack` |
|
||||
|
||||
**Rules:** Do **not** rename these `id` values—change **`displayName`** only, or add a new `id` in a follow-up issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) and server startup ([NEO-77](../../plans/NEO-77-implementation-plan.md)) enforce **exactly** these four ids and duplicate-`id` rejection across all ability JSON files. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows. **`prototype_pulse`** at 25 damage × 4 casts defeats a 100 HP prototype dummy (E5M1 backlog default). **`IAbilityDefinitionRegistry`** + hotbar/cast migration **landed** ([NEO-79](../../plans/NEO-79-implementation-plan.md), E5M1-03).
|
||||
|
||||
## Prototype notes (NEO-28)
|
||||
|
||||
Epic 1 **Slice 3** exercises a minimal cast accept/deny surface on **`POST /game/players/{id}/ability-cast`** before the full engine exists: after hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), the server requires **`targetId`** to match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`** for vocabulary alignment. **`accepted: true`** still does not imply damage, cooldown commit, or a full **`CombatResolution`** payload — those remain E5.M1 scope.
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
|||
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
|
||||
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
|
||||
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 |
|
||||
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **Slice 5 client (remaining):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` |
|
||||
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **Slice 5 client (remaining):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
|
||||
| E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
|
||||
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` |
|
||||
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
|
||||
| E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
|
||||
| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-06** [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) through **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). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -71,11 +71,13 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E5.M1 | CombatRulesEngine | E1.M3, E2.M2 | CombatAction, CombatResolution, ThreatState | Prototype | Planned |
|
||||
| E5.M1 | CombatRulesEngine | E1.M3, E2.M2 | CombatAction, CombatResolution, ThreatState | Prototype | In Progress |
|
||||
| E5.M2 | NpcAiAndBehaviorProfiles | E5.M1 | NpcBehaviorDef, TelegraphEvent, AggroRule | Prototype | Planned |
|
||||
| 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).
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# NEO-74 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-74 |
|
||||
| Title | E3.M2: Client craft UI + recipe list (E3S5-03) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-74/e3m2-client-craft-ui-recipe-list-e3s5-03 |
|
||||
| Plan | `docs/plans/NEO-74-implementation-plan.md` |
|
||||
| Branch | `NEO-74-e3m2-client-craft-ui-recipe-list` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Server running with default dev player (`dev-local-1`); craft HTTP landed (NEO-70); recipe defs GET landed (NEO-68); inventory HUD (NEO-72) and gather/skill HUD (NEO-73) working.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows after boot.
|
||||
3. Scroll the recipe panel below skills — **eight** prototype recipes with **Craft** buttons.
|
||||
4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged.
|
||||
5. Gather **≥5** **`scrap_metal_bulk`** (see [NEO-73 manual QA](NEO-73.md) **R** at resource nodes).
|
||||
6. Click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: +1 …`** (refined plate output). **`InventoryLabel`** updates without **I**. **`refine`** xp increases on **`SkillProgressionLabel`**.
|
||||
7. Gather **≥1** more scrap if needed; ensure **≥2** **`refined_plate_stock`** and **≥1** **`scrap_metal_bulk`** in bag (craft **`refine_scrap_standard`** again or gather).
|
||||
8. Click **Craft** on **`make_field_stim_mk0`**. Success feedback shows **`field_stim_mk0`** output; bag lists consumable; **`refine`** xp increases again.
|
||||
9. Optional deny spot-check: with a full bag, attempt a craft whose output cannot fit — expect **`Craft: denied — inventory_full`**.
|
||||
|
||||
## Notes
|
||||
|
||||
- Craft quantity is fixed at **1** per button (no spinner in this story).
|
||||
- Success copy comes from POST **`outputsGranted`**; inventory and skill rows refresh via GET after success only.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# NEO-75 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-75 |
|
||||
| Title | E3: Playable gather→refine→make loop in client (E3S5-04) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-75/e3-playable-gatherrefinemake-loop-in-client-e3s5-04 |
|
||||
| Plan | `docs/plans/NEO-75-implementation-plan.md` |
|
||||
| Branch | `NEO-75-e3-playable-gather-refine-make-loop` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** inventory, skills, and resource-node depletion reset.
|
||||
- **No Bruno/curl seeding** for this checklist — all materials come from in-client gather (**R**).
|
||||
- NEO-72 inventory HUD, NEO-73 gather feedback, NEO-74 craft UI landed on the same branch or `main`.
|
||||
|
||||
## Material math
|
||||
|
||||
| Step | Action | Scrap | Plates | Result |
|
||||
|------|--------|-------|--------|--------|
|
||||
| Gather ×4 | **R** at four anchors (below) | **+11** | — | 11 scrap |
|
||||
| Refine #1 | Craft **`refine_scrap_standard`** | −5 | +1 | 6 scrap, 1 plate |
|
||||
| Refine #2 | Craft **`refine_scrap_standard`** | −5 | +1 | 1 scrap, 2 plates |
|
||||
| Make | Craft **`make_field_stim_mk0`** | −1 | −2 | **`field_stim_mk0` ×1** |
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`InventoryLabel`** shows empty bag; **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows.
|
||||
3. **WASD** to **`prototype_resource_node_alpha`** anchor **(12, −6)**. Press **R** once. **`GatherFeedbackLabel`** shows **`+1`** scrap delta; inventory scrap updates without **I**; **`salvage`** xp increases.
|
||||
4. Walk to **`prototype_subsurface_vein_beta`** **(18, −6)**; press **R** (**+2** scrap). Repeat at **`prototype_bio_mat_gamma`** **(12, 0)** (**+3**) and **`prototype_urban_bulk_delta`** **(18, 0)** (**+5**). Inventory shows **11** scrap total.
|
||||
5. In the recipe panel, click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows success; inventory shows **6** scrap and **1** **`refined_plate_stock`**; **`refine`** xp increases.
|
||||
6. Click **Craft** on **`refine_scrap_standard`** again. Inventory shows **1** scrap and **2** plates.
|
||||
7. Click **Craft** on **`make_field_stim_mk0`**. Success feedback shows **`field_stim_mk0`** output; **`InventoryLabel`** lists **`Field Stim Mk0`** (or agreed display name) with quantity **≥ 1**; **`refine`** xp increases again.
|
||||
8. Uncheck **`Economy HUD`** — inventory, skills, and recipe panel hide; gather/craft feedback labels above remain visible. Check **`Economy HUD`** again — panel repopulates with current data.
|
||||
|
||||
## Notes
|
||||
|
||||
- **R** posts the **nearest in-range** `resource_node`; walk to each anchor before pressing **R** (see [NEO-73 manual QA](NEO-73.md) for anchor coords).
|
||||
- Re-run this checklist after a **server restart** (nodes deplete; inventory persists only in memory).
|
||||
- Combat **use** of **`field_stim_mk0`** is out of scope (E5 follow-up).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Steps 1–8 completable in one session without Bruno/curl.
|
||||
- [ ] **`field_stim_mk0`** quantity **≥ 1** in inventory at step 7.
|
||||
- [ ] **Salvage** xp moved after gather; **refine** xp moved after each successful craft.
|
||||
|
|
@ -127,9 +127,9 @@ Working backlog for **Epic 3 — Slice 5** ([client economy integration](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow.
|
||||
- [ ] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET).
|
||||
- [ ] **Refine** skill XP visible and increases on successful craft.
|
||||
- [x] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow.
|
||||
- [x] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET).
|
||||
- [x] **Refine** skill XP visible and increases on successful craft.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -150,9 +150,10 @@ Working backlog for **Epic 3 — Slice 5** ([client economy integration](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Manual QA checklist completable by a human in one session with server + client running.
|
||||
- [ ] **`field_stim_mk0`** quantity ≥ 1 in inventory at end of script.
|
||||
- [ ] Epic 3 Slice 3 acceptance re-read: **player** (not Bruno) completes gather → refine → usable item.
|
||||
- [x] Manual QA checklist completable by a human in one session with server + client running.
|
||||
- [x] **`field_stim_mk0`** quantity ≥ 1 in inventory at end of script.
|
||||
- [x] Epic 3 Slice 3 acceptance re-read: **player** (not Bruno) completes gather → refine → usable item.
|
||||
- [x] Economy HUD collapse toggle functional ([`NEO-75` manual QA](../manual-qa/NEO-75.md) step 8).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
# E5.M1 — Prototype story backlog (CombatRulesEngine)
|
||||
|
||||
Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp)). Decomposition and contracts: [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E5.M1`**; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Precursor (do not re-scope):** [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) cast funnel — hotbar loadout ([NEO-29](https://linear.app/neon-sprawl/issue/NEO-29)), cast POST + client digits ([NEO-31](https://linear.app/neon-sprawl/issue/NEO-31)), target lock + range deny ([NEO-28](https://linear.app/neon-sprawl/issue/NEO-28)), global cooldown + snapshot ([NEO-32](https://linear.app/neon-sprawl/issue/NEO-32)), telemetry hook comments ([NEO-30](https://linear.app/neon-sprawl/issue/NEO-30)). **`POST …/ability-cast`** today returns **`accepted: true`** with **no** damage, **no** per-ability cooldown from data, and **no** **`CombatResolution`** payload — that gap is **E5.M1**.
|
||||
|
||||
**Upstream (must be landed):** [E1.M3](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) targeting ([NEO-23](https://linear.app/neon-sprawl/issue/NEO-23)–[NEO-26](https://linear.app/neon-sprawl/issue/NEO-26)) — **`PrototypeTargetRegistry`** alpha/beta stubs remain combat dummies until [E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md). [E2.M2](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) skill XP engine is **not** the combat payout path — gig XP is [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44).
|
||||
|
||||
**Prototype combat spine (frozen in E5M1-01):** **four** `AbilityDef` rows — ids **`prototype_pulse`**, **`prototype_guard`**, **`prototype_dash`**, **`prototype_burst`** (same allowlist as today’s hardcoded [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs)). **Two** combat dummies — **`prototype_target_alpha`** / **`prototype_target_beta`** with shared max HP. Player-as-target hostility and **E6.M1** PvP gates stay **stub deny** until Epic 6; prototype targets are NPC stubs only.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E5M1-01 | server | [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) |
|
||||
| E5M1-02 | server | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) |
|
||||
| E5M1-03 | server | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) |
|
||||
| E5M1-04 | server | [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) |
|
||||
| E5M1-05 | server | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) |
|
||||
| E5M1-06 | server | [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) |
|
||||
| E5M1-07 | server | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) |
|
||||
| E5M1-08 | server | [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) |
|
||||
| E5M1-09 | server | [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) |
|
||||
| E5M1-10 | server | [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) |
|
||||
| E5M1-11 | client | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) |
|
||||
| E5M1-12 | client | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) |
|
||||
|
||||
**Dependency graph in Linear:** E5M1-02 blocked by E5M1-01. E5M1-03 blocked by E5M1-02. E5M1-04 blocked by E5M1-03. E5M1-05 blocked by E5M1-03. E5M1-06 blocked by E5M1-05 and E5M1-03. E5M1-07 blocked by E5M1-06. E5M1-08 blocked by E5M1-06 (may parallel E5M1-07). E5M1-09 blocked by E5M1-07. E5M1-10 (NEO-44) blocked by E5M1-07. E5M1-11 blocked by E5M1-07 and E5M1-08. E5M1-12 blocked by E5M1-11 and E5M1-10.
|
||||
|
||||
**Board order:** estimates **1–12** matching slug order (E5M1-01 = 1 … E5M1-12 = 12). On the Epic 5 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E5M1-01** | server | Existing four prototype ability ids (hardcoded allowlist) |
|
||||
| 2 | **E5M1-02** | server | E5M1-01 |
|
||||
| 3 | **E5M1-03** | server | E5M1-02 |
|
||||
| 4 | **E5M1-04** | server | E5M1-03 |
|
||||
| 5 | **E5M1-05** | server | E5M1-03, `PrototypeTargetRegistry` ids |
|
||||
| 6 | **E5M1-06** | server | E5M1-05, E5M1-03 |
|
||||
| 7 | **E5M1-07** | server | E5M1-06 |
|
||||
| 8 | **E5M1-08** | server | E5M1-06 |
|
||||
| 9 | **E5M1-09** | server | E5M1-07 |
|
||||
| 10 | **E5M1-10** | server | E5M1-07 ([NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)) |
|
||||
| 11 | **E5M1-11** | client | E5M1-07, E5M1-08 |
|
||||
| 12 | **E5M1-12** | client | E5M1-11, E5M1-10 |
|
||||
|
||||
**Downstream (separate modules):** [E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) NPC AI + telegraphs; [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) encounters + loot; [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) closes when **`CombatResolution`** lands; per-ability cooldown UI may refresh from extended cast responses or existing snapshot route.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Ability count | **4** frozen defs | Matches existing hotbar allowlist; epic allows 4–6 |
|
||||
| Combat targets | **`prototype_target_alpha`** / **`beta`** only | Reuse E1.M3 registry until E5.M2 spawns real NPCs |
|
||||
| Max HP | **100** per dummy (constant in content or combat constants) | Enough casts to observe defeat without tuning TTK yet |
|
||||
| Hit resolution | **Deterministic** damage from `AbilityDef.baseDamage`; no miss/crit RNG | Readable prototype tests; RNG is pre-production |
|
||||
| Cooldown | **Per-ability** `cooldownSeconds` from catalog on successful **resolve** | Replaces global `AbilityPrototypeCooldown` when E5M1-07 lands |
|
||||
| Player HP / death | **Out of scope** Slice 1 | `player_death` telemetry gets a **reserved** hook site only |
|
||||
| `ThreatState` | **Stub** / comment-only in Slice 1 | Full threat/aggro is E5.M2 |
|
||||
| PvP | **Stub** deny path documented; no player targets in prototype registry | [pvp_combat_integration.md](../decomposition/modules/pvp_combat_integration.md) |
|
||||
| Cast wire | **Extend** `POST …/ability-cast` **`AbilityCastResponse` v1** | Avoid parallel combat POST in prototype; E1.M4 client keeps one route |
|
||||
| Target HP read | **`GET /game/world/combat-targets`** snapshot | Client HUD without trusting local damage math |
|
||||
| Gig XP | **[NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)** on **`targetDefeated`** | Combat → **gig** XP only; no E2.M2 skill grant |
|
||||
|
||||
---
|
||||
|
||||
### E5M1-01 — Prototype AbilityDef catalog + schemas + CI
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **four** frozen combat abilities before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/ability-def.schema.json`.
|
||||
- `content/abilities/prototype_abilities.json` with stable **`id`** values matching today’s allowlist (`prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`).
|
||||
- Fields: **`displayName`**, **`baseDamage`** (non-negative int), **`cooldownSeconds`** (positive number), optional **`abilityKind`** enum for future UX (`attack` / `utility` / `movement`).
|
||||
- `scripts/validate_content.py`: schema validation, duplicate ids, exact four-id allowlist, non-empty display names.
|
||||
- Designer note in [E5_M1](../decomposition/modules/E5_M1_CombatRulesEngine.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, combat engine, HTTP, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates ability JSON against schema.
|
||||
- [x] Exactly four prototype ability ids; duplicate `id` fails CI.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
**Landed ([NEO-76](https://linear.app/neon-sprawl/issue/NEO-76)):** [`content/abilities/prototype_abilities.json`](../../content/abilities/prototype_abilities.json), [`ability-def.schema.json`](../../content/schemas/ability-def.schema.json), CI gates in [`validate_content.py`](../../scripts/validate_content.py); plan [NEO-76-implementation-plan.md](NEO-76-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-02 — Server ability catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/abilities/*.json` with CI-parity validation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Combat/` (path finalized in plan).
|
||||
- Fail-fast on malformed files, duplicate ids, allowlist mismatch with CI.
|
||||
- Unit tests (AAA) for loader happy path and failure modes.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Injectable registry interface (E5M1-03), HTTP projection, combat resolution.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Host fails startup on invalid ability JSON (mirror CI rules).
|
||||
- [x] Tests cover at least one happy path and one malformed catalog rejection.
|
||||
|
||||
**Landed ([NEO-77](https://linear.app/neon-sprawl/issue/NEO-77)):** fail-fast server load of `content/abilities/*_abilities.json` — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77-implementation-plan.md](NEO-77-implementation-plan.md)); [server README — Ability catalog](../../server/README.md#ability-catalog-contentabilities-neo-77).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-03 — `IAbilityDefinitionRegistry` + DI
|
||||
|
||||
**Goal:** Injectable lookup for combat and hotbar validation; replace direct static allowlist reads over time.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `IAbilityDefinitionRegistry` + implementation backed by startup catalog.
|
||||
- DI registration in `Program.cs`.
|
||||
- Unit tests (AAA) for known-id lookup and unknown-id miss.
|
||||
- Migrate **`PrototypeAbilityRegistry`** call sites in hotbar/cast path to registry (keep normalize behavior).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP GET route (E5M1-04), damage application.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Unknown ability id fails hotbar/cast validation same as today.
|
||||
- [x] All four prototype ids resolve with expected **`baseDamage`** / **`cooldownSeconds`**.
|
||||
|
||||
**Landed ([NEO-79](https://linear.app/neon-sprawl/issue/NEO-79)):** [`IAbilityDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs) + DI; hotbar/cast migrated — [NEO-79-implementation-plan.md](NEO-79-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-04 — `GET /game/world/ability-definitions`
|
||||
|
||||
**Goal:** Versioned read-only projection of frozen ability defs for client/tooling.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `GET /game/world/ability-definitions` → list DTO (`schemaVersion`, `abilities[]` with id, displayName, baseDamage, cooldownSeconds, abilityKind).
|
||||
- API + integration tests (AAA); Bruno folder `bruno/neon-sprawl-server/ability-definitions/`.
|
||||
- `server/README.md`; no manual QA (server-only per NEO-78 kickoff).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD — client uses this optionally in E5M1-11; not required for Bruno proof.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] GET returns all four prototype abilities with stable field names.
|
||||
- [x] Bruno happy path documents response shape.
|
||||
|
||||
**Landed ([NEO-78](https://linear.app/neon-sprawl/issue/NEO-78)):** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78-implementation-plan.md](NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`.
|
||||
|
||||
**Client counterpart:** optional enrichment in [NEO-85](#e5m1-11--client-combat-feedback--target-hp-hud) — not a separate issue.
|
||||
|
||||
---
|
||||
|
||||
### E5M1-05 — Combat entity health store + prototype dummy init
|
||||
|
||||
**Goal:** Server-owned HP for combat dummies keyed by target id.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `ICombatEntityHealthStore` (or equivalent) with **`TryGet`**, **`ApplyDamage`**, **`TryResetToFull`** for prototype targets.
|
||||
- Initialize **`prototype_target_alpha`** and **`prototype_target_beta`** to catalog max HP at first access (in-memory; not persisted in Slice 1).
|
||||
- Unit tests (AAA) for damage, floor at zero, defeated flag.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Player HP, persistence migration, NPC spawn (E5.M2).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Damaging alpha reduces stored HP; HP cannot go below zero.
|
||||
- [x] Defeated target remains defeated until explicit reset policy (document prototype: server restart resets).
|
||||
|
||||
**Landed ([NEO-80](https://linear.app/neon-sprawl/issue/NEO-80)):** **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** — lazy init for **`prototype_target_alpha`** / **`prototype_target_beta`** at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** ([NEO-80-implementation-plan.md](NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../server/README.md#combat-entity-health-neo-80).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-06 — `CombatOperations.TryResolve` + `CombatResult`
|
||||
|
||||
**Goal:** Pure server combat resolution: ability + target → damage + remaining HP + defeated.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `CombatOperations.TryResolve` (name TBD) using **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`**.
|
||||
- Result type with **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**, stable deny **`reasonCode`** when target already defeated or unknown.
|
||||
- Unit tests (AAA) for happy path, zero-damage utility abilities, defeated-target deny.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP wiring (E5M1-07), gig XP (NEO-44), telemetry hooks.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] **`prototype_pulse`** against full HP dummy deals catalog damage deterministically.
|
||||
- [ ] Second resolve on defeated target returns structured deny (no silent no-op).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-07 — Wire `ability-cast` into combat engine + extend cast response
|
||||
|
||||
**Goal:** Successful casts after E1.M4 gates invoke **`CombatOperations`** and return **`CombatResolution`** fields on the existing cast POST.
|
||||
|
||||
**In scope**
|
||||
|
||||
- After NEO-28/32 gates pass, call combat engine; commit per-ability cooldown from ability def (replace global constant).
|
||||
- Extend **`AbilityCastResponse` v1** with optional resolution block on accept: e.g. **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**, **`targetId`**, **`abilityId`** (exact names in plan).
|
||||
- JSON deny **`target_defeated`** (or aligned code) when firing at defeated dummy.
|
||||
- Integration tests + Bruno: lock target → cast → response shows damage; repeated casts until defeated then deny.
|
||||
- `server/README.md` cast section updated; cross-link E1.M4/E5.M1 deny vocabulary.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot presentation — **[NEO-85](#e5m1-11--client-combat-feedback--target-hp-hud)**.
|
||||
- Rich **`ThreatState`**, player damage, PvP eligibility (stub only).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Accept path returns non-empty resolution payload with deterministic damage.
|
||||
- [ ] Cooldown duration matches ability catalog, not global constant.
|
||||
- [ ] Bruno documents lock → cast → damage → defeat deny spine.
|
||||
|
||||
**Client counterpart:** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)
|
||||
|
||||
---
|
||||
|
||||
### E5M1-08 — `GET /game/world/combat-targets` snapshot
|
||||
|
||||
**Goal:** Read-only HP snapshot for prototype combat dummies for client HUD and manual QA.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `GET /game/world/combat-targets` → versioned list (`targetId`, `maxHp`, `currentHp`, `defeated`).
|
||||
- Seeds store entries for alpha/beta on first read (same init policy as E5M1-05).
|
||||
- API tests (AAA); Bruno folder; README section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot wiring (E5M1-11), NPC movement.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET reflects damage applied via cast route without client-side HP math.
|
||||
- [ ] Defeated flag true after lethal cast sequence in Bruno.
|
||||
|
||||
**Client counterpart:** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)
|
||||
|
||||
---
|
||||
|
||||
### E5M1-09 — Slice 1 combat telemetry hook sites
|
||||
|
||||
**Goal:** Comment-only hooks for Epic 5 Slice 1 product telemetry names; no E9.M1 ingest.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Hook placement in combat resolve / cast accept path for **`ability_used`**, **`enemy_defeat`**.
|
||||
- Reserved **`encounter_start`** / **`player_death`** comment sites (encounters and player HP deferred).
|
||||
- `TODO(E9.M1)` markers; README + module doc pointers.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Production logging, dashboards.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp) vocabulary.
|
||||
- [ ] **`enemy_defeat`** site fires only on transition to defeated (not every overkill cast).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-10 — Gig XP on combat defeat ([NEO-44](https://linear.app/neon-sprawl/issue/NEO-44))
|
||||
|
||||
**Goal:** On prototype target defeat, award **gig XP** to the attacker's **main gig** — **not** E2.M2 skill XP.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Minimal gig progression store + grant helper (prototype main gig only — id TBD in kickoff plan, e.g. frozen **`breach`**).
|
||||
- Hook from combat defeat path in E5M1-07 engine.
|
||||
- **`GET`** (or extend existing snapshot) for gig progression visibility — scope in implementation plan.
|
||||
- Tests proving no **`POST …/skill-progression`** call on combat defeat.
|
||||
- `server/README.md`; manual QA steps.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Full nine-gig catalog UI, hub swap, sub-gig XP ([gigs.md](../game-design/gigs.md)).
|
||||
- Godot gig bar — optional in capstone [NEO-86](#e5m1-12--playable-tab-target-combat-capstone-godot).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition.
|
||||
- [ ] Skill progression snapshot unchanged by combat defeat in tests.
|
||||
- [ ] Documented invariant matches [progression.md](../game-design/progression.md).
|
||||
|
||||
**Note:** Issue pre-dates this backlog; slug **E5M1-10** maps to existing **[NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)**.
|
||||
|
||||
---
|
||||
|
||||
### E5M1-11 — Client combat feedback + target HP HUD
|
||||
|
||||
**Goal:** Godot displays authoritative cast outcomes and dummy HP without local combat math.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend **`ability_cast_client.gd`** (or sibling) to parse extended **`AbilityCastResponse`** resolution fields.
|
||||
- Update **`CastFeedbackLabel`** (or dedicated combat line) with damage / defeat messaging on accept.
|
||||
- **`combat_targets_client.gd`** + HUD label for locked target HP (poll or refresh after successful cast + on target lock change).
|
||||
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
|
||||
- `docs/manual-qa/NEO-85.md` — Godot steps (server + client running).
|
||||
- `client/README.md` combat HUD section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final combat VFX, floating damage numbers art pass, player HP bar.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player sees damage/de defeat feedback in HUD after digit-key cast (not Output-only).
|
||||
- [ ] Target HP label matches server snapshot after cast sequence.
|
||||
- [ ] Manual QA exercisable without Bruno.
|
||||
|
||||
**Client counterpart:** this issue (**NEO-85**).
|
||||
|
||||
---
|
||||
|
||||
### E5M1-12 — Playable tab-target combat capstone (Godot)
|
||||
|
||||
**Goal:** Prove Epic 5 Slice 1 acceptance **in Godot**: tab-target lock, cast abilities, readable deny reasons, defeat a prototype dummy — without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `docs/manual-qa/NEO-86.md` single-session script: spawn → Tab lock alpha → bind slot 0 **`prototype_pulse`** → cast until defeat → verify HUD + optional gig XP visibility if NEO-44 landed.
|
||||
- `client/README.md` end-to-end combat loop section.
|
||||
- Update [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M1 row when complete.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- NPC AI (E5.M2), encounters/loot (E5.M3), group scaling.
|
||||
|
||||
**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.
|
||||
|
||||
**Client counterpart:** this issue (**NEO-86**).
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- **[E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md)** Slice 2 — decompose when E5.M1 lands (NPC archetypes + telegraphs + client telegraph UX).
|
||||
- **[E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md)** module **Ready** when cast path returns real **`CombatResolution`** and client HUD reflects it.
|
||||
- Track delivery in Linear; keep `blockedBy` synchronized if scope changes.
|
||||
- For each issue kickoff: `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` for player-visible **client** stories when implementation lands.
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [x] Every player-visible AC has a **`client`** Linear issue (**NEO-85**, **NEO-86**)
|
||||
- [x] No “optional follow-up” / “future client” without NEO-XX
|
||||
- [x] Capstone client issue exists for playable Godot verification (**NEO-86**)
|
||||
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M1 row updated when work starts
|
||||
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E5.M1 note updated when issues are created
|
||||
|
||||
## Related docs
|
||||
|
||||
- [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md)
|
||||
- [E1_M4_AbilityInputScaffold.md](../decomposition/modules/E1_M4_AbilityInputScaffold.md)
|
||||
- [epic_05_pve_combat.md](../decomposition/epics/epic_05_pve_combat.md)
|
||||
- [abilities.md](../game-design/abilities.md)
|
||||
- [gigs.md](../game-design/gigs.md)
|
||||
- [pvp_combat_integration.md](../decomposition/modules/pvp_combat_integration.md)
|
||||
- [client_server_authority.md](../decomposition/modules/client_server_authority.md)
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# NEO-74 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-74 |
|
||||
| **Title** | E3.M2: Client craft UI + recipe list (E3S5-03) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-74/e3m2-client-craft-ui-recipe-list-e3s5-03 |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 **Slice 5** (client 3 of 4) · backlog **E3S5-03** |
|
||||
| **Branch** | `NEO-74-e3m2-client-craft-ui-recipe-list` |
|
||||
| **Server deps** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — `GET /game/world/recipe-definitions` (**Done**); [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — `POST …/craft` (**Done**); [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — inventory HUD + refresh (**Done**); [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) — skill-progression GET (**Done**) |
|
||||
| **Client deps** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — `SkillProgressionClient`, gather refresh pattern (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` wiring, debug HUD labels, GdUnit mock transport |
|
||||
| **Downstream** | [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) capstone gather→refine→make loop; optional HUD collapse |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Craft success copy** | What should success feedback show? | **`outputsGranted` from POST body** — e.g. `Craft: +1 {displayName}`; still GET inventory + skill-progression after success (E3S5 refresh policy). | **User:** POST **`outputsGranted`** summary. |
|
||||
| **Craft feedback label** | Dedicated label vs reuse gather? | **Dedicated `CraftFeedbackLabel`** below **`GatherFeedbackLabel`** — mirrors cast/gather separation. | **User:** dedicated **`CraftFeedbackLabel`**. |
|
||||
| **Recipe panel layout** | Where to place recipe list + Craft buttons? | **Left-column `ScrollContainer`** below **`SkillProgressionLabel`** — matches debug HUD stack; NEO-75 can collapse if crowded. | **User:** left scroll below skills. |
|
||||
|
||||
No other blocking decisions — list **all eight** prototype recipes per [E3S5 kickoff table](E3S5-client-prototype-backlog.md#kickoff-decisions-slice-5-client); qty **1** only (no spinner); deny copy **`Craft: denied — {reasonCode}`** mirrors gather; **`refine`** row added to existing **`SkillProgressionLabel`** (salvage row unchanged).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Player selects a prototype recipe and **`POST /game/players/{id}/craft`** from Godot; sees success/deny, updated inventory, and **refine** XP on HUD — not Output-panel-only proof.
|
||||
|
||||
**In scope (from Linear + [E3S5-03](E3S5-client-prototype-backlog.md#e3s5-03--client-craft-ui--recipe-list-e3m2)):**
|
||||
|
||||
- **`recipe_definitions_client.gd`:** GET world recipe defs (NEO-68); cache ordered list; emit **`recipes_ready`**.
|
||||
- **`craft_client.gd`:** POST `{ schemaVersion: 1, recipeId }` (qty **1** implicit); parse success/deny + **`reasonCode`** + I/O lists; emit **`craft_result_received`**.
|
||||
- **`craft_recipe_panel.gd`** (child under **`UICanvas`**): build scrollable recipe rows (id / **`displayName`**, inputs summary via **`ItemDefinitionsClient.display_name_for`**, **Craft** **`Button`** per row); signal **`craft_requested(recipe_id)`**.
|
||||
- **`CraftFeedbackLabel`:** success **`Craft: +N {displayName}`** from **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**; no refresh on deny.
|
||||
- Extend **`SkillProgressionLabel`:** add **`refine: level N, xp M`** row below salvage; boot hydrate + refresh after successful craft.
|
||||
- **`main.gd` craft path:** on **`craft_requested`** → **`craft_client.request_craft(recipe_id)`**; on success → paint feedback from POST outputs → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`**; on deny → feedback only (inventory unchanged per AC).
|
||||
- Boot: chain **`recipe_definitions_client.request_sync_from_server()`** after item defs (or parallel); panel populates on **`recipes_ready`**.
|
||||
- GdUnit: craft POST payload/parse; recipe defs parse; deny/success signal paths.
|
||||
- **`client/README.md`** craft UI subsection.
|
||||
- **`docs/manual-qa/NEO-74.md`**.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Station/bench world objects; craft quantity spinner; recipe filtering by skill level.
|
||||
- HUD collapse/toggle (NEO-75 optional).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow.
|
||||
- [x] Denied craft shows **`reasonCode`** on **`CraftFeedbackLabel`**; inventory unchanged (verify via GET / no auto-refresh on deny).
|
||||
- [x] **Refine** skill level/XP visible on **`SkillProgressionLabel`** and increases after successful craft.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Clients:** `recipe_definitions_client.gd`, `craft_client.gd`; `craft_recipe_panel.gd` scroll list + **Craft** buttons.
|
||||
- **HUD:** `CraftFeedbackLabel` (POST **`outputsGranted`** / deny); **`SkillProgressionLabel`** salvage + **refine** rows; inventory refresh on craft success only.
|
||||
- **Tests:** `craft_client_test.gd`, `recipe_definitions_client_test.gd`, `craft_feedback_refresh_test.gd`.
|
||||
- **Docs:** `client/README.md` craft section; [`NEO-74` manual QA](../manual-qa/NEO-74.md).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`recipe_definitions_client.gd`**
|
||||
- Mirror **`item_definitions_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/world/recipe-definitions`**.
|
||||
- **`parse_recipes_json(text)`** static: require **`schemaVersion` 1**, **`recipes`** array.
|
||||
- Cache **`Array`** of recipe dicts in server id order; emit **`recipes_ready(recipes)`**.
|
||||
- Helper **`recipe_by_id(id)`** for panel lookups.
|
||||
|
||||
2. **`craft_client.gd`**
|
||||
- Mirror **`ability_cast_client.gd`** POST pattern: injectable HTTP, **`_busy`** guard.
|
||||
- **`request_craft(recipe_id: String, quantity: int = 1) -> bool`**: POST body `{ "schemaVersion": 1, "recipeId": recipe_id, "quantity": quantity }` to **`…/players/{id}/craft`**.
|
||||
- **`parse_craft_response_json(text)`** static: require **`schemaVersion` 1**, **`success`** bool.
|
||||
- Signal **`craft_result_received(result: Dictionary)`** with parsed body (includes **`success`**, **`reasonCode`**, **`outputsGranted`**, **`inputsConsumed`**, optional **`xpGrantSummary`**).
|
||||
- On HTTP transport failure / non-2xx / JSON mismatch: **`craft_request_failed(recipe_id, reason)`** (mirror interaction client failure path).
|
||||
- **`push_warning`** on deny with stable reason (NEO-31 pattern).
|
||||
|
||||
3. **`craft_recipe_panel.gd`**
|
||||
- **`@export`** refs or **`setup(...)`** for **`ItemDefinitionsClient`** display-name lookup.
|
||||
- **`populate(recipes: Array)`**: clear **`ScrollContainer/VBoxContainer`** children; for each recipe row:
|
||||
- **`Label`**: **`{displayName}`** (`{id}`) + inputs line e.g. **`needs: 5× Scrap Metal Bulk`** (join **`inputs`** with **`display_name_for(itemId)`**).
|
||||
- **`Button`**: text **`Craft`**; pressed → emit **`craft_requested(recipe.get("id"))`**.
|
||||
- Disable buttons while **`craft_client`** busy (optional **`set_craft_busy(busy)`** from **`main`**).
|
||||
|
||||
4. **`main.gd` craft orchestration**
|
||||
- Constants: **`REFINE_SKILL_ID := "refine"`**.
|
||||
- State: **`_last_craft_recipe_id: String`**, **`_craft_busy`** (or delegate to client **`_busy`**).
|
||||
- **`_setup_craft_ui()`**: wire clients, panel, feedback label; connect **`craft_requested`**, **`craft_result_received`**, **`craft_request_failed`**.
|
||||
- Success handler:
|
||||
- Format **`outputsGranted`** rows → **`Craft: +{qty} {displayName}`** (multi-output: comma-join).
|
||||
- Trigger inventory + skill progression refresh (same as gather success).
|
||||
- Deny handler: **`Craft: denied — {reasonCode}`**; no GET refresh.
|
||||
- Extend **`_render_skill_progression_label()`**: salvage row + **`refine`** row (two lines under **`Skills:`** header).
|
||||
|
||||
5. **Scene layout (`main.tscn`)**
|
||||
- Add nodes: **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`** (with **`ScrollContainer` > `VBoxContainer`**).
|
||||
- **`CraftFeedbackLabel`:** below **`GatherFeedbackLabel`** (~offset_top 404); shift **`CooldownSlotsLabel`** down (~+28px) and cascade inventory/skills/panel offsets to avoid overlap.
|
||||
- **`CraftRecipePanel`:** below **`SkillProgressionLabel`** (~offset_top 728); fixed height scroll (~200–280px) for eight rows.
|
||||
- HTTP client nodes as siblings (same pattern as **`InventoryClient`**).
|
||||
|
||||
6. **Tests**
|
||||
- **`craft_client_test.gd`:** mock 200 success → **`success=true`**, **`outputsGranted`** parsed; mock 200 deny → **`success=false`**, **`reasonCode=insufficient_materials`**; verify POST URL + JSON body includes **`recipeId`**. AAA layout.
|
||||
- **`recipe_definitions_client_test.gd`:** mock 200 with two recipes → **`recipes_ready`** length + id order. AAA layout.
|
||||
- **`craft_feedback_refresh_test.gd`:** stub handler — craft allow signal → inventory + skill **`request_sync_from_server`** invoked; deny → refresh **not** invoked. AAA layout.
|
||||
|
||||
7. **Manual QA (`docs/manual-qa/NEO-74.md`)**
|
||||
- Fresh player: craft deny **`insufficient_materials`** for **`refine_scrap_standard`** → HUD reason + bag unchanged.
|
||||
- After gather ≥5 scrap: **`refine_scrap_standard`** success → feedback line, inventory shows **`refined_plate_stock`**, refine XP bumped.
|
||||
- With 2 refined + 1 scrap: **`make_field_stim_mk0`** success → **`field_stim_mk0`** in bag.
|
||||
- Deny paths spot-check: unknown recipe (if test hook), bag full (optional note — may need pre-fill).
|
||||
|
||||
8. **Docs on land**
|
||||
- Update [E3_M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
|
||||
- Check **E3S5-03** boxes in **`E3S5-client-prototype-backlog.md`**.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-74-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/recipe_definitions_client.gd` | GET world recipe defs; **`recipes_ready`**; parse v1 **`recipes`** array. |
|
||||
| `client/scripts/recipe_definitions_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/scripts/craft_client.gd` | POST craft; **`craft_result_received`** / **`craft_request_failed`**. |
|
||||
| `client/scripts/craft_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/scripts/craft_recipe_panel.gd` | Scrollable recipe list + **Craft** buttons; **`craft_requested`** signal. |
|
||||
| `client/scripts/craft_recipe_panel.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/craft_client_test.gd` | GdUnit: POST payload, success/deny parse. AAA layout. |
|
||||
| `client/test/recipe_definitions_client_test.gd` | GdUnit: parse v1 recipes array. AAA layout. |
|
||||
| `client/test/craft_feedback_refresh_test.gd` | GdUnit: success → inventory + skill refresh; deny → no refresh. AAA layout. |
|
||||
| `docs/manual-qa/NEO-74.md` | Craft spine UI session: deny, refine, make stim, refine XP delta. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/main.gd` | Boot wiring for recipe defs + craft clients; craft orchestration; **`CraftFeedbackLabel`** render; **`refine`** skill row; authority HTTP config for new clients. |
|
||||
| `client/scenes/main.tscn` | Add **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`**; adjust HUD vertical offsets. |
|
||||
| `client/README.md` | Craft UI subsection: recipe panel, craft POST, feedback labels, boot hydrate, server deps. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `client/test/craft_client_test.gd` | Mock 200 success → **`craft_result_received`** with **`outputsGranted`**; mock deny → **`reasonCode`**; POST body **`recipeId`** + **`schemaVersion` 1**. AAA layout. |
|
||||
| `client/test/recipe_definitions_client_test.gd` | Mock 200 → **`recipes_ready`** emits ordered array; schema mismatch → no emit. AAA layout. |
|
||||
| `client/test/craft_feedback_refresh_test.gd` | Craft success handler triggers inventory + skill **`request_sync_from_server`**; deny path skips refresh (spy/mock). AAA layout. |
|
||||
|
||||
No new **C#** tests (client-only). No new Bruno (server covered by NEO-68/NEO-70).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **HUD vertical crowding (8 recipes)** | Left scroll with ~200–280px max height; full collapse deferred to NEO-75. | **adopted** (kickoff) |
|
||||
| **Multi-output success copy** | Comma-join **`outputsGranted`** rows — prototype recipes have single output except batch variants. | **adopted** |
|
||||
| **Craft busy double-click** | Guard with **`craft_client._busy`**; disable panel buttons while in flight. | **adopted** |
|
||||
| **Deny without inventory refresh** | Do not call inventory GET on **`success=false`** — AC requires unchanged bag; manual QA verifies. | **adopted** |
|
||||
| **Item display names on recipe inputs** | Reuse landed **`ItemDefinitionsClient`** cache; fall back to raw **`itemId`**. | **adopted** |
|
||||
| **Slice 5 module docs** | Update alignment register when NEO-74 lands, not at kickoff. | **deferred** (implementation) |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Craft success copy:** **`outputsGranted`** from POST response formatted on **`CraftFeedbackLabel`** (user confirmed).
|
||||
- **Craft feedback:** dedicated **`CraftFeedbackLabel`** below gather (user confirmed).
|
||||
- **Recipe panel:** left-column **`ScrollContainer`** below **`SkillProgressionLabel`** (user confirmed).
|
||||
- **Recipe scope:** all **eight** prototype recipes with qty-**1** **Craft** buttons (E3S5 backlog).
|
||||
- **Skill HUD:** add **`refine`** row to **`SkillProgressionLabel`**; refresh after successful craft.
|
||||
- **Refresh policy:** GET inventory + skill-progression on craft success only; no GET on deny.
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# NEO-75 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-75 |
|
||||
| **Title** | E3: Playable gather→refine→make loop in client (E3S5-04) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-75/e3-playable-gatherrefinemake-loop-in-client-e3s5-04 |
|
||||
| **Module** | Epic 3 **Slice 5 capstone** — [E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) + [E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) + [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · backlog **E3S5-04** |
|
||||
| **Branch** | `NEO-75-e3-playable-gather-refine-make-loop` |
|
||||
| **Server deps** | NEO-63 gather interact, NEO-70 craft POST, NEO-55 inventory GET, NEO-37 skill-progression GET (**Done** on `main`) |
|
||||
| **Client deps** | [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) inventory HUD (**Done**); [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) gather feedback (**Done**); [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI (**Done**) |
|
||||
| **Pattern** | Capstone integration — reuse NEO-72–74 wiring; docs + manual QA primary; HUD collapse per kickoff |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Implementation scope** | Docs-only vs code changes? | **Docs + manual QA first**; add code only if QA fails — loop already wired in NEO-72–74. | **User:** docs + manual QA first; code only if QA fails. |
|
||||
| **HUD collapse** | Build toggle now or defer? | **Defer** — optional per backlog; left scroll usable for capstone. | **User:** **implement** collapsible HUD sections in this story. |
|
||||
| **Capstone session start** | Fresh player without Bruno? | **Restart server** (in-memory dev store resets) before Godot **F5** — no curl/Bruno seeding. | **Adopted** (Linear AC + kickoff default). |
|
||||
| **Progression gate smoke** | Filter recipes by skill level? | **Visible XP only** — salvage/refine rows refresh after gather/craft; no recipe gating UI (explicit NEO-74 out-of-scope). | **Adopted** (decomposition default). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Prove Epic 3 prototype acceptance **in Godot**: gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** → **`make_field_stim_mk0`** without Bruno; satisfies vision **crafting loop** + **progression gate** smoke test.
|
||||
|
||||
**In scope (from Linear + [E3S5-04](E3S5-client-prototype-backlog.md#e3s5-04--playable-gatherrefinemake-loop-in-client-capstone)):**
|
||||
|
||||
- **`docs/manual-qa/NEO-75.md`**: numbered **single-session** capstone script — fresh dev player, four-node gather route, two refines + one make, final **`field_stim_mk0`** assertion; **no Bruno/curl** steps.
|
||||
- **`client/README.md`**: **End-to-end economy loop** section — integration checklist (boot → **R** gather → craft panel → inventory/skill refresh chain); input keys; material math (11 scrap minimum).
|
||||
- **Collapsible economy HUD**: toggle to hide/show **`InventoryLabel`**, **`SkillProgressionLabel`**, **`CraftRecipePanel`** block so prototype scene stays playable on smaller viewports.
|
||||
- **Module alignment** (on story completion): update [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M1/M2/M3 rows + Epic 3 Slice 5 “complete” note in [`epic_03_crafting_economy.md`](../decomposition/epics/epic_03_crafting_economy.md).
|
||||
- **Integration fixes only if capstone QA fails** on current `main` wiring (unexpected deny, refresh gap, etc.).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Quest UI (E7); combat consumable **use** of **`field_stim_mk0`** (E5 follow-up); art pass.
|
||||
- New HTTP routes or economy clients; recipe quantity spinner; station placement.
|
||||
- Bruno-only verification as prototype-complete proof.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Manual QA checklist completable by a human in **one session** with server + client running (**`docs/manual-qa/NEO-75.md`**).
|
||||
- [x] **`field_stim_mk0`** quantity ≥ **1** in inventory at end of script.
|
||||
- [x] Epic 3 Slice 3 AC re-read satisfied in Godot: player (not Bruno) completes **gather → refine → usable item**; **salvage** / **refine** XP visible after actions.
|
||||
- [x] Economy HUD collapse toggle documented and functional.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **HUD:** `prototype_economy_hud_section.gd` — **`Economy HUD`** checkbox collapses inventory/skills/recipe panel under **`UICanvas/EconomyHudSection/Body/`**.
|
||||
- **Docs:** [`NEO-75` manual QA](../manual-qa/NEO-75.md) capstone script (zero Bruno); `client/README.md` end-to-end economy loop section.
|
||||
- **Tests:** `prototype_economy_hud_section_test.gd` — toggle visibility.
|
||||
- **Alignment:** E3.M1/M2/M3 rows + E3S5-04 backlog AC updated for Slice 5 client complete.
|
||||
- **Integration:** No `main.gd` logic changes beyond `@onready` path updates for reparented HUD nodes.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Capstone manual QA script (`docs/manual-qa/NEO-75.md`)
|
||||
|
||||
**Preconditions:** Stop any running server; start fresh (`dotnet run`) so in-memory **`dev-local-1`** inventory/skills/nodes reset. Godot **F5** with no prior session curl seeding.
|
||||
|
||||
**Material math** (from [`content/recipes/prototype_recipes.json`](../../content/recipes/prototype_recipes.json)):
|
||||
|
||||
| Step | Action | Scrap consumed | Plates gained | Scrap remaining |
|
||||
|------|--------|----------------|---------------|-----------------|
|
||||
| Gather ×4 | **R** at alpha (+1), beta (+2), gamma (+3), delta (+5) | — | — | **11** |
|
||||
| Refine #1 | Craft **`refine_scrap_standard`** | 5 | +1 | **6** |
|
||||
| Refine #2 | Craft **`refine_scrap_standard`** | 5 | +1 | **1** (+ **2** plates) |
|
||||
| Make | Craft **`make_field_stim_mk0`** | 1 | −2 plates | **0** (+ **`field_stim_mk0` ×1**) |
|
||||
|
||||
**Checklist outline:**
|
||||
|
||||
1. Start server + client; confirm empty bag and skill rows on HUD.
|
||||
2. Walk each resource anchor (coords from [NEO-73 manual QA](../manual-qa/NEO-73.md)); **R** once per node — verify **`GatherFeedbackLabel`**, inventory scrap, **salvage** XP without **I**.
|
||||
3. Craft **`refine_scrap_standard`** twice; verify **`CraftFeedbackLabel`**, **refine** XP, plate stock.
|
||||
4. Craft **`make_field_stim_mk0`**; verify consumable in **`InventoryLabel`**.
|
||||
5. Optional: collapse economy HUD, expand again — panel still populated.
|
||||
|
||||
### 2. HUD collapse (`prototype_economy_hud_section.gd`)
|
||||
|
||||
- Add **`UICanvas/EconomyHudSection`** (`VBoxContainer` + script):
|
||||
- Header row: **`CheckButton`** text **`Economy HUD`** (default **pressed** / expanded).
|
||||
- Body **`VBoxContainer`**: reparent existing **`InventoryLabel`**, **`SkillProgressionLabel`**, **`CraftRecipePanel`** from flat **`UICanvas`** (remove fixed **`offset_top`** stacking — use container layout).
|
||||
- **`toggled(pressed)`** → set body **`visible = pressed`**; header stays visible.
|
||||
- **`main.gd`**: no signal rewiring — `@onready` paths update to new hierarchy (or export node refs unchanged if reparent preserves names).
|
||||
- Document toggle in **`client/README.md`** and capstone manual QA step.
|
||||
|
||||
**Keep expanded by default** so capstone QA matches NEO-72–74 expectations; collapse is optional UX for viewport space.
|
||||
|
||||
### 3. README integration checklist
|
||||
|
||||
New **`## End-to-end economy loop (NEO-75)`** section after NEO-74 craft subsection:
|
||||
|
||||
- Flow diagram in prose: boot GETs → **R** nearest resource node → gather feedback + inventory/salvage refresh → scroll craft panel → **Craft** → craft feedback + inventory/refine refresh.
|
||||
- Cross-links: NEO-72 (**I**), NEO-73 (**R** + anchors), NEO-74 (recipes).
|
||||
- Pointer to **`docs/manual-qa/NEO-75.md`** as authoritative capstone script.
|
||||
|
||||
### 4. Module alignment (implementation batch or story end)
|
||||
|
||||
When acceptance criteria pass:
|
||||
|
||||
- **`documentation_and_implementation_alignment.md`**: E3.M1/M2/M3 — note **NEO-75 landed**, Epic 3 Slice 5 client capstone complete.
|
||||
- **`epic_03_crafting_economy.md`**: Slice 5 AC checkboxes / status if not already marked.
|
||||
|
||||
### 5. Integration fixes (conditional)
|
||||
|
||||
Run capstone QA on **`main`** wiring before collapse refactor; if any step fails, fix minimal **`main.gd`** / client bug in this branch and note in plan **Decisions**. Expected path: **no logic changes** beyond HUD layout.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/manual-qa/NEO-75.md` | Capstone single-session manual QA (zero Bruno). |
|
||||
| `client/scripts/prototype_economy_hud_section.gd` | Collapse toggle + body visibility for economy HUD block. |
|
||||
| `client/scripts/prototype_economy_hud_section.gd.uid` | Godot UID companion (same commit as `.gd`). |
|
||||
| `client/test/prototype_economy_hud_section_test.gd` | GdUnit: toggle hides/shows body container. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scenes/main.tscn` | Add **`EconomyHudSection`**; reparent inventory/skills/craft panel under collapsible body. |
|
||||
| `client/scripts/main.gd` | Update `@onready` node paths if hierarchy changes; wire collapse only if script needs refs from root. |
|
||||
| `client/README.md` | End-to-end economy loop section + economy HUD toggle note. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Record NEO-75 / Slice 5 capstone complete when shipped. |
|
||||
| `docs/decomposition/epics/epic_03_crafting_economy.md` | Slice 5 prototype client completion note (if needed). |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| **`client/test/prototype_economy_hud_section_test.gd`** (add) | **`# Arrange`**: scene with section + body child. **`# Act`**: toggle **`CheckButton`**. **`# Assert`**: body **`visible`** matches pressed state. |
|
||||
| Existing NEO-72–74 client tests | No change expected — capstone reuses their clients; run suite in CI. |
|
||||
|
||||
**No `main.gd` automated test** — orchestration verified via capstone manual QA (same policy as NEO-72–74).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Loop already works on `main`** | Run capstone QA before collapse refactor; expect pass. | **adopted** — no integration logic changes |
|
||||
| **HUD reparent breaks `@onready` paths** | Update paths in **`main.gd`** + **`craft_recipe_panel.gd`** exports in same commit as **`main.tscn`**. | **adopted** |
|
||||
| **Depleted nodes block repeat QA** | Capstone script uses **one R per node** (11 scrap); delta node has capacity 10 — single pass sufficient. Re-run requires **server restart**. | **adopted** |
|
||||
| **Vertical layout shift after collapse container** | Use **`VBoxContainer`** layout instead of absolute **`offset_top`** for economy block only; combat/debug labels above unchanged. | **adopted** |
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# NEO-76 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-76 |
|
||||
| **Title** | E5M1-01: Prototype AbilityDef catalog + schemas + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-76/e5m1-01-prototype-abilitydef-catalog-schemas-ci |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-01** |
|
||||
| **Branch** | `NEO-76-e5m1-prototype-backlog` |
|
||||
| **Precursor** | [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) — hardcoded allowlist in [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) (unchanged this story) |
|
||||
| **Pattern** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) / [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) — row JSON Schema + catalog envelope + `scripts/validate_content.py` gate |
|
||||
| **Blocks** | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) — server ability catalog load |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Prototype stat freeze** | `baseDamage` / `cooldownSeconds` / `abilityKind` per row? | **Recommended table** — pulse 25 dmg / 3.0s; burst 40 / 5.0; guard 0 / 6.0 utility; dash 0 / 4.0 movement; four pulse casts = 100 HP dummy defeat; pulse cooldown matches NEO-32 global 3s. | **User:** recommended table (adopted below). |
|
||||
| **abilityKind on rows** | Required on all four prototype rows? | **Include on all four** — optional in schema but aids E5M1-04 GET / E5M1-11 HUD without follow-up schema churn. | **Adopted** (bundled with stat freeze). |
|
||||
| **Catalog envelope** | File shape? | `{ "schemaVersion": 1, "abilities": [ … ] }` in `content/abilities/prototype_abilities.json` (mirror items / recipes). | **Adopted** (NEO-50 / NEO-65 precedent). |
|
||||
| **CI constant name** | Slice gate identifier? | **`PROTOTYPE_E5M1_ABILITY_IDS`** — Epic 5 Slice 1; avoids collision with economy Slice 1–4 numbering in `validate_content.py`. | **Adopted**. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **four** frozen `AbilityDef` rows before server load (NEO-77+).
|
||||
|
||||
**In scope (from Linear + [E5M1-01](E5M1-prototype-backlog.md#e5m1-01--prototype-abilitydef-catalog--schemas--ci)):**
|
||||
|
||||
- `content/schemas/ability-def.schema.json`.
|
||||
- `content/abilities/prototype_abilities.json` — four frozen ids (freeze table below).
|
||||
- `scripts/validate_content.py` — schema validation, duplicate `id`, exact four-id allowlist, non-empty `displayName`.
|
||||
- Designer note + **Prototype Slice 1 freeze** table in [E5_M1](../decomposition/modules/E5_M1_CombatRulesEngine.md) + `content/README.md`.
|
||||
- **CT.M1** PR gate paragraph for ability catalogs.
|
||||
- `documentation_and_implementation_alignment.md` E5.M1 row → **In Progress** / NEO-76.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server loader, combat engine, HTTP, Godot (NEO-77+).
|
||||
- Changing [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) or global cooldown behavior (NEO-77 / E5M1-07).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates ability JSON against schema.
|
||||
- [x] Exactly four prototype ability ids; duplicate `id` fails CI.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schema:** [`ability-def.schema.json`](../../content/schemas/ability-def.schema.json) — required `id`, `displayName`, `baseDamage`, `cooldownSeconds`; optional `abilityKind`.
|
||||
- **Catalog:** [`prototype_abilities.json`](../../content/abilities/prototype_abilities.json) — four frozen rows per kickoff stat table.
|
||||
- **CI:** `PROTOTYPE_E5M1_ABILITY_IDS` gate in [`validate_content.py`](../../scripts/validate_content.py).
|
||||
- **Docs:** E5.M1 freeze table, `content/README.md`, CT.M1, alignment register, E5M1-01 backlog checkboxes.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Row schema (`ability-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `id`, `displayName`, `baseDamage`, `cooldownSeconds`. `id` pattern `^[a-z][a-z0-9_]*$`. `displayName` `minLength: 1`. `baseDamage` integer ≥ 0. `cooldownSeconds` number exclusiveMinimum 0 (positive). Optional: `abilityKind` enum `attack` \| `utility` \| `movement` (present on all four prototype rows).
|
||||
|
||||
2. **Catalog file:** `content/abilities/prototype_abilities.json` with `schemaVersion: 1` and four rows from the freeze table.
|
||||
|
||||
3. **Frozen content values (E5M1-01 — kickoff adopted):**
|
||||
|
||||
| `AbilityDef.id` | `displayName` | `baseDamage` | `cooldownSeconds` | `abilityKind` |
|
||||
|-----------------|---------------|--------------|-------------------|---------------|
|
||||
| `prototype_pulse` | Prototype Pulse | 25 | 3.0 | `attack` |
|
||||
| `prototype_burst` | Prototype Burst | 40 | 5.0 | `attack` |
|
||||
| `prototype_guard` | Prototype Guard | 0 | 6.0 | `utility` |
|
||||
| `prototype_dash` | Prototype Dash | 0 | 4.0 | `movement` |
|
||||
|
||||
**Rules:** Do **not** rename `id` values after ship — change `displayName` only, or add a new `id` in a follow-up issue. Relaxing the four-id CI gate requires a new Linear issue. **`prototype_pulse`** at 25 dmg × 4 casts = 100 HP dummy defeat (E5M1 backlog max HP default). **`prototype_pulse`** cooldown 3.0s matches current [`AbilityPrototypeCooldown.GlobalDuration`](../../server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs) until E5M1-07 switches to per-ability catalog cooldowns.
|
||||
|
||||
4. **`validate_content.py`:**
|
||||
- Add `ABILITY_SCHEMA`, `ABILITIES_DIR`; discover `content/abilities/*_abilities.json`.
|
||||
- `_validate_ability_catalogs`: envelope `schemaVersion: 1`, top-level `abilities` array; validate each row against `ability-def.schema.json`; track `seen_ids`; reject duplicates across files.
|
||||
- **`_prototype_e5m1_ability_gate`:** ids must equal `PROTOTYPE_E5M1_ABILITY_IDS` (frozenset of four ids above).
|
||||
- Run ability validation after existing catalogs succeed (no cross-catalog refs in Slice 1).
|
||||
- Require ability schema file in `main()` startup checks; extend module docstring + success summary with ability file + id counts.
|
||||
- Keep `PROTOTYPE_E5M1_ABILITY_IDS` in sync with [E5.M1 freeze table](../decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01) and future NEO-77 server loader.
|
||||
|
||||
5. **Docs:**
|
||||
- `content/README.md` — add `abilities/` table row + E5 Slice 1 freeze paragraph.
|
||||
- `E5_M1_CombatRulesEngine.md` — add **Prototype Slice 1 freeze (E5M1-01)** subsection with id + stat table + CI rules line.
|
||||
- `CT_M1_ContentValidationPipeline.md` — ability catalog PR gate paragraph.
|
||||
- `documentation_and_implementation_alignment.md` — E5.M1 row → **In Progress** / NEO-76 catalog landed.
|
||||
- `E5M1-prototype-backlog.md` — E5M1-01 checkboxes when implementation completes.
|
||||
|
||||
6. **No server/C#/client changes** in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/ability-def.schema.json` | JSON Schema for a single `AbilityDef` row. |
|
||||
| `content/abilities/prototype_abilities.json` | Prototype four-ability catalog (`schemaVersion` + `abilities` array). |
|
||||
| `docs/plans/NEO-76-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Load/validate ability catalogs; duplicate `id`; E5M1 four-id gate; success summary. |
|
||||
| `content/README.md` | Document `content/abilities/`, schema path, E5 Slice 1 freeze (active catalog). |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Freeze table + CI enforcement note; link to plan when complete. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for ability catalog validation. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 / NEO-76 status after catalog lands. |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | E5M1-01 checkboxes + landed note when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| **CI / PR gate** | `.github/workflows/pr-gate.yml` already runs `python scripts/validate_content.py` — extended script is the regression signal. |
|
||||
| **Manual negative cases** | From repo root after implementation: (1) duplicate ability `id` → non-zero exit; (2) remove one frozen id → gate error; (3) break schema (negative `baseDamage` or zero `cooldownSeconds`) → schema error; (4) empty `displayName` → schema error. |
|
||||
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports ability catalog + existing catalogs OK. |
|
||||
|
||||
No dedicated pytest module in-repo today (same as NEO-50 / NEO-65).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Catalog growth** | E5M1 gate requires **exactly** the four frozen ids; expanding the roster needs a follow-up issue to change `PROTOTYPE_E5M1_ABILITY_IDS`. | **adopted** |
|
||||
| **C# vs Python drift** | None until NEO-77; document “keep in sync” constant names in both places when server load lands (mirror NEO-66 / `PrototypeSlice3RecipeCatalogRules`). | **adopted** |
|
||||
| **Runtime still uses global 3s cooldown** | Expected — content lands before E5M1-07 wires per-ability `cooldownSeconds`; pulse 3.0s pre-aligns catalog with NEO-32 behavior. | **adopted** |
|
||||
| **Zero-damage guard/dash before E5M1-06** | Catalog values are forward-looking; cast accept path still has no damage until E5M1-07. Zero-damage rows support E5M1-06 unit tests. | **adopted** |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# NEO-77 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-77 |
|
||||
| **Title** | E5M1-02: Server ability catalog load (fail-fast) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-77/e5m1-02-server-ability-catalog-load-fail-fast |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-02** |
|
||||
| **Branch** | `NEO-77-e5m1-server-ability-catalog-load` |
|
||||
| **Precursor** | [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) — frozen four-ability catalog + CI gates (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) / [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket |
|
||||
| **Blocks** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` + DI (E5M1-03) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **NEO-77 vs NEO-79 (DI scope)** | Ship `IAbilityDefinitionRegistry` on this story, or only load + `AbilityDefinitionCatalog`? | **Strict split** — NEO-77: loader + `AbilityDefinitionCatalog` + eager fail-fast boot; [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) adds injectable `IAbilityDefinitionRegistry` (E5M1-03). Rationale: E5M1 backlog separates E5M1-02/03; mirrors NEO-66/NEO-67. | **User:** `AbilityDefinitionCatalog` singleton only (kickoff). |
|
||||
| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` / `RecipeDefinitionCatalogLoaderTests`. | **User:** loader AAA + host boot tests (kickoff). |
|
||||
| **`PrototypeAbilityRegistry`** | Migrate hotbar/cast allowlist reads this story? | **Leave unchanged** — E5M1-03 explicitly migrates call sites; NEO-77 is load-only. | **User:** unchanged (kickoff). |
|
||||
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + E5M1 four-id gate). Rationale: NEO-51 / NEO-66 precedent; no cross-catalog refs in Slice 1. | **Adopted** — NEO-51 precedent; no separate question. |
|
||||
| **Lookup API on NEO-77** | How does “catalog resolves `AbilityDef` by `id`” land without NEO-79? | **`AbilityDefinitionCatalog.ById`** (read-only map) + `TryGetAbility(string id, out AbilityDefRow row)`; row carries damage + cooldown fields for NEO-79 consumers. | **Adopted** — NEO-66 `RecipeDefinitionCatalog` precedent. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** On host startup, load all `content/abilities/*_abilities.json` catalogs (validated in CI per [NEO-76](NEO-76-implementation-plan.md)), build an in-memory **`AbilityDefinitionCatalog`**, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype E5M1 four-id gate.
|
||||
|
||||
**In scope (from Linear + [E5M1-02](E5M1-prototype-backlog.md#e5m1-02--server-ability-catalog-load-fail-fast)):**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Combat/`.
|
||||
- Configurable abilities directory and schema path (`ContentPathsOptions` extension).
|
||||
- CI-parity validation: `schemaVersion`, row schema (`ability-def`), duplicate `id`, E5M1 four-id allowlist gate.
|
||||
- DI registration of **`AbilityDefinitionCatalog`** singleton; eager resolve in `Program.cs`.
|
||||
- Unit + host startup tests (AAA).
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- **`IAbilityDefinitionRegistry`** / hotbar-cast migration from **`PrototypeAbilityRegistry`** (**NEO-79**, E5M1-03).
|
||||
- HTTP projection (**NEO-78**, E5M1-04), combat resolution (**NEO-81+**).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Server refuses boot when ability catalog invalid (missing dir, no `*_abilities.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M1 gate failure).
|
||||
- [x] `AbilityDefinitionCatalog` resolves prototype rows by ability `id` (e.g. `prototype_pulse`, `prototype_burst`) with `baseDamage`, `cooldownSeconds`, optional `abilityKind`.
|
||||
- [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory.
|
||||
- [x] Success log includes **ability count**, **catalog directory**, and **file count** (Information).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Loader:** [`AbilityDefinitionCatalogLoader`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs) — CI-parity validation via JsonSchema.Net + E5M1 gate.
|
||||
- **Catalog:** [`AbilityDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs) + [`AbilityDefRow`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs) — `TryGetAbility` lookup by id.
|
||||
- **Gate rules:** [`PrototypeE5M1AbilityCatalogRules`](../../server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs) — sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS`.
|
||||
- **DI / startup:** [`AbilityCatalogServiceCollectionExtensions`](../../server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs) + eager resolve in [`Program.cs`](../../server/NeonSprawl.Server/Program.cs).
|
||||
- **Tests:** [`AbilityDefinitionCatalogLoaderTests`](../../server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs) — 14 AAA cases (loader + host).
|
||||
- **Docs:** `server/README.md` ability catalog section; E5.M1 module doc + alignment register + E5M1 backlog E5M1-02 checkboxes.
|
||||
- **Unchanged:** [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) — migration deferred to NEO-79.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`Game/Combat/` row DTO** — `AbilityDefRow` (`Id`, `DisplayName`, `BaseDamage`, `CooldownSeconds`, optional `AbilityKind` enum or string).
|
||||
|
||||
2. **`AbilityDefinitionCatalogLoader.Load(abilitiesDirectory, schemaPath, logger)`** — single-pass load mirroring `validate_content.py` `_validate_ability_catalogs` + `_prototype_e5m1_ability_gate`:
|
||||
- Enumerate `*_abilities.json` (top-level only), ordered by path.
|
||||
- Build **JsonSchema.Net** evaluator from `ability-def.schema.json`.
|
||||
- Per file: parse JSON; require `schemaVersion == 1` and top-level `abilities` array.
|
||||
- Per row: validate against schema; on clean rows track `id`; build `AbilityDefRow`; reject duplicate `id` across files.
|
||||
- Run **`PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError`** (mirror `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate` in `scripts/validate_content.py`).
|
||||
- Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Ability catalog validation failed:`.
|
||||
|
||||
3. **`AbilityDefinitionCatalog`** — immutable snapshot: `AbilitiesDirectory`, `ById`, `DistinctAbilityCount`, `CatalogJsonFileCount`; `TryGetAbility(id, out AbilityDefRow?)` for lookups.
|
||||
|
||||
4. **`AbilityCatalogPathResolution`** — `TryDiscoverAbilitiesDirectory`, `ResolveAbilitiesDirectory`, `ResolveAbilityDefSchemaPath` (parent `content/schemas/` layout when unset).
|
||||
|
||||
5. **`PrototypeE5M1AbilityCatalogRules`** — frozen four ability ids; sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate`. Ids: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst` (same set as [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) — unchanged this story).
|
||||
|
||||
6. **`AbilityCatalogServiceCollectionExtensions.AddAbilityDefinitionCatalog`** — bind `ContentPathsOptions`; register **`AbilityDefinitionCatalog`** singleton factory that resolves paths and calls loader. No dependency on item/skill catalogs (Slice 1 abilities have no cross-ref fields).
|
||||
|
||||
7. **`Program.cs`** — `AddAbilityDefinitionCatalog(configuration)` alongside existing catalog registrations; after `Build()`, `GetRequiredService<AbilityDefinitionCatalog>()` with other eager catalog resolves.
|
||||
|
||||
8. **Tests** — temp-dir fixtures copying repo `ability-def.schema.json`; valid four-ability JSON matching [prototype_abilities.json](../../content/abilities/prototype_abilities.json); negative cases (duplicate id, wrong/missing E5M1 roster, bad `baseDamage` / `cooldownSeconds`, empty `displayName`, `schemaVersion` ≠ 1, missing dir/schema, no `*_abilities.json`, invalid JSON); `InMemoryWebApplicationFactory` host test + `WebApplicationFactory` with empty abilities dir fails startup.
|
||||
|
||||
9. **Test factories** — pin `Content:AbilitiesDirectory` in `InMemoryWebApplicationFactory` and other `WebApplicationFactory` subclasses that already pin recipes/items (same pattern as NEO-66).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs` | Load-time `AbilityDef` row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs` | In-memory catalog + `TryGetAbility`. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs` | Disk I/O, schema validation, E5M1 gate. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs` | Frozen four ids gate (sync comment to Python). |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | `AddAbilityDefinitionCatalog` DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs` | Repo `content/abilities` + schema discovery for tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. |
|
||||
| `docs/plans/NEO-77-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `AbilitiesDirectory`, `AbilityDefSchemaPath` under `Content` section. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `AbilityDefinitionCatalog` at startup. |
|
||||
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:AbilitiesDirectory` / schema path keys for override documentation (mirror other catalogs). |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:AbilitiesDirectory` to discovered repo path so existing tests keep booting. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Pin abilities directory (same as recipes/items). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. |
|
||||
| `server/README.md` | New **Ability catalog (`content/abilities`, NEO-77)** section (config keys, discovery, fail-fast, E5M1 parity with CI). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — note NEO-77 server load when implementation completes. |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Status line — E5M1-02 server load landed when complete. |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | E5M1-02 checkboxes when implementation completes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs` | **Unit:** valid four-ability prototype catalog loads; `abilities` not array; schema violation (negative `baseDamage`, zero `cooldownSeconds`, empty `displayName`); duplicate `id` across files; E5M1 gate failure (missing id + extra/wrong id); `schemaVersion` ≠ 1; missing dir/schema; no `*_abilities.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `AbilityDefinitionCatalog` count 4; `WebApplicationFactory` with empty abilities dir fails startup with actionable message. |
|
||||
|
||||
**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA loader/host tests above.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Constants drift vs Python** | Comment on `PrototypeE5M1AbilityCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate`. | **adopted** |
|
||||
| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:AbilitiesDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** |
|
||||
| **Registry consumers** | Hotbar/cast/combat use `IAbilityDefinitionRegistry` in NEO-79+; this story only exposes `AbilityDefinitionCatalog` for boot + direct test lookup. **`PrototypeAbilityRegistry` unchanged.** | **adopted** |
|
||||
| **Global cooldown still active** | Expected — catalog lands before E5M1-07 wires per-ability `cooldownSeconds`; load does not change runtime cast behavior. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`AbilityDefinitionCatalog` singleton only** — defer `IAbilityDefinitionRegistry` and `PrototypeAbilityRegistry` migration to NEO-79 (user confirmed kickoff).
|
||||
- **In-process validation** + **loader and host boot tests** per NEO-51 / NEO-66 precedent.
|
||||
- **No cross-catalog checks** — ability rows have no item/skill refs in Slice 1; loader has no catalog dependencies.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# NEO-78 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-78 |
|
||||
| **Title** | E5M1-04: GET /game/world/ability-definitions |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-78/e5m1-04-get-gameworldability-definitions |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-04** |
|
||||
| **Branch** | `NEO-78-get-world-ability-definitions` |
|
||||
| **Precursor** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` + DI (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — `GET /game/world/recipe-definitions`; [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — item definitions world GET |
|
||||
| **Client counterpart** | None (server-only); optional client enrichment in [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) via this HTTP route |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-78.md`? | **Yes** — NEO-68 pattern for world GET HTTP. | **User:** skip — manual QA only when there are client-facing changes; this story is server-only (Bruno + automated tests suffice). |
|
||||
| **Optional `abilityKind`** | Emit when row has no kind? | **Omit property when null** — NEO-68 “loaded fields only”; all four prototype rows have a kind today. | **User:** omit when null. |
|
||||
|
||||
No other blocking decisions — route path, envelope (`schemaVersion` **1**, **`abilities`** array), row fields, registry injection, and frozen four-id order are specified in [E5M1-04](E5M1-prototype-backlog.md#e5m1-04--get-gameworldability-definitions) and [E5.M1 freeze box](../decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose a stable, read-only JSON endpoint that returns all four loaded prototype ability definitions for Bruno, tooling, and optional client preview ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)) — backed by **`IAbilityDefinitionRegistry`** without duplicating catalog truth.
|
||||
|
||||
**In scope (from Linear + [E5M1-04](E5M1-prototype-backlog.md#e5m1-04--get-gameworldability-definitions)):**
|
||||
|
||||
- **`GET /game/world/ability-definitions`** with versioned envelope (`schemaVersion` **1**, **`abilities`** array).
|
||||
- Row fields: **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, **`abilityKind`** (omit when null).
|
||||
- Abilities ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
|
||||
- Bruno folder `bruno/neon-sprawl-server/ability-definitions/`.
|
||||
- `server/README.md` section.
|
||||
- API integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Godot HUD / client wiring ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)).
|
||||
- Combat resolution, damage application, cooldown commit ([NEO-80+](E5M1-prototype-backlog.md)).
|
||||
- Per-player ability state or loadout mutation.
|
||||
- `docs/manual-qa/NEO-78.md` (server-only; no client-facing changes per kickoff).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] GET returns all **four** prototype abilities with **`schemaVersion`** **1** and stable field names (`id`, `displayName`, `baseDamage`, `cooldownSeconds`, `abilityKind` when present).
|
||||
- [x] Bruno happy path documents response shape and runs against dev server.
|
||||
- [x] Automated API tests (AAA) assert four ids in registry order and spot-check metadata.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`GET /game/world/ability-definitions`** — parameterless read; inject **`IAbilityDefinitionRegistry`** only (not **`AbilityDefinitionCatalog`**).
|
||||
|
||||
2. **Response shape:** Top-level **`schemaVersion`** (`AbilityDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`abilities`** array. Each row maps from **`AbilityDefRow`**: **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, **`abilityKind`**. Use **`JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)`** on **`abilityKind`** so absent kinds are omitted from JSON.
|
||||
|
||||
3. **Ordering:** Build list from **`registry.GetDefinitionsInIdOrder()`** (already ordinal by id). Tests assert exact id sequence (frozen four from [E5.M1 freeze box](../decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01)):
|
||||
|
||||
`prototype_burst`, `prototype_dash`, `prototype_guard`, `prototype_pulse`
|
||||
|
||||
(ordinal sort of the four ids — same order as **`AbilityDefinitionRegistryTests`** / catalog keys).
|
||||
|
||||
4. **Implementation:** New **`AbilityDefinitionsWorldApi`** + **`AbilityDefinitionsListDtos.cs`** in `Game/Combat/` (mirror [`RecipeDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs)). Wire **`app.MapAbilityDefinitionsWorldApi()`** from **`Program.cs`** next to other world definition APIs (after **`MapRecipeDefinitionsWorldApi()`**).
|
||||
|
||||
5. **Bruno:** `bruno/neon-sprawl-server/ability-definitions/` with **`Get ability definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`abilities.length === 4`**, ascending id order, **exact frozen-four registry sequence**, spot-check **`prototype_pulse`** (`baseDamage` 25, `cooldownSeconds` 3.0, `abilityKind` `attack`) and **`prototype_burst`** (`baseDamage` 40, `cooldownSeconds` 5.0, `abilityKind` `attack`).
|
||||
|
||||
6. **Docs:** Update **`server/README.md`** ability-catalog section with GET + curl example. Update [E5_M1](E5_M1_CombatRulesEngine.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E5.M1 row when landed.
|
||||
|
||||
### Expected prototype row values (from content)
|
||||
|
||||
| `id` | `displayName` | `baseDamage` | `cooldownSeconds` | `abilityKind` |
|
||||
|------|---------------|--------------|-------------------|---------------|
|
||||
| `prototype_pulse` | Prototype Pulse | 25 | 3.0 | `attack` |
|
||||
| `prototype_guard` | Prototype Guard | 0 | 6.0 | `utility` |
|
||||
| `prototype_dash` | Prototype Dash | 0 | 4.0 | `movement` |
|
||||
| `prototype_burst` | Prototype Burst | 40 | 5.0 | `attack` |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs` | Versioned response + row DTOs for JSON serialization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, four ids in order, spot-check rows. |
|
||||
| `bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru` | Manual / Bruno verification against dev server. |
|
||||
| `bruno/neon-sprawl-server/ability-definitions/folder.bru` | Bruno folder metadata (match `recipe-definitions`). |
|
||||
| `docs/plans/NEO-78-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapAbilityDefinitionsWorldApi()` alongside other game world definition APIs. |
|
||||
| `server/README.md` | Document `GET /game/world/ability-definitions`, curl example, links to plan/Bruno. |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | **Related implementation slices** — HTTP read model bullet (NEO-78). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — note NEO-78 HTTP projection when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs` | **Integration:** `GET /game/world/ability-definitions` via **`InMemoryWebApplicationFactory`**; **`ReadFromJsonAsync`**; assert **`schemaVersion` 1**; **`abilities`** count **4**; ordered **`id`** list equals frozen four in ordinal id order; spot-check **`prototype_pulse`** and **`prototype_burst`** damage/cooldown/kind. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
Bruno scripts complement automated tests for manual dev-server verification. No `docs/manual-qa/NEO-78.md` (server-only per kickoff).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Linear blockedBy NEO-79** | Branch from **`main`** where NEO-79 is merged (confirmed at kickoff; branch fast-forwarded). | **adopted** |
|
||||
| **Optional fields later** | When content adds new optional ability-def fields, bump **`schemaVersion`** or extend v1 in a follow-up issue. | **deferred** |
|
||||
| **Client not in this story** | Cross-link **NEO-85** in plan; Bruno is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | **adopted** |
|
||||
| **`cooldownSeconds` JSON type** | Emit as JSON **number** (double) matching content schema; tests assert numeric equality. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **No manual QA doc** — server-only HTTP; Bruno + automated tests (user confirmed).
|
||||
- **`abilityKind` omitted when null** — v1 contract emits loaded fields only (user confirmed).
|
||||
- **Registry-only injection** — HTTP layer depends on **`IAbilityDefinitionRegistry`**, not **`AbilityDefinitionCatalog`**.
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`AbilityDefinitionsWorldApi`** + **`AbilityDefinitionsListDtos`** registered via **`MapAbilityDefinitionsWorldApi()`** in **`Program.cs`**; injects **`IAbilityDefinitionRegistry`** only.
|
||||
- **`AbilityDefinitionsWorldApiTests`** — integration test for schema v1, frozen-four id order, **`prototype_pulse`** / **`prototype_burst`** spot-checks.
|
||||
- **Bruno:** `bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru` with response-shape tests.
|
||||
- **`server/README.md`**, **E5.M1** module doc, **alignment register**, and **E5M1 backlog** updated.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# NEO-79 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-79 |
|
||||
| **Title** | E5M1-03: `IAbilityDefinitionRegistry` + DI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-79/e5m1-03-iabilitydefinitionregistry-di |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-03** |
|
||||
| **Branch** | `NEO-79-iabilitydefinitionregistry-di` |
|
||||
| **Precursor** | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) — fail-fast `AbilityDefinitionCatalog` load (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) / [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) — thin registry adapter over startup catalog + DI; strict split after NEO-77 loader |
|
||||
| **Blocks** | [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) — GET world ability-definitions; [NEO-80+](E5M1-prototype-backlog.md) — combat engine consumers |
|
||||
| **Client counterpart** | None (server-only); optional client enrichment in [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) via NEO-78 HTTP |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Enumeration API** | Include `GetDefinitionsInIdOrder()` now? | **Yes** — mirror `IRecipeDefinitionRegistry`; [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) `GET /game/world/ability-definitions` needs ordered defs without reaching into `AbilityDefinitionCatalog`. | **User:** include enumeration. |
|
||||
| **Lookup naming** | `TryGetDefinition` vs `TryGetAbility`? | **`TryGetDefinition(string? abilityId, …)`** — same naming as item/skill/recipe registries; catalog keeps `TryGetAbility` for direct catalog access. | **User:** `TryGetDefinition`. |
|
||||
| **Hotbar/cast migration** | How replace `PrototypeAbilityRegistry.TryNormalizeKnown`? | **`TryNormalizeKnown` on `IAbilityDefinitionRegistry`** — trim + lowercase + catalog lookup; inject into hotbar/cast APIs. Preserves today’s deny behavior (`unknown_ability`) without duplicating normalize in two endpoints. | **User:** registry `TryNormalizeKnown`. |
|
||||
| **`PrototypeAbilityRegistry`** | Keep static allowlist or slim down? | **Keep id constant strings only** — remove `HashSet` allowlist and `TryNormalizeKnown`; tests/fixtures keep `PrototypePulse` etc. | **User:** constants only. |
|
||||
| **Program.cs eager resolve** | Eager-resolve `IAbilityDefinitionRegistry` at boot? | **Omit** — `AbilityDefinitionCatalog` is already eager-resolved in `Program.cs` (NEO-77); registry is a thin adapter (NEO-67 default). | **Adopted** — NEO-67 precedent; no separate question. |
|
||||
| **Runtime validation** | Re-validate abilities in registry? | **No** — catalog load is fail-fast (NEO-77); registry delegates to loaded rows only. | **Adopted** — NEO-67 precedent. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Provide **`IAbilityDefinitionRegistry`** backed by the startup-loaded **`AbilityDefinitionCatalog`**: resolve by stable **`abilityId`**, enumerate definitions in id order, and expose **`TryNormalizeKnown`** for hotbar/cast allowlist validation. Register in DI and migrate **`HotbarLoadoutApi`** / **`AbilityCastApi`** off the static allowlist so unknown ids deny exactly as today while known ids resolve **`baseDamage`** / **`cooldownSeconds`** from content.
|
||||
|
||||
**In scope (from Linear + [E5M1-03](E5M1-prototype-backlog.md#e5m1-03--iabilitydefinitionregistry--di)):**
|
||||
|
||||
- `IAbilityDefinitionRegistry` + `AbilityDefinitionRegistry` thin adapter over `AbilityDefinitionCatalog`.
|
||||
- DI registration in `AddAbilityDefinitionCatalog`.
|
||||
- Unit tests (AAA): known-id lookup with damage + cooldown; unknown-id miss; `TryNormalizeKnown` trim/case behavior; enumeration order; host resolves registry from DI.
|
||||
- Migrate hotbar/cast call sites from `PrototypeAbilityRegistry.TryNormalizeKnown` to injected registry.
|
||||
- Slim `PrototypeAbilityRegistry` to id constants only.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- HTTP GET route ([NEO-78](https://linear.app/neon-sprawl/issue/NEO-78)).
|
||||
- Damage application, combat engine, cooldown commit from catalog ([NEO-80+](E5M1-prototype-backlog.md)).
|
||||
- Changing loader, E5M1 gate, or catalog load semantics (NEO-77).
|
||||
- Godot / client changes.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Unknown ability id fails hotbar/cast validation same as today (`unknown_ability`, no behavior regression).
|
||||
- [x] All four prototype ids resolve with expected **`baseDamage`** / **`cooldownSeconds`** via `TryGetDefinition`.
|
||||
- [x] DI resolves `IAbilityDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
|
||||
- [x] Unit tests (AAA): lookup for known prototype ids; unknown id returns false without throwing; `TryNormalizeKnown` accepts trimmed/case-variant input for catalog ids; `GetDefinitionsInIdOrder` returns four rows ordered by `id`.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`IAbilityDefinitionRegistry`** — mirror [`IRecipeDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Crafting/IRecipeDefinitionRegistry.cs):
|
||||
- `TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition)` — null and unknown ids return false without throwing; exact ordinal match on catalog keys.
|
||||
- `TryNormalizeKnown(string rawAbilityId, [NotNullWhen(true)] out string normalized)` — trim, lowercase, empty/whitespace → false; success when normalized id exists in catalog (replaces static allowlist semantics).
|
||||
- `GetDefinitionsInIdOrder()` — all rows ordered by `AbilityDefRow.Id` (ordinal).
|
||||
- XML remarks: combat engine ([NEO-81+](../../docs/plans/E5M1-prototype-backlog.md)) and HTTP read model ([NEO-78](https://linear.app/neon-sprawl/issue/NEO-78)) should depend on this interface, not `AbilityDefinitionCatalog`.
|
||||
|
||||
2. **`AbilityDefinitionRegistry`** — primary-constructor adapter over `AbilityDefinitionCatalog` (same pattern as [`RecipeDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionRegistry.cs)); precompute ordered list at construction.
|
||||
|
||||
3. **DI** — extend [`AddAbilityDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs) to register `IAbilityDefinitionRegistry` → `AbilityDefinitionRegistry` after catalog singleton. No change to `Program.cs` eager-resolve (catalog only).
|
||||
|
||||
4. **Hotbar/cast migration** — inject `IAbilityDefinitionRegistry` into:
|
||||
- [`HotbarLoadoutApi`](../../server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs) POST handler — replace `PrototypeAbilityRegistry.TryNormalizeKnown` with `registry.TryNormalizeKnown`.
|
||||
- [`AbilityCastApi`](../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) POST handler — same replacement for request `abilityId` validation.
|
||||
|
||||
5. **`PrototypeAbilityRegistry`** — retain only public const id strings (`PrototypePulse`, etc.); remove static `HashSet` and `TryNormalizeKnown`. Update class summary to point at `IAbilityDefinitionRegistry`.
|
||||
|
||||
6. **Docs** — update [`server/README.md`](../../server/README.md) ability-catalog section (registry is preferred lookup; hotbar/cast migrated). Optional one-line E5M1 backlog note when implementation lands.
|
||||
|
||||
7. **Tests** — new `AbilityDefinitionRegistryTests.cs` mirroring `RecipeDefinitionRegistryTests`: in-memory catalog helper, loader-backed prototype fixture via `AbilityCatalogTestPaths` + repo `prototype_abilities.json`, host DI test asserting four prototype rows with expected damage/cooldown from [E5.M1 freeze box](../decomposition/modules/E5_M1_CombatRulesEngine.md). Existing hotbar/cast API tests should remain green without changing expected deny/accept behavior.
|
||||
|
||||
### Expected prototype lookup values (from content)
|
||||
|
||||
| `id` | `baseDamage` | `cooldownSeconds` |
|
||||
|------|--------------|-------------------|
|
||||
| `prototype_pulse` | 25 | 3.0 |
|
||||
| `prototype_guard` | 0 | 6.0 |
|
||||
| `prototype_dash` | 0 | 4.0 |
|
||||
| `prototype_burst` | 40 | 5.0 |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate; remarks for NEO-78/81+ callers. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs` | Singleton adapter over `AbilityDefinitionCatalog`. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs` | AAA unit + host DI tests (mirror `RecipeDefinitionRegistryTests`). |
|
||||
| `docs/plans/NEO-79-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | Register `IAbilityDefinitionRegistry` → `AbilityDefinitionRegistry` after catalog singleton. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs` | Tighten summary to reference `IAbilityDefinitionRegistry` (replace NEO-79 placeholder). |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs` | Inject registry; migrate allowlist validation to `TryNormalizeKnown`. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject registry; migrate request ability validation to `TryNormalizeKnown`. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs` | Remove static allowlist/`TryNormalizeKnown`; keep id const strings for tests. |
|
||||
| `server/README.md` | Document `IAbilityDefinitionRegistry` as preferred lookup; note hotbar/cast migration. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` for `prototype_pulse` / `prototype_burst` with expected `BaseDamage` + `CooldownSeconds`; unknown id + null → false; `TryNormalizeKnown` with leading/trailing space and mixed case; empty/whitespace → false; `GetDefinitionsInIdOrder` count 4 + ordinal id order. **Loader fixture:** copy repo `prototype_abilities.json` via `AbilityCatalogTestPaths`, load catalog, assert all four freeze-box values. **Host:** `InMemoryWebApplicationFactory` resolves `IAbilityDefinitionRegistry` from DI; lookup + enumeration smoke. |
|
||||
| Existing `HotbarLoadoutApiTests`, `AbilityCastApiTests`, `HotbarLoadoutPersistenceIntegrationTests`, `CooldownSnapshotApiTests` | **Regression:** no test changes expected if behavior preserved; run full AbilityInput test suite after migration. |
|
||||
|
||||
No manual QA for this story (server-only DI + internal migration; HTTP proof is NEO-78). **Optional Bruno deny smokes added:** `hotbar-loadout/Post loadout unknown ability deny.bru`, `ability-cast/Post cast unknown ability deny.bru` — HTTP regression for `unknown_ability` after catalog-backed validation.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Case-only mismatch** — client sends id not in catalog after normalize | **Deny `unknown_ability`** — same as today; catalog ids are lowercase frozen strings. | **adopted** |
|
||||
| **Dual lookup surfaces** — `TryGetDefinition` vs `TryNormalizeKnown` | **Keep both** — hotbar/cast need normalize-only; combat/HTTP need row lookup without re-implementing trim rules. | **adopted** |
|
||||
| **Test const indirection** | **Keep `PrototypeAbilityRegistry` const strings** in existing tests — no mass string-literal churn. | **adopted** |
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`IAbilityDefinitionRegistry`** + **`AbilityDefinitionRegistry`** registered in **`AddAbilityDefinitionCatalog`**; catalog-only eager resolve unchanged in **`Program.cs`**.
|
||||
- **`HotbarLoadoutApi`** / **`AbilityCastApi`** inject registry and call **`TryNormalizeKnown`**; **`PrototypeAbilityRegistry`** reduced to id constants.
|
||||
- **10** new AAA tests in **`AbilityDefinitionRegistryTests`**; **31** existing AbilityInput tests unchanged and green (41 total in filtered run).
|
||||
- **Bruno:** optional `unknown_ability` deny smokes under `hotbar-loadout/` and `ability-cast/` (see Tests section).
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# NEO-80 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-80 |
|
||||
| **Title** | E5M1-05: Combat entity health store + dummy init |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-80/e5m1-05-combat-entity-health-store-dummy-init |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-05** |
|
||||
| **Branch** | `NEO-80-combat-entity-health-store` |
|
||||
| **Precursor** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` + DI (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — lazy in-memory instance store + per-id locking; [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — dedicated `Add*Store()` DI extension |
|
||||
| **Blocks** | [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) — `CombatOperations.TryResolve` + `CombatResult` |
|
||||
| **Client counterpart** | None (server-only internal store); HP visibility lands in [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) GET combat-targets + [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client HUD |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Max HP source** | Content JSON vs combat constant? | **`PrototypeCombatConstants.MaxPrototypeTargetHp = 100`** in `Game/Combat/` — no target content schema in Slice 1; matches E5M1 backlog default (four × 25 dmg pulse = defeat). | **User:** combat constants. |
|
||||
| **Init timing** | Lazy first access vs eager startup seed? | **Lazy init** on first `TryGet` / `TryApplyDamage` for known registry ids — backlog explicit; mirrors NEO-61 resource node lazy rows. | **User:** lazy first access. |
|
||||
| **Target id gate** | Registry-only vs generic any-id store? | **`PrototypeTargetRegistry` ids only** (`prototype_target_alpha`, `prototype_target_beta`) — E5M1 prototype scope; prevents arbitrary entity pollution. | **User:** registry only. |
|
||||
| **ApplyDamage shape** | Out snapshot vs mutation outcome struct? | **`bool TryApplyDamage(targetId, damage, out CombatEntityHealthSnapshot)`** — snapshot carries `targetId`, `maxHp`, `currentHp`, `defeated`; NEO-81 wraps with combat deny logic. | **User:** TryApplyDamage + out snapshot. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Provide a server-owned, in-memory HP store for prototype combat dummies keyed by **`PrototypeTargetRegistry`** target id. Lazy-initialize **`prototype_target_alpha`** and **`prototype_target_beta`** to **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** on first read or damage. Support deterministic damage application with HP floored at zero and a **`defeated`** flag until explicit reset.
|
||||
|
||||
**In scope (from Linear + [E5M1-05](E5M1-prototype-backlog.md#e5m1-05--combat-entity-health-store--prototype-dummy-init)):**
|
||||
|
||||
- `ICombatEntityHealthStore` with **`TryGet`**, **`TryApplyDamage`**, **`TryResetToFull`** for prototype registry targets.
|
||||
- `InMemoryCombatEntityHealthStore` — thread-safe, per-id locking (NEO-61 pattern).
|
||||
- `CombatEntityHealthSnapshot` readonly struct (`targetId`, `maxHp`, `currentHp`, `defeated`).
|
||||
- `PrototypeCombatConstants.MaxPrototypeTargetHp = 100`.
|
||||
- DI via **`AddCombatEntityHealthStore()`** in `Program.cs`.
|
||||
- Unit tests (AAA): lazy init, damage, floor at zero, defeated persistence, reset, unknown-id deny, alpha/beta independence.
|
||||
- Document in-memory prototype reset policy in `server/README.md` (server restart clears HP; **`TryResetToFull`** for tests / future fixtures).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- HTTP routes ([NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET).
|
||||
- `CombatOperations.TryResolve` ([NEO-81](https://linear.app/neon-sprawl/issue/NEO-81)).
|
||||
- Cast wiring / `AbilityCastResponse` resolution ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)).
|
||||
- Player HP, Postgres persistence, NPC spawn ([E5.M2](E5M2_NpcAiAndBehaviorProfiles.md)).
|
||||
- Godot / client changes.
|
||||
- Bruno (no HTTP surface in this story).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Damaging **`prototype_target_alpha`** reduces stored HP; HP cannot go below zero.
|
||||
- [x] **`defeated`** is true when **`currentHp == 0`** and remains true on further damage until reset.
|
||||
- [x] **`prototype_target_beta`** initializes independently with the same max HP on first access.
|
||||
- [x] Unknown target id returns **`false`** from **`TryGet`** / **`TryApplyDamage`** without throwing.
|
||||
- [x] In-memory prototype reset policy documented (server restart; **`TryResetToFull`** for tests).
|
||||
- [x] DI resolves **`ICombatEntityHealthStore`** at host startup (test via **`InMemoryWebApplicationFactory`**).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`PrototypeCombatConstants`** — `public const int MaxPrototypeTargetHp = 100;` in `Game/Combat/`. XML remarks: four **`prototype_pulse`** casts (25 dmg) defeat one dummy; aligns with [E5.M1 freeze box](../decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01).
|
||||
|
||||
2. **`CombatEntityHealthSnapshot`** — readonly record struct: **`TargetId`**, **`MaxHp`**, **`CurrentHp`**, **`Defeated`** (`CurrentHp <= 0`). Used by store read and mutation methods.
|
||||
|
||||
3. **`ICombatEntityHealthStore`** — contract for NEO-81+ consumers:
|
||||
- **`TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot)`** — normalize id (trim + lowercase); reject empty/unknown registry ids; **lazy-init** row at max HP on first successful read.
|
||||
- **`TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot)`** — same id gate + lazy init; **`damage`** must be **≥ 0** (negative → false); subtract damage, floor **`currentHp`** at **0**; set **`defeated`** when **`currentHp == 0`** (idempotent on overkill).
|
||||
- **`TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot)`** — registry id only; set **`currentHp = maxHp`**, **`defeated = false`**; creates row if absent.
|
||||
|
||||
4. **`InMemoryCombatEntityHealthStore`** — `ConcurrentDictionary<string, int>` for current HP + per-id locks (mirror [`InMemoryResourceNodeInstanceStore`](../../server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs)). Private **`EnsurePrototypeTarget(string normalizedId)`** validates **`PrototypeTargetRegistry.TryGet`** and lazy-inits HP to **`MaxPrototypeTargetHp`**.
|
||||
|
||||
5. **DI** — **`CombatEntityHealthServiceCollectionExtensions.AddCombatEntityHealthStore()`** registers **`ICombatEntityHealthStore` → `InMemoryCombatEntityHealthStore`** singleton. Chained from **`AddAbilityDefinitionCatalog`** after ability registry registration. **No Postgres** in Slice 1 (backlog in-memory policy).
|
||||
|
||||
6. **Prototype reset policy (document):**
|
||||
- **Production prototype:** HP rows are **not persisted**; **server process restart** resets all dummies to uninitialized (lazy full HP on next access).
|
||||
- **`TryResetToFull`:** explicit test/fixture hook to revive a dummy without restart; not exposed over HTTP in this story.
|
||||
|
||||
7. **NEO-81 handoff:** Store does **not** deny damage to already-defeated targets — HP stays **0**, **`defeated`** stays **true**. Structured **`target_defeated`** deny belongs in **`CombatOperations.TryResolve`** (NEO-81).
|
||||
|
||||
### Expected prototype values
|
||||
|
||||
| Target id | Max HP | Init policy |
|
||||
|-----------|--------|-------------|
|
||||
| `prototype_target_alpha` | 100 | Lazy on first `TryGet` / `TryApplyDamage` |
|
||||
| `prototype_target_beta` | 100 | Lazy on first `TryGet` / `TryApplyDamage` |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs` | Frozen **`MaxPrototypeTargetHp = 100`** for prototype dummies. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatEntityHealthSnapshot.cs` | Readonly snapshot struct returned by store methods. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs` | Public contract: **`TryGet`**, **`TryApplyDamage`**, **`TryResetToFull`**. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs` | Thread-safe in-memory implementation with registry gate + lazy init. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs` | **`AddCombatEntityHealthStore()`** DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs` | AAA unit + host DI tests. |
|
||||
| `docs/plans/NEO-80-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | Chain **`AddCombatEntityHealthStore()`** after ability registry registration (avoids `Program.cs` change for DI-only story). |
|
||||
| `server/README.md` | Document combat entity health store, max HP constant, lazy init, in-memory reset policy, and NEO-81 consumer note. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs` | **Lazy init:** first **`TryGet`** on alpha → **`currentHp` 100**, **`defeated` false**, **`maxHp` 100**. **Damage:** **`TryApplyDamage`** 25 → 75 HP; repeated until 0 → **`defeated` true**. **Floor:** overkill damage keeps **`currentHp` 0**, **`defeated` true**. **Reset:** **`TryResetToFull`** → 100 HP, **`defeated` false**. **Independence:** beta lazy-inits separately. **Deny:** unknown id, empty id, negative damage → false. **Host:** **`InMemoryWebApplicationFactory`** resolves **`ICombatEntityHealthStore`** from DI. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
No Bruno or manual QA for this story (server-only internal store; no HTTP or client-facing change).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Defeated-target damage at store layer** | **Allow apply** (HP stays 0); NEO-81 denies re-hit at combat ops layer. | **adopted** |
|
||||
| **Healing / negative damage** | **Reject negative `damage`** with false; healing is out of Slice 1 scope. | **adopted** |
|
||||
| **Persistence later** | **Defer Postgres** to a follow-up when E5.M2+ needs durable NPC HP; interface stays swappable. | **deferred** |
|
||||
| **Test factory isolation** | Each test uses fresh **`InMemoryWebApplicationFactory`** (`using`) → fresh singleton store; no cross-test bleed. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Max HP** lives in **`PrototypeCombatConstants`** (not content JSON).
|
||||
- **Lazy init** on first access for **`PrototypeTargetRegistry`** ids only.
|
||||
- **`TryApplyDamage`** returns **`bool`** + **`out CombatEntityHealthSnapshot`** (not mutation outcome struct).
|
||||
- **No HTTP** in NEO-80; combat-target snapshot is [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83).
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** registered via **`AddCombatEntityHealthStore()`** chained from **`AddAbilityDefinitionCatalog`**.
|
||||
- **`PrototypeCombatConstants.MaxPrototypeTargetHp = 100`**; lazy init gated to **`PrototypeTargetRegistry`** ids.
|
||||
- **14** AAA tests in **`CombatEntityHealthStoreTests`** (all green); code review follow-up added deny-path and normalization coverage.
|
||||
- **`server/README.md`** — combat entity health section with init/damage/reset policy.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Code review — NEO-74 client craft UI + recipe list
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-74-e3m2-client-craft-ui-recipe-list` · commits `dff58ad`–`c16b610` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Review feedback addressed (recipe-sync failure HUD, test `.uid` companions, unused `_scroll` binding).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve** — implementation matches the adopted plan and E3S5-03 acceptance criteria; GdUnit suites pass. Follow-up wired **`recipes_sync_failed`** HUD and added test **`.uid`** companions.
|
||||
|
||||
## Summary
|
||||
|
||||
The branch adds **`recipe_definitions_client.gd`**, **`craft_client.gd`**, and **`craft_recipe_panel.gd`**, wires them from **`main.gd`** with **`CraftFeedbackLabel`**, a scrollable eight-recipe panel, **refine** row on **`SkillProgressionLabel`**, and post-success inventory + skill refresh (deny leaves bag unchanged). Three GdUnit suites cover POST payload/parse, recipe GET parse, and refresh policy. Manual QA, plan reconciliation, E3.M2 module note, alignment register, and E3S5 backlog checkboxes are updated. Design follows NEO-72/73/31 patterns (thin HTTP clients, `_busy` guard, injectable transport, authority config copy, dedicated feedback label). HTTP handlers correctly use **`PackedStringArray`** headers (NEO-72 lesson applied).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-74-implementation-plan.md`](../plans/NEO-74-implementation-plan.md) | **Matches** — kickoff decisions (POST **`outputsGranted`** copy, dedicated **`CraftFeedbackLabel`**, left scroll panel, qty **1**, deny refresh policy) reflected; acceptance checklist and reconciliation complete. |
|
||||
| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-03** | **Matches** — acceptance criteria checked; scope/out-of-scope honored; does not claim capstone (NEO-75) complete. |
|
||||
| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — NEO-74 client slice **landed** note added. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row notes NEO-74 landed; NEO-75 cited as remaining Slice 5 client work. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — client sends craft **intent** via POST; inventory/skill display refreshed from server GET after success. |
|
||||
| [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) | **Matches** — deny/success/refine XP walkthrough; optional **`inventory_full`** spot-check. |
|
||||
| [`client/README.md`](../../client/README.md) craft UI section | **Matches** — endpoints, HUD labels, refresh policy; automated test Scope lists NEO-74 suites. |
|
||||
| Full-stack epic decomposition | **Matches** — E3S5-03 **client** counterpart for server NEO-68/NEO-70; Godot manual QA present. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Wire `recipes_sync_failed` in `main.gd`** — `RecipeDefinitionsClient` emits **`recipes_sync_failed`** (tested in GdUnit) but **`main.gd`** never connects it. On boot failure the recipe panel stays empty with no HUD line (inventory/skill clients show errors). Connect to **`CraftFeedbackLabel`** or **`push_warning`** + a one-line error for parity with **`inventory_sync_failed`** / **`progression_sync_failed`**.~~ **Done.** **`_on_recipes_sync_failed`** paints **`Recipes: error — …`** on **`CraftFeedbackLabel`**.
|
||||
|
||||
2. ~~**Add `.gd.uid` companions for new GdUnit test scripts** — `craft_client_test.gd`, `recipe_definitions_client_test.gd`, and `craft_feedback_refresh_test.gd` have no tracked **`*.gd.uid`** files (NEO-73 established this for new tests under `client/test/`). Generate in Godot and commit alongside the tests per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md).~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`craft_recipe_panel.gd`** declares **`@onready var _scroll`** but only uses **`_vbox`** — remove unused binding or use **`_scroll`** for max-height policy.~~ **Done.**
|
||||
- Nit: **`craft_feedback_refresh_test.gd`** harness duplicates refresh logic from **`main.gd`** (intentional isolation) — acceptable; no **`main.gd`** integration test remains (same gap as NEO-73 gather refresh test).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Server (separate terminal)
|
||||
cd server/NeonSprawl.Server && dotnet run
|
||||
|
||||
# NEO-74 GdUnit suites (from client/)
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/craft_client_test.gd \
|
||||
-a res://test/recipe_definitions_client_test.gd \
|
||||
-a res://test/craft_feedback_refresh_test.gd
|
||||
```
|
||||
|
||||
Manual: follow [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) — deny **`insufficient_materials`**, **`refine_scrap_standard`** success, **`make_field_stim_mk0`**, **`refine`** XP delta without pressing **I** on success.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Code review — NEO-75 playable gather→refine→make loop (capstone)
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-75-e3-playable-gather-refine-make-loop` · commits `fbae314`–`977bcc3` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — capstone docs, HUD collapse, and path updates match the adopted plan; GdUnit passes. Merge after human capstone manual QA; minor module/README doc consistency nits below.
|
||||
|
||||
## Summary
|
||||
|
||||
The branch completes Epic 3 Slice 5 capstone integration: collapsible **`EconomyHudSection`** wraps inventory/skills/recipe panel under a **`Economy HUD`** checkbox, **`main.gd`** `@onready` paths follow the reparent, and a focused **`prototype_economy_hud_section.gd`** script toggles body visibility. Docs deliver the zero-Bruno capstone script (`docs/manual-qa/NEO-75.md`), README end-to-end loop section, implementation plan reconciliation, alignment register updates, and E3S5-04 backlog checkboxes. No new HTTP routes or economy client logic — the gather→refine→make loop reuses NEO-72–74 wiring as intended. Risk is low: layout-only client change plus documentation; automated coverage is the HUD toggle suite only (orchestration remains manual QA per plan).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-75-implementation-plan.md`](../plans/NEO-75-implementation-plan.md) | **Matches** — kickoff decisions (docs-first, implement HUD collapse, fresh server session, visible XP only) reflected; acceptance checklist and reconciliation complete. |
|
||||
| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-04** | **Matches** — capstone manual QA, README integration, optional HUD collapse, alignment cross-links; AC checked. |
|
||||
| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — status notes NEO-74 + NEO-75 landed. |
|
||||
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — Status row notes NEO-73 gather feedback + NEO-75 capstone landed. |
|
||||
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — Status row notes NEO-72 inventory HUD + NEO-75 economy collapse landed. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1/M2/M3 rows note NEO-75 landed and Slice 5 client complete. |
|
||||
| [`docs/decomposition/epics/epic_03_crafting_economy.md`](../decomposition/epics/epic_03_crafting_economy.md) | **Matches** — Slice 5 **Status** note records NEO-75 capstone landed. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — no client-side stack math; display-only HUD collapse. |
|
||||
| [`docs/manual-qa/NEO-75.md`](../manual-qa/NEO-75.md) | **Matches** — single-session script, material math, collapse step; acceptance boxes left for human sign-off (expected). |
|
||||
| [`client/README.md`](../../client/README.md) | **Matches** — NEO-75 loop section; NEO-72/73/74 HUD paths use **`EconomyHudSection/Body/`** hierarchy. |
|
||||
| Full-stack epic decomposition | **Matches** — E3S5-04 **client** capstone completes NEO-72→75 chain; Godot manual QA authoritative (not Bruno-only). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Add Slice 5 capstone note to `epic_03_crafting_economy.md`** — Plan §4 called for a Slice 5 completion note when AC pass; alignment register and backlog are updated but the epic page still reads as open-ended. A one-line “Slice 5 client capstone (NEO-75) landed” under Slice 5 would close the loop.~~ Done.
|
||||
|
||||
2. ~~**Refresh E3.M1 / E3.M3 module Status rows** — E3.M2 was updated; E3.M1 and E3.M3 module pages still show pre–Slice 5 status while the alignment register already cites NEO-73/72/75. Mirror the E3.M2 pattern (client slice landed + capstone) for doc parity.~~ Done.
|
||||
|
||||
3. ~~**Align NEO-73 README HUD paths** — Update the gather section to `UICanvas/EconomyHudSection/Body/InventoryLabel` and `…/SkillProgressionLabel` so all three Slice 5 README sections reference the same hierarchy after the reparent.~~ Done (NEO-74 craft HUD paths aligned in the same pass).
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`test_toggle_collapses_body`** drives collapse via direct **`button_pressed = false`** rather than simulating a user click on **`ToggleButton`** — acceptable in headless GdUnit; behavior is covered.
|
||||
- Nit: **`set_expanded`** and **`_on_toggle_toggled`** duplicate visibility logic — fine at this size; only matters if collapse rules grow (e.g. persist preference).
|
||||
- ~~Nit: Capstone AC for HUD collapse is checked in the implementation plan but not listed as a separate bullet in E3S5-04 backlog acceptance criteria — optional backlog tweak for traceability.~~ Done — E3S5-04 AC bullet added.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# NEO-75 GdUnit suite (from client/)
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/prototype_economy_hud_section_test.gd
|
||||
|
||||
# Full client suite (CI parity)
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode
|
||||
```
|
||||
|
||||
Manual (required before claiming capstone complete): follow [`docs/manual-qa/NEO-75.md`](../manual-qa/NEO-75.md) — **fresh server restart**, four-node gather (**11** scrap), two **`refine_scrap_standard`**, one **`make_field_stim_mk0`**, verify **`field_stim_mk0` ≥ 1**, salvage/refine XP movement, and economy HUD collapse/expand with populated panel.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Code review — NEO-76 prototype AbilityDef catalog + CI
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-76-e5m1-prototype-backlog` · commits `510d711`–`9b11a52` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Code review findings addressed — duplicate catalog row removed; module register table + E5.M1 status aligned.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — schema, `validate_content.py` gates, docs, and catalog now pass CI; ready for merge.
|
||||
|
||||
## Summary
|
||||
|
||||
The branch decomposes Epic 5 Slice 1 into the E5M1 backlog (NEO-76–NEO-86, NEO-44), adds kickoff + implementation plans, and lands content-only E5M1-01: `ability-def.schema.json`, `prototype_abilities.json`, and ability validation in `scripts/validate_content.py` following NEO-50/NEO-65 patterns. Documentation updates (E5.M1 freeze table, `content/README.md`, CT.M1, alignment register, epic backlog link) are thorough and match kickoff decisions. No server/C#/client code — appropriate for this story.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-76-implementation-plan.md`](../plans/NEO-76-implementation-plan.md) | **Matches** — kickoff table, schema shape, CI constant name, file list, and reconciliation AC complete; catalog passes duplicate-id validation. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-01** | **Matches** — E5M1-01 AC checked and landed note present; content passes CI. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — freeze table, Linear slug table, CI rules, and **Status** **In Progress** aligned with alignment register. |
|
||||
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — ability catalog PR gate paragraph added. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row added **In Progress** / NEO-76 landed. Register update appropriate when work starts. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 Linear note below Epic 5 table; E5.M2–E5.M4 rows render correctly; E5.M1 **In Progress**. |
|
||||
| [`docs/decomposition/epics/epic_05_pve_combat.md`](../decomposition/epics/epic_05_pve_combat.md) | **Matches** — Slice 1 backlog link to E5M1-prototype-backlog.md. |
|
||||
| [`content/README.md`](../../content/README.md) | **Matches** — `abilities/` row + E5 Slice 1 freeze paragraph. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no runtime authority changes; content-only. |
|
||||
| Full-stack epic decomposition | **Matches** — E5M1-01 is **server/content** only; client counterparts **NEO-85** / **NEO-86** documented in backlog (not required for this issue). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
1. ~~**Duplicate `prototype_pulse` in `content/abilities/prototype_abilities.json`** — The catalog has **five** rows; `prototype_pulse` appears twice (lines 4–9 and 32–37). Running `python scripts/validate_content.py` exits non-zero with `duplicate ability id 'prototype_pulse'`. This violates E5M1-01 AC (“exactly four prototype ability ids; duplicate `id` fails CI”) and will fail `.github/workflows/pr-gate.yml`. Remove the trailing duplicate row so the file contains exactly the four frozen ids.~~ **Done.** Trailing duplicate row removed; `validate_content.py` reports **4 unique ability id(s)**.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Fix `module_dependency_register.md` Epic 5 table layout** — The **E5.M1 note** paragraph sits between the E5.M1 and E5.M2 table rows, which breaks the markdown table. Move the note below the Epic 5 table (same pattern as other epic footnotes) so E5.M2–E5.M4 rows render in the table.~~ **Done.** Note moved below full Epic 5 table; E5.M1 status **In Progress**.
|
||||
|
||||
2. ~~**Align E5.M1 module Status with alignment register** — [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) lists E5.M1 as **In Progress**; [`E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) summary **Status** still reads **Planned**. Mirror the E3.M* pattern (module page status note when catalog lands).~~ **Done.** E5.M1 module **Status** row updated to **In Progress** with NEO-76 landed note.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Duplicate-id error message cites the same file twice (`… in content/abilities/prototype_abilities.json and content/abilities/prototype_abilities.json`) — acceptable; same-file duplicates are rare in normal authoring.~~ **Deferred** — acceptable per original review; no change.
|
||||
- ~~Nit: [`NEO-76-implementation-plan.md`](../plans/NEO-76-implementation-plan.md) reconciliation and backlog checkboxes are marked complete while CI currently fails — update after catalog fix or note “pending catalog fix” until green.~~ **Done.** Catalog fix makes reconciliation accurate; CI green.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# From repo root (venv recommended)
|
||||
python3 -m venv .venv-content && .venv-content/bin/pip install -r scripts/requirements-content.txt
|
||||
.venv-content/bin/python scripts/validate_content.py
|
||||
# Expect: content OK … 1 ability catalog file(s) … 4 unique ability id(s)
|
||||
|
||||
# CI parity
|
||||
# pr-gate.yml runs the same script after pip install -r scripts/requirements-content.txt
|
||||
```
|
||||
|
||||
Four unique ids match `PROTOTYPE_E5M1_ABILITY_IDS` and [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) allowlist (`prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`).
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# Code review — NEO-77 server ability catalog load (fail-fast)
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-77-e5m1-server-ability-catalog-load` · commits `07444e4`–`7c2697e` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-77 delivers **E5M1-02**: fail-fast startup load of `content/abilities/*_abilities.json` under `server/NeonSprawl.Server/Game/Combat/`, mirroring the NEO-51 / NEO-66 catalog pattern. The loader validates Draft 2020-12 row schema, `schemaVersion`, duplicate `id`, and the frozen E5M1 four-id gate (`PrototypeE5M1AbilityCatalogRules`), registers `AbilityDefinitionCatalog` in DI, and eagerly resolves it in `Program.cs`. Fourteen AAA loader/host tests pass; full suite (354 tests) is green. Docs (plan, E5M1 backlog, E5.M1 module page, alignment register, `server/README.md`) are updated. `PrototypeAbilityRegistry` is correctly unchanged (NEO-79). Bruno adds a health smoke request consistent with other catalog stories. No client or ability HTTP API — appropriately deferred. Risk is low: infrastructure-only, CI already gates content.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-77-implementation-plan.md`](../plans/NEO-77-implementation-plan.md) | **Matches** — kickoff decisions, loader/catalog/DI scope, acceptance checklist, reconciliation section; `IAbilityDefinitionRegistry` correctly out of scope. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-02** | **Matches** — AC checkboxes + landed note. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status line and freeze rules reference NEO-77 server load + NEO-79 registry deferral. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row updated with NEO-77 landed note; next backlog item NEO-79. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 table **Status** **In Progress** is correct; **E5.M1 note** cites NEO-76 + NEO-77 server load. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server startup catalog only; no client mutation or combat resolution. |
|
||||
| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — C# loader mirrors `_validate_ability_catalogs` + `_prototype_e5m1_ability_gate`; sync comment on `PrototypeE5M1AbilityCatalogRules.ExpectedAbilityIds`. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — Ability catalog section (config keys, discovery, fail-fast, E5M1 parity). |
|
||||
| Full-stack epic decomposition | **Matches** — E5M1-02 is **server/content** only; client counterparts documented in backlog (NEO-85 / NEO-86); no false “prototype complete” claim. |
|
||||
| Manual QA | **N/A** — plan documents skip rationale: automated loader/host tests + Bruno health smoke. |
|
||||
|
||||
Register/tracking: alignment table E5.M1 **In Progress** with NEO-77 note is correct.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Add `folder.bru` under `bruno/neon-sprawl-server/ability-catalog/`** — Other catalog smoke folders (`item-catalog`, `recipe-catalog`, `resource-node-catalog`, `mastery-catalog`) include a `folder.bru` meta file; ability-catalog currently has only the health request. Add for Bruno collection parity.~~ **Done.** Added `bruno/neon-sprawl-server/ability-catalog/folder.bru`.
|
||||
|
||||
2. ~~**E5M1 gate test — extra/wrong id** — `Load_ShouldThrow_WhenE5M1GateFails` removes one ability (missing id). Plan test table also lists wrong roster; consider an AAA case that swaps in an unknown id (e.g. `prototype_extra`) to cover the “got […]” branch, not only “missing”.~~ **Done.** Renamed missing-id test to `Load_ShouldThrow_WhenE5M1GateFailsWithMissingId`; added `Load_ShouldThrow_WhenE5M1GateFailsWithExtraId`. Plan test table updated.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `ValidPrototypeCatalogJson` duplicates `content/abilities/prototype_abilities.json` inline — fine for isolated temp-dir fixtures (same pattern as NEO-66 recipe tests); optional future refactor to copy repo file.
|
||||
|
||||
- Nit: E5M1 gate error string format differs slightly from Python (`'prototype_pulse', …` vs `sorted(...)!r`); behavior is equivalent and messages are actionable.
|
||||
|
||||
- Nit: Repeated `services.AddOptions<ContentPathsOptions>().Bind(...)` in each catalog extension — established repo pattern (NEO-51/NEO-66); harmless duplicate binds.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~AbilityDefinitionCatalogLoaderTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
|
||||
# Content CI parity (repo root)
|
||||
python3 scripts/validate_content.py
|
||||
# Expect: 4 unique ability id(s)
|
||||
```
|
||||
|
||||
Manual (optional):
|
||||
|
||||
- Start server from repo root; confirm Information log: ability count **4**, catalog directory, file count **1**.
|
||||
- Bruno: `bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru` → 200 + `service: NeonSprawl.Server`.
|
||||
- Negative: set `Content__AbilitiesDirectory` to an empty temp dir → startup fails with `Ability catalog validation failed:`.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Code review — NEO-78 GET world ability-definitions
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-78-get-world-ability-definitions` · commits `374b4b5`–`44e0056` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
**Follow-up:** Code review suggestions addressed (dependency register, E5M1 backlog in-scope line, test count assert, Bruno cleanup + guard/dash spot-checks).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-78 delivers **E5M1-04**: a read-only **`GET /game/world/ability-definitions`** endpoint backed by **`IAbilityDefinitionRegistry.GetDefinitionsInIdOrder()`**, with versioned DTOs (`schemaVersion` **1**, **`abilities`** array), row projection (`id`, `displayName`, `baseDamage`, `cooldownSeconds`, optional **`abilityKind`** omitted when null), Bruno coverage, and an AAA integration test asserting the frozen-four id order plus **`prototype_pulse`** / **`prototype_burst`** spot-checks. Implementation closely mirrors the landed NEO-68 recipe-definitions and NEO-53 item-definitions patterns; no auth, no second catalog source, no client wiring (deferred to NEO-85). Residual risk is low — additive HTTP read model on existing registry truth.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-78-implementation-plan.md`](../plans/NEO-78-implementation-plan.md) | **Matches** — kickoff decisions (no manual QA, omit null `abilityKind`, registry-only injection); acceptance checklist complete; reconciliation section reflects landed files. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-04** | **Matches** — AC checkboxes checked; landed note with plan + Bruno links; in-scope line reflects no manual QA (server-only kickoff). |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites NEO-78 landed; **Related implementation slices** HTTP bullet added; freeze table ids align with test assertions. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row includes NEO-78 HTTP projection + Bruno folder. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-78 GET + Bruno folder. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — read-only world catalog; server source of truth; no client mutation in this story. |
|
||||
| NEO-68 / NEO-53 reference implementations | **Matches** — `AbilityDefinitionsWorldApi` + separate DTO file mirror `RecipeDefinitionsWorldApi` / `ItemDefinitionsWorldApi`; integration test shape matches `RecipeDefinitionsWorldApiTests`. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; NEO-85 optional enrichment; does not claim prototype slice complete without Godot. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — new **Ability definitions (NEO-78)** section with curl example and plan/Bruno links. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Dependency register E5.M1 note** — Extend the **E5.M1 note** in `docs/decomposition/modules/module_dependency_register.md` with **NEO-78 landed:** `GET /game/world/ability-definitions` — `AbilityDefinitionsWorldApi` + DTOs; Bruno `ability-definitions/` (same pattern as NEO-79 review follow-up for registry DI).~~ **Done.**
|
||||
|
||||
2. ~~**E5M1 backlog in-scope line** — In **E5M1-04** “In scope”, consider replacing “manual QA stub if needed” with “no manual QA (server-only per NEO-78 kickoff)” so the backlog does not contradict the plan decision.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Bruno test **“frozen prototype four is present”** is redundant given **“frozen prototype four matches registry id order”** already asserts the exact four ids.~~ **Done.** Removed redundant test.
|
||||
|
||||
- ~~Nit: C# integration test infers count **4** via `FrozenFourInIdOrder` length only; an explicit `Assert.Equal(4, body.Abilities.Count)` would match Bruno’s explicit length check (optional — recipe test uses the same frozen-array pattern).~~ **Done.**
|
||||
|
||||
- ~~Nit: Spot-checks cover **`prototype_pulse`** and **`prototype_burst`** only; **`prototype_guard`** (`utility`) and **`prototype_dash`** (`movement`) kinds are untested in automated/Bruno paths — acceptable per plan scope but easy to add later if desired.~~ **Done.** Guard/dash spot-checks added to C# test and Bruno.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~AbilityDefinitionsWorldApiTests|FullyQualifiedName~AbilityDefinitionRegistryTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~Combat"
|
||||
```
|
||||
|
||||
Manual (optional):
|
||||
|
||||
- Bruno: `bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru` against a running dev server (`http://localhost:5253`).
|
||||
|
||||
**Reviewer note:** Full `dotnet test` reported **32** failures in `GatherOperationsTests` (likely environment/DB-dependent, unrelated to this diff). All **27** Combat-filtered tests and the new ability-definitions integration test passed.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Code review — NEO-79 `IAbilityDefinitionRegistry` + DI
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-79-iabilitydefinitionregistry-di` · commits `8bafec9`–`b9644a0` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Code review suggestions addressed (module/alignment docs, plan Bruno note, `TryNormalizeKnown` null guard + test).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-79 delivers **E5M1-03**: a thin `IAbilityDefinitionRegistry` adapter over the NEO-77 `AbilityDefinitionCatalog`, registered in DI via `AddAbilityDefinitionCatalog`, with `TryGetDefinition`, `TryNormalizeKnown`, and `GetDefinitionsInIdOrder`. Hotbar loadout and ability cast routes now inject the registry instead of the static `PrototypeAbilityRegistry` allowlist; `PrototypeAbilityRegistry` is reduced to id constants for tests. Ten new AAA unit/host tests cover lookup, normalization, enumeration, loader fixture parity, and DI resolution. Existing hotbar/cast API regression tests remain green. Bruno adds two HTTP deny smoke requests for `unknown_ability`. Implementation matches the kickoff plan and mirrors the NEO-67 recipe registry pattern. Residual risk is low — behavior-preserving internal migration; module tracking docs should catch up on merge.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-79-implementation-plan.md`](../plans/NEO-79-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation; all AC checked; Bruno deny smokes noted in reconciliation. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-03** | **Matches** — AC checkboxes + landed note with plan link. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites NEO-79 landed; freeze box updated. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-79 registry + hotbar/cast migration. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row includes NEO-79 landed + Bruno smokes. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only DI; no client or combat resolution changes. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — ability catalog section updated for registry-first lookup and hotbar/cast validation. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; server-only story. |
|
||||
| Manual QA | **N/A** — plan skip rationale holds; Bruno deny smokes are a useful bonus. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, change the **Status** row from “E5M1-03+ pending” to cite **E5M1-03 NEO-79 landed** (registry + hotbar/cast migration), matching the E5M1 backlog landed note.~~ **Done.** Status + freeze box updated.
|
||||
|
||||
2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-79 landed:** `IAbilityDefinitionRegistry` + DI; hotbar/cast use `TryNormalizeKnown` (same pattern as NEO-77 reconciliation).~~ **Done.**
|
||||
|
||||
3. ~~**Plan reconciliation vs Bruno** — `NEO-79-implementation-plan.md` says “No Bruno or manual QA”; the branch adds two Bruno deny requests under `ability-cast/` and `hotbar-loadout/`. Either add a one-line reconciliation note (“optional Bruno deny smokes added”) or leave as-is — the requests are valuable HTTP regression coverage.~~ **Done.** Tests + reconciliation sections updated.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `TryGetDefinition` has no explicit test for case-variant ids returning false (normalization is covered on `TryNormalizeKnown` only). Acceptable given the plan’s split between exact lookup and normalize-for-validation.
|
||||
|
||||
- ~~Nit: `TryNormalizeKnown` would throw on `null` input (same as the removed static allowlist); call sites guard with `string.IsNullOrWhiteSpace` first — document in XML if future callers might skip that guard.~~ **Done.** `string?` parameter, null guard in implementation, XML remark, and unit test.
|
||||
|
||||
- Nit: `BuildDefinitionsInIdOrder` materializes a `List<AbilityDefRow>` at construction — same pattern as `RecipeDefinitionRegistry`; fine for four prototype rows.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~AbilityDefinitionRegistryTests|FullyQualifiedName~HotbarLoadoutApiTests|FullyQualifiedName~AbilityCastApiTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
Manual (optional):
|
||||
|
||||
- Bruno: `bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru` → 200, `updated: false`, `reasonCode: unknown_ability`.
|
||||
- Bruno: `bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru` → 200, `accepted: false`, `reasonCode: unknown_ability`.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Code review — NEO-80 combat entity health store
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-80-combat-entity-health-store` · commits `e729996`–`b060b9d` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Code review suggestions addressed (module/alignment docs, E5M1 backlog landed note, `TryApplyDamage` deny tests, normalization + reset deny tests, AAA fix on beta independence test).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-80 delivers **E5M1-05**: a thread-safe, in-memory `ICombatEntityHealthStore` for prototype combat dummies (`prototype_target_alpha`, `prototype_target_beta`), with lazy init to `PrototypeCombatConstants.MaxPrototypeTargetHp` (100), damage flooring at zero, defeated flag semantics, and `TryResetToFull` for tests/fixtures. DI registers the store as a singleton via `AddCombatEntityHealthStore()`, chained from `AddAbilityDefinitionCatalog` per the plan. Ten AAA unit/host tests cover lazy init, damage, defeat/overkill, reset, alpha/beta independence, empty/unknown/negative deny paths, and host resolution. Full suite (377 tests) passes. Implementation aligns with the kickoff plan and mirrors the NEO-61 per-id locking pattern. Residual risk is low — server-only internal store with no HTTP surface; module tracking docs and one minor test gap should be closed on merge.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-80-implementation-plan.md`](../plans/NEO-80-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation; DI chained from ability catalog (not `Program.cs`) as documented in Files to modify. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-05** | **Matches** — AC checkboxes checked; landed note with plan link added. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites NEO-80 landed; related implementation slice added. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-80 health store. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row includes NEO-80 landed. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only in-memory store; no client or HTTP changes. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — combat entity health section documents init, damage, reset policy, and NEO-81 handoff. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; HP visibility deferred to NEO-83/NEO-85. |
|
||||
| Manual QA | **N/A** — plan skip rationale holds (server-only internal store). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** from “E5M1-05+ pending” to cite **E5M1-05 NEO-80 landed** (`ICombatEntityHealthStore` + lazy dummy init), matching prior NEO-76–78 landed pattern.~~ **Done.** Status + related implementation slice updated.
|
||||
|
||||
2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-80 landed:** `ICombatEntityHealthStore` / `InMemoryCombatEntityHealthStore`, `PrototypeCombatConstants.MaxPrototypeTargetHp = 100`, DI via `AddCombatEntityHealthStore()`.~~ **Done.**
|
||||
|
||||
3. ~~**E5M1 backlog landed note** — In `docs/plans/E5M1-prototype-backlog.md` § E5M1-05, check AC boxes and add a one-line landed note with link to [NEO-80 implementation plan](../plans/NEO-80-implementation-plan.md) (same pattern as E5M1-03/04).~~ **Done.**
|
||||
|
||||
4. ~~**`TryApplyDamage` deny coverage** — Plan Tests table lists “Deny: unknown id, empty id, negative damage.” Negative damage and empty id are covered only on `TryGet`; add `TryApplyDamage` false-path tests for unknown and empty ids so both mutation entry points are symmetrically guarded.~~ **Done.** Added unknown/empty deny tests plus case normalization and `TryResetToFull` deny tests.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: No test for case-variant ids (e.g. `"Prototype_Target_Alpha"`) — normalization is implemented via `Trim().ToLowerInvariant()` but untested; low risk given registry keys are lowercase constants.~~ **Done.** `TryGet_ShouldNormalizeCaseVariantTargetIds`.
|
||||
|
||||
- ~~Nit: `TryResetToFull` deny path (unknown id) is untested; happy path and `TryGet` deny are covered.~~ **Done.** `TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown`.
|
||||
|
||||
- Nit: `AddCombatEntityHealthStore()` is chained inside `AddAbilityDefinitionCatalog` — acceptable per plan, but couples unrelated concerns; a future `AddCombatServices()` aggregator may be cleaner when NEO-81+ adds more combat DI. **Deferred** — documented in plan; revisit with NEO-81 combat DI.
|
||||
|
||||
- ~~Nit: `TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha` performs two `TryGet` calls in **Act** (beta + alpha read); alpha state could be asserted via a prior snapshot from Arrange instead — AAA style only.~~ **Done.** Alpha HP asserted from damage snapshot in Arrange.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~CombatEntityHealthStoreTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
Manual: **N/A** — no HTTP or client surface in this story.
|
||||
|
|
@ -10,6 +10,7 @@ Validates:
|
|||
- resource node catalogs: content/resource-nodes/*_resource_nodes.json vs resource-node-def.schema.json (NEO-57)
|
||||
- resource yield catalogs: content/resource-nodes/*_resource_yields.json vs resource-yield-row.schema.json (NEO-57)
|
||||
- recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65)
|
||||
- ability catalogs: content/abilities/*_abilities.json rows vs content/schemas/ability-def.schema.json (NEO-76)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -30,11 +31,13 @@ RESOURCE_NODE_SCHEMA = REPO_ROOT / "content/schemas/resource-node-def.schema.jso
|
|||
RESOURCE_YIELD_ROW_SCHEMA = REPO_ROOT / "content/schemas/resource-yield-row.schema.json"
|
||||
RECIPE_IO_ROW_SCHEMA = REPO_ROOT / "content/schemas/recipe-io-row.schema.json"
|
||||
RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json"
|
||||
ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-def.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes"
|
||||
RECIPES_DIR = REPO_ROOT / "content/recipes"
|
||||
ABILITIES_DIR = REPO_ROOT / "content/abilities"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -88,6 +91,17 @@ PROTOTYPE_SLICE3_RECIPE_IDS = frozenset(
|
|||
)
|
||||
PROTOTYPE_SLICE3_REFINE_SKILL_ID = "refine"
|
||||
|
||||
# Epic 5 Slice 1 prototype lock (NEO-76): exact ability ids after schema passes.
|
||||
# Keep in sync with E5.M1 freeze table and future NEO-77 server loader.
|
||||
PROTOTYPE_E5M1_ABILITY_IDS = frozenset(
|
||||
{
|
||||
"prototype_pulse",
|
||||
"prototype_guard",
|
||||
"prototype_dash",
|
||||
"prototype_burst",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
|
|
@ -397,6 +411,64 @@ def _validate_item_catalogs(
|
|||
return errors, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max
|
||||
|
||||
|
||||
def _validate_ability_catalogs(
|
||||
*,
|
||||
ability_files: list[Path],
|
||||
ability_validator: Draft202012Validator,
|
||||
) -> tuple[int, dict[str, str]]:
|
||||
"""Validate ability JSON files. Returns (error_count, seen_ids)."""
|
||||
errors = 0
|
||||
seen_ids: dict[str, str] = {}
|
||||
|
||||
for path in ability_files:
|
||||
rel = str(path.relative_to(REPO_ROOT))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
schema_version = data.get("schemaVersion")
|
||||
if schema_version != 1:
|
||||
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
abilities = data.get("abilities")
|
||||
if not isinstance(abilities, list):
|
||||
print(f"error: {rel}: expected top-level 'abilities' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(abilities):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: abilities[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(ability_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} abilities[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
aid = row.get("id")
|
||||
if isinstance(aid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(aid)
|
||||
if prev:
|
||||
print(f"error: duplicate ability id {aid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[aid] = rel
|
||||
|
||||
return errors, seen_ids
|
||||
|
||||
|
||||
def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E5M1 ability contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E5M1_ABILITY_IDS:
|
||||
return (
|
||||
"error: prototype E5M1 expects exactly ability ids "
|
||||
f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||
if len(track_skill_ids) != 1:
|
||||
|
|
@ -717,6 +789,9 @@ def main() -> int:
|
|||
if not RECIPE_SCHEMA.is_file():
|
||||
print(f"error: missing schema {RECIPE_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not ABILITY_SCHEMA.is_file():
|
||||
print(f"error: missing schema {ABILITY_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -731,6 +806,8 @@ def main() -> int:
|
|||
resource_yield_schema = json.loads(RESOURCE_YIELD_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
resource_yield_validator = Draft202012Validator(resource_yield_schema)
|
||||
recipe_validator = _recipe_def_validator()
|
||||
ability_schema = json.loads(ABILITY_SCHEMA.read_text(encoding="utf-8"))
|
||||
ability_validator = Draft202012Validator(ability_schema)
|
||||
|
||||
if not SKILLS_DIR.is_dir():
|
||||
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
|
||||
|
|
@ -787,6 +864,15 @@ def main() -> int:
|
|||
print(f"error: no *_recipes.json files under {RECIPES_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not ABILITIES_DIR.is_dir():
|
||||
print(f"error: missing directory {ABILITIES_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ability_files = sorted(ABILITIES_DIR.glob("*_abilities.json"))
|
||||
if not ability_files:
|
||||
print(f"error: no *_abilities.json files under {ABILITIES_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
errors = 0
|
||||
|
|
@ -975,6 +1061,19 @@ def main() -> int:
|
|||
print(slice3_recipe_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ability_errors, ability_seen_ids = _validate_ability_catalogs(
|
||||
ability_files=ability_files,
|
||||
ability_validator=ability_validator,
|
||||
)
|
||||
if ability_errors:
|
||||
print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids)
|
||||
if e5m1_ability_err:
|
||||
print(e5m1_ability_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"content OK: "
|
||||
f"{len(skill_files)} skill catalog file(s), "
|
||||
|
|
@ -984,10 +1083,12 @@ def main() -> int:
|
|||
f"{len(node_files)} resource node catalog file(s), "
|
||||
f"{len(yield_files)} resource yield catalog file(s), "
|
||||
f"{len(recipe_files)} recipe catalog file(s), "
|
||||
f"{len(ability_files)} ability catalog file(s), "
|
||||
f"{len(seen_ids)} unique skill id(s), "
|
||||
f"{len(item_seen_ids)} unique item id(s), "
|
||||
f"{len(node_seen_ids)} unique nodeDefId(s), "
|
||||
f"{len(recipe_seen_ids)} unique recipe id(s), "
|
||||
f"{len(ability_seen_ids)} unique ability id(s), "
|
||||
f"{len(track_skill_ids)} mastery track(s)"
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
internal static class AbilityCatalogTestPaths
|
||||
{
|
||||
internal static string DiscoverRepoAbilitiesDirectory() =>
|
||||
AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoAbilityDefSchemaPath() =>
|
||||
AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath(
|
||||
DiscoverRepoAbilitiesDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
public class AbilityDefinitionCatalogLoaderTests
|
||||
{
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"abilities": [
|
||||
{
|
||||
"id": "prototype_pulse",
|
||||
"displayName": "Prototype Pulse",
|
||||
"baseDamage": 25,
|
||||
"cooldownSeconds": 3.0,
|
||||
"abilityKind": "attack"
|
||||
},
|
||||
{
|
||||
"id": "prototype_guard",
|
||||
"displayName": "Prototype Guard",
|
||||
"baseDamage": 0,
|
||||
"cooldownSeconds": 6.0,
|
||||
"abilityKind": "utility"
|
||||
},
|
||||
{
|
||||
"id": "prototype_dash",
|
||||
"displayName": "Prototype Dash",
|
||||
"baseDamage": 0,
|
||||
"cooldownSeconds": 4.0,
|
||||
"abilityKind": "movement"
|
||||
},
|
||||
{
|
||||
"id": "prototype_burst",
|
||||
"displayName": "Prototype Burst",
|
||||
"baseDamage": 40,
|
||||
"cooldownSeconds": 5.0,
|
||||
"abilityKind": "attack"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string AbilitiesDir, string SchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-abilitycat-");
|
||||
var abilitiesDir = Path.Combine(root.FullName, "content", "abilities");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(abilitiesDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "ability-def.schema.json");
|
||||
File.Copy(AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(), schemaPath, overwrite: true);
|
||||
return (root.FullName, abilitiesDir, schemaPath);
|
||||
}
|
||||
|
||||
private static void WriteCatalog(string abilitiesDir, string catalogJson) =>
|
||||
File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8);
|
||||
|
||||
private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId)
|
||||
{
|
||||
var abilities = catalogRoot["abilities"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected abilities array");
|
||||
foreach (var node in abilities)
|
||||
{
|
||||
if (node is JsonObject row && row["id"]?.GetValue<string>() == abilityId)
|
||||
return row;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"ability id not found: {abilityId}");
|
||||
}
|
||||
|
||||
private static AbilityDefinitionCatalog LoadCatalog(string abilitiesDir, string schemaPath) =>
|
||||
AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
||||
// Act
|
||||
var catalog = LoadCatalog(abilitiesDir, schemaPath);
|
||||
// Assert
|
||||
Assert.Equal(4, catalog.DistinctAbilityCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse));
|
||||
Assert.NotNull(pulse);
|
||||
Assert.Equal(25, pulse!.BaseDamage);
|
||||
Assert.Equal(3.0, pulse.CooldownSeconds);
|
||||
Assert.Equal("attack", pulse.AbilityKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenAbilitiesIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(abilitiesDir, "bad_abilities.json"),
|
||||
"""{"schemaVersion": 1, "abilities": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'abilities' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(abilitiesDir, "bad_abilities.json"),
|
||||
"""{"schemaVersion": 2, "abilities": []}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateAbilityIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
||||
File.WriteAllText(
|
||||
Path.Combine(abilitiesDir, "extra_abilities.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"abilities": [
|
||||
{
|
||||
"id": "prototype_pulse",
|
||||
"displayName": "Duplicate Pulse",
|
||||
"baseDamage": 1,
|
||||
"cooldownSeconds": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate ability id 'prototype_pulse'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M1GateFailsWithMissingId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
var abilities = root["abilities"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected abilities array");
|
||||
abilities.RemoveAt(abilities.Count - 1);
|
||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M1GateFailsWithExtraId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra";
|
||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenBaseDamageIsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetAbilityRow(root, "prototype_pulse")["baseDamage"] = -1;
|
||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("baseDamage", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenCooldownSecondsIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetAbilityRow(root, "prototype_pulse")["cooldownSeconds"] = 0;
|
||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("cooldownSeconds", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDisplayNameIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetAbilityRow(root, "prototype_pulse")["displayName"] = "";
|
||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNoAbilityCatalogFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("no *_abilities.json files", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenJsonIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(abilitiesDir, "bad_abilities.json"),
|
||||
"{not json",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenAbilitiesDirectoryMissing()
|
||||
{
|
||||
// Arrange
|
||||
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-abilities-" + Guid.NewGuid().ToString("n"));
|
||||
var schemaPath = AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath();
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
AbilityDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, abilitiesDir, _) = CreateTempContentLayout();
|
||||
var missingSchema = Path.Combine(abilitiesDir, "missing-ability-def.schema.json");
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
AbilityDefinitionCatalogLoader.Load(abilitiesDir, missingSchema, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/health");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var catalog = factory.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||
Assert.Equal(4, catalog.DistinctAbilityCount);
|
||||
Assert.True(catalog.TryGetAbility("prototype_burst", out var burst));
|
||||
Assert.Equal(40, burst!.BaseDamage);
|
||||
Assert.Equal(5.0, burst.CooldownSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-abilities-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(badDir);
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
b.UseSetting("Content:AbilitiesDirectory", badDir));
|
||||
factory.CreateClient();
|
||||
});
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
Assert.Contains("Ability catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(badDir))
|
||||
Directory.Delete(badDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
using System.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
public class AbilityDefinitionRegistryTests
|
||||
{
|
||||
private static AbilityDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, AbilityDefRow> byId)
|
||||
{
|
||||
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", byId, catalogJsonFileCount: 1);
|
||||
return new AbilityDefinitionRegistry(catalog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenPulseExists()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var def);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(def);
|
||||
Assert.Equal(25, def.BaseDamage);
|
||||
Assert.Equal(3.0, def.CooldownSeconds);
|
||||
Assert.Equal("attack", def.AbilityKind);
|
||||
Assert.Equal("Prototype Pulse", def.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenBurstExists()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeBurst,
|
||||
"Prototype Burst",
|
||||
40,
|
||||
5.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeBurst, out var def);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(def);
|
||||
Assert.Equal(40, def.BaseDamage);
|
||||
Assert.Equal(5.0, def.CooldownSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenAbilityIdIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(null, out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition("not_a_real_ability", out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown(" Prototype_Pulse ", out var normalized);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown(null, out var normalized);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(string.Empty, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown(" ", out var normalized);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(string.Empty, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal("prototype_unknown", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleAbilities()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeBurst,
|
||||
"Prototype Burst",
|
||||
40,
|
||||
5.0,
|
||||
"attack"),
|
||||
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeDash,
|
||||
"Prototype Dash",
|
||||
0,
|
||||
4.0,
|
||||
"movement"),
|
||||
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeGuard,
|
||||
"Prototype Guard",
|
||||
0,
|
||||
6.0,
|
||||
"utility"),
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.Equal(4, list.Count);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeDash, list[1].Id);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeGuard, list[2].Id);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[3].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture()
|
||||
{
|
||||
// Arrange
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-ability-registry-loader-");
|
||||
try
|
||||
{
|
||||
var abilitiesDir = Path.Combine(root.FullName, "content", "abilities");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(abilitiesDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "ability-def.schema.json");
|
||||
File.Copy(AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(), schemaPath, overwrite: true);
|
||||
File.Copy(
|
||||
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
|
||||
Path.Combine(abilitiesDir, "prototype_abilities.json"),
|
||||
overwrite: true);
|
||||
var loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
|
||||
var registry = new AbilityDefinitionRegistry(loaded);
|
||||
// Act
|
||||
var pulseOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var pulse);
|
||||
var guardOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeGuard, out var guard);
|
||||
var dashOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeDash, out var dash);
|
||||
var burstOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeBurst, out var burst);
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.True(pulseOk);
|
||||
Assert.NotNull(pulse);
|
||||
Assert.Equal(25, pulse!.BaseDamage);
|
||||
Assert.Equal(3.0, pulse.CooldownSeconds);
|
||||
Assert.True(guardOk);
|
||||
Assert.NotNull(guard);
|
||||
Assert.Equal(0, guard!.BaseDamage);
|
||||
Assert.Equal(6.0, guard.CooldownSeconds);
|
||||
Assert.True(dashOk);
|
||||
Assert.NotNull(dash);
|
||||
Assert.Equal(0, dash!.BaseDamage);
|
||||
Assert.Equal(4.0, dash.CooldownSeconds);
|
||||
Assert.True(burstOk);
|
||||
Assert.NotNull(burst);
|
||||
Assert.Equal(40, burst!.BaseDamage);
|
||||
Assert.Equal(5.0, burst.CooldownSeconds);
|
||||
Assert.Equal(4, list.Count);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root.FullName, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort: transient lock or race on some hosts; temp dir is unique per run.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var registry = factory.Services.GetRequiredService<IAbilityDefinitionRegistry>();
|
||||
var pulseFound = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var pulse);
|
||||
var unknown = registry.TryGetDefinition("not_a_real_ability", out var missing);
|
||||
var normalized = registry.TryNormalizeKnown(" Prototype_Burst ", out var burstId);
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.True(pulseFound);
|
||||
Assert.NotNull(pulse);
|
||||
Assert.Equal(25, pulse!.BaseDamage);
|
||||
Assert.Equal(3.0, pulse.CooldownSeconds);
|
||||
Assert.False(unknown);
|
||||
Assert.Null(missing);
|
||||
Assert.True(normalized);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId);
|
||||
Assert.Equal(4, list.Count);
|
||||
Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
public class AbilityDefinitionsWorldApiTests
|
||||
{
|
||||
/// <summary>Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno.</summary>
|
||||
public static readonly string[] FrozenFourInIdOrder =
|
||||
[
|
||||
"prototype_burst",
|
||||
"prototype_dash",
|
||||
"prototype_guard",
|
||||
"prototype_pulse",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenFourInIdOrder()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/world/ability-definitions");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<AbilityDefinitionsListResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.NotNull(body.Abilities);
|
||||
Assert.Equal(4, body.Abilities.Count);
|
||||
var ids = body.Abilities.Select(static a => a.Id).ToList();
|
||||
Assert.Equal(FrozenFourInIdOrder, ids);
|
||||
|
||||
var pulse = body.Abilities.Single(a => a.Id == "prototype_pulse");
|
||||
Assert.Equal("Prototype Pulse", pulse.DisplayName);
|
||||
Assert.Equal(25, pulse.BaseDamage);
|
||||
Assert.Equal(3.0, pulse.CooldownSeconds);
|
||||
Assert.Equal("attack", pulse.AbilityKind);
|
||||
|
||||
var burst = body.Abilities.Single(a => a.Id == "prototype_burst");
|
||||
Assert.Equal("Prototype Burst", burst.DisplayName);
|
||||
Assert.Equal(40, burst.BaseDamage);
|
||||
Assert.Equal(5.0, burst.CooldownSeconds);
|
||||
Assert.Equal("attack", burst.AbilityKind);
|
||||
|
||||
var guard = body.Abilities.Single(a => a.Id == "prototype_guard");
|
||||
Assert.Equal("utility", guard.AbilityKind);
|
||||
Assert.Equal(0, guard.BaseDamage);
|
||||
Assert.Equal(6.0, guard.CooldownSeconds);
|
||||
|
||||
var dash = body.Abilities.Single(a => a.Id == "prototype_dash");
|
||||
Assert.Equal("movement", dash.AbilityKind);
|
||||
Assert.Equal(0, dash.BaseDamage);
|
||||
Assert.Equal(4.0, dash.CooldownSeconds);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
public sealed class CombatEntityHealthStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGet_OnFirstAccess_ShouldLazyInitAlphaToFullHp()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.MaxHp);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDamage_ShouldReduceHp_WhenDamageIsApplied()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(applied);
|
||||
Assert.Equal(75, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDamage_RepeatedUntilZero_ShouldMarkDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var first = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _);
|
||||
var second = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _);
|
||||
var third = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _);
|
||||
var fourth = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var defeated);
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.True(second);
|
||||
Assert.True(third);
|
||||
Assert.True(fourth);
|
||||
Assert.Equal(0, defeated.CurrentHp);
|
||||
Assert.True(defeated.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDamage_OnDefeatedTarget_ShouldKeepHpAtZeroAndRemainDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
// Act
|
||||
var overkill = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(overkill);
|
||||
Assert.Equal(0, snapshot.CurrentHp);
|
||||
Assert.True(snapshot.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResetToFull_AfterDefeat_ShouldRestoreFullHpAndClearDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
// Act
|
||||
var reset = store.TryResetToFull(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(reset);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out var alphaAfterDamage);
|
||||
// Act
|
||||
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetBetaId, out var beta);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp);
|
||||
Assert.False(beta.Defeated);
|
||||
Assert.Equal(60, alphaAfterDamage.CurrentHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ShouldReturnFalse_WhenTargetIdIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet("not_a_prototype_target", out var snapshot);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ShouldReturnFalse_WhenTargetIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(" ", out var snapshot);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDamage_ShouldReturnFalse_WhenDamageIsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, -1, out var snapshot);
|
||||
// Assert
|
||||
Assert.False(applied);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage("not_a_prototype_target", 25, out var snapshot);
|
||||
// Assert
|
||||
Assert.False(applied);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage(" ", 25, out var snapshot);
|
||||
// Assert
|
||||
Assert.False(applied);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ShouldNormalizeCaseVariantTargetIds()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(" Prototype_Target_Alpha ", out var snapshot);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var reset = store.TryResetToFull("not_a_prototype_target", out var snapshot);
|
||||
// Assert
|
||||
Assert.False(reset);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCombatEntityHealthStoreFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
|
|
@ -41,11 +42,15 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
|
|
@ -39,11 +40,15 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -33,9 +34,13 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
|||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -33,9 +34,13 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
|||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
|
|
@ -42,11 +43,15 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Diagnostics;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
|
@ -57,6 +58,7 @@ public static class AbilityCastApi
|
|||
IPlayerHotbarLoadoutStore store,
|
||||
IPlayerTargetLockStore locks,
|
||||
IPlayerAbilityCooldownStore cooldowns,
|
||||
IAbilityDefinitionRegistry abilities,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
|
||||
|
|
@ -105,7 +107,7 @@ public static class AbilityCastApi
|
|||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body.AbilityId) ||
|
||||
!PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
|
||||
!abilities.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
|
||||
{
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
|
@ -30,7 +31,7 @@ public static class HotbarLoadoutApi
|
|||
|
||||
app.MapPost(
|
||||
"/game/players/{id}/hotbar-loadout",
|
||||
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
|
||||
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store, IAbilityDefinitionRegistry abilities) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != HotbarLoadoutUpdateRequest.CurrentSchemaVersion)
|
||||
{
|
||||
|
|
@ -75,7 +76,7 @@ public static class HotbarLoadoutApi
|
|||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(update.AbilityId) ||
|
||||
!PrototypeAbilityRegistry.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
|
||||
!abilities.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
|
||||
{
|
||||
return Results.Json(
|
||||
new HotbarLoadoutUpdateResponse
|
||||
|
|
|
|||
|
|
@ -1,29 +1,10 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Prototype ability allowlist for NEO-29 hotbar bindings.</summary>
|
||||
/// <summary>Stable prototype ability id constants for tests and fixtures (NEO-29). Allowlist validation uses <see cref="Combat.IAbilityDefinitionRegistry"/>.</summary>
|
||||
public static class PrototypeAbilityRegistry
|
||||
{
|
||||
public const string PrototypePulse = "prototype_pulse";
|
||||
public const string PrototypeGuard = "prototype_guard";
|
||||
public const string PrototypeDash = "prototype_dash";
|
||||
public const string PrototypeBurst = "prototype_burst";
|
||||
|
||||
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal)
|
||||
{
|
||||
PrototypePulse,
|
||||
PrototypeGuard,
|
||||
PrototypeDash,
|
||||
PrototypeBurst,
|
||||
};
|
||||
|
||||
public static bool TryNormalizeKnown(string rawAbilityId, out string normalized)
|
||||
{
|
||||
normalized = rawAbilityId.Trim().ToLowerInvariant();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Allowed.Contains(normalized);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Resolves ability catalog paths for local dev, tests, and container layouts (NEO-77).</summary>
|
||||
public static class AbilityCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/abilities</c> directory.</summary>
|
||||
public static string? TryDiscoverAbilitiesDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "abilities");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the abilities catalog directory.
|
||||
/// Empty <paramref name="configuredAbilitiesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveAbilitiesDirectory(string? configuredAbilitiesDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredAbilitiesDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:AbilitiesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/abilities'). " +
|
||||
"Set Content:AbilitiesDirectory in configuration or environment (e.g. Content__AbilitiesDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredAbilitiesDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single ability row (Draft 2020-12).</summary>
|
||||
public static string ResolveAbilityDefSchemaPath(
|
||||
string abilitiesDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(abilitiesDirectory, "..", "schemas", "ability-def.schema.json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>DI registration for the fail-fast ability catalog (NEO-77).</summary>
|
||||
public static class AbilityCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="AbilityDefinitionCatalog"/> and <see cref="IAbilityDefinitionRegistry"/> as singletons.</summary>
|
||||
public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<AbilityDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Combat.AbilityCatalog");
|
||||
|
||||
var abilitiesDir = AbilityCatalogPathResolution.ResolveAbilitiesDirectory(
|
||||
opts.AbilitiesDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var schemaPath = AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath(
|
||||
abilitiesDir,
|
||||
opts.AbilityDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<IAbilityDefinitionRegistry>(sp =>
|
||||
new AbilityDefinitionRegistry(sp.GetRequiredService<AbilityDefinitionCatalog>()));
|
||||
|
||||
services.AddCombatEntityHealthStore();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>One validated <c>AbilityDef</c> row from <c>content/abilities/*_abilities.json</c> (NEO-77).</summary>
|
||||
public sealed record AbilityDefRow(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
int BaseDamage,
|
||||
double CooldownSeconds,
|
||||
string? AbilityKind);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>In-memory ability catalog loaded at startup (NEO-77). Game callers should use <see cref="IAbilityDefinitionRegistry"/>.</summary>
|
||||
public sealed class AbilityDefinitionCatalog(
|
||||
string abilitiesDirectory,
|
||||
IReadOnlyDictionary<string, AbilityDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_abilities.json</c> catalogs.</summary>
|
||||
public string AbilitiesDirectory { get; } = abilitiesDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, AbilityDefRow> ById { get; } = new ReadOnlyDictionary<string, AbilityDefRow>(new Dictionary<string, AbilityDefRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctAbilityCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_abilities.json</c> files under <see cref="AbilitiesDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
|
||||
/// <summary>Resolves a catalog row by stable ability <paramref name="id"/>.</summary>
|
||||
public bool TryGetAbility(string id, out AbilityDefRow? row) =>
|
||||
ById.TryGetValue(id, out row);
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Loads and validates <c>content/abilities/*_abilities.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-77).</summary>
|
||||
public static class AbilityDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static AbilityDefinitionCatalog Load(string abilitiesDirectory, string schemaPath, ILogger logger)
|
||||
{
|
||||
abilitiesDirectory = Path.GetFullPath(abilitiesDirectory);
|
||||
schemaPath = Path.GetFullPath(schemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(schemaPath))
|
||||
errors.Add($"error: missing schema file {schemaPath}");
|
||||
|
||||
if (!Directory.Exists(abilitiesDirectory))
|
||||
errors.Add($"error: missing directory {abilitiesDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(abilitiesDirectory, "*_abilities.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_abilities.json files under {abilitiesDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var abilityIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in jsonFiles)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root is not JsonObject rootObj)
|
||||
{
|
||||
errors.Add($"error: {path}: expected JSON object at root");
|
||||
continue;
|
||||
}
|
||||
|
||||
var schemaVersionNode = rootObj["schemaVersion"];
|
||||
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||
schemaVersion != 1)
|
||||
{
|
||||
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var abilitiesNode = rootObj["abilities"];
|
||||
if (abilitiesNode is not JsonArray abilitiesArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'abilities' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < abilitiesArray.Count; i++)
|
||||
{
|
||||
var ability = abilitiesArray[i];
|
||||
if (ability is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: abilities[{i}] must be an object");
|
||||
continue;
|
||||
}
|
||||
|
||||
var eval = schema.Evaluate(rowObj, evalOptions);
|
||||
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (!eval.IsValid)
|
||||
{
|
||||
if (schemaMsgs.Count == 0)
|
||||
schemaMsgs.Add($"error: {path} abilities[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var rowSchemaErrors = schemaMsgs.Count;
|
||||
|
||||
var aid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (aid is not null && rowSchemaErrors == 0)
|
||||
{
|
||||
if (abilityIdToSourceFile.TryGetValue(aid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate ability id '{aid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
abilityIdToSourceFile[aid] = path;
|
||||
rows[aid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var e5m1 = PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError(abilityIdToSourceFile);
|
||||
if (e5m1 is not null)
|
||||
{
|
||||
errors.Add(e5m1);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Loaded ability catalog from {AbilitiesDirectory}: {AbilityCount} ability(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
abilitiesDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
}
|
||||
|
||||
return new AbilityDefinitionCatalog(abilitiesDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static AbilityDefRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
|
||||
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
|
||||
string? abilityKind = null;
|
||||
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
|
||||
abilityKind = abilityKindValue.GetValue<string>();
|
||||
|
||||
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind);
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||
{
|
||||
if (r.HasDetails)
|
||||
{
|
||||
foreach (var d in r.Details!)
|
||||
AppendSchemaMessages(d, filePath, index, sink);
|
||||
}
|
||||
|
||||
if (!r.HasErrors)
|
||||
return;
|
||||
|
||||
foreach (var kv in r.Errors!)
|
||||
{
|
||||
var loc = r.InstanceLocation?.ToString();
|
||||
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||
loc = "(root)";
|
||||
|
||||
sink.Add($"error: {filePath} abilities[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Ability catalog validation failed:");
|
||||
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||
sb.AppendLine(e);
|
||||
|
||||
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Adapter over <see cref="AbilityDefinitionCatalog"/> (NEO-79).</summary>
|
||||
public sealed class AbilityDefinitionRegistry(AbilityDefinitionCatalog catalog) : IAbilityDefinitionRegistry
|
||||
{
|
||||
private readonly IReadOnlyList<AbilityDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition)
|
||||
{
|
||||
if (abilityId is null)
|
||||
{
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return catalog.TryGetAbility(abilityId, out definition);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryNormalizeKnown(string? rawAbilityId, [NotNullWhen(true)] out string normalized)
|
||||
{
|
||||
if (rawAbilityId is null)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = rawAbilityId.Trim().ToLowerInvariant();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return catalog.TryGetAbility(normalized, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<AbilityDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
|
||||
|
||||
private static IReadOnlyList<AbilityDefRow> BuildDefinitionsInIdOrder(AbilityDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var list = new List<AbilityDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
list.Add(catalog.ById[id]);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/world/ability-definitions</c> (NEO-78).</summary>
|
||||
public sealed class AbilityDefinitionsListResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
/// <summary>Loaded abilities ordered by stable <c>id</c> (ordinal), matching <see cref="IAbilityDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
|
||||
[JsonPropertyName("abilities")]
|
||||
public required IReadOnlyList<AbilityDefinitionJson> Abilities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One row in the read-only ability definition projection.</summary>
|
||||
public sealed class AbilityDefinitionJson
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("baseDamage")]
|
||||
public required int BaseDamage { get; init; }
|
||||
|
||||
[JsonPropertyName("cooldownSeconds")]
|
||||
public required double CooldownSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("abilityKind")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? AbilityKind { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Maps <c>GET /game/world/ability-definitions</c> (NEO-78).</summary>
|
||||
public static class AbilityDefinitionsWorldApi
|
||||
{
|
||||
public static WebApplication MapAbilityDefinitionsWorldApi(this WebApplication app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/game/world/ability-definitions",
|
||||
(IAbilityDefinitionRegistry registry) =>
|
||||
{
|
||||
var defs = registry.GetDefinitionsInIdOrder();
|
||||
var abilities = new List<AbilityDefinitionJson>(defs.Count);
|
||||
foreach (var d in defs)
|
||||
{
|
||||
abilities.Add(
|
||||
new AbilityDefinitionJson
|
||||
{
|
||||
Id = d.Id,
|
||||
DisplayName = d.DisplayName,
|
||||
BaseDamage = d.BaseDamage,
|
||||
CooldownSeconds = d.CooldownSeconds,
|
||||
AbilityKind = d.AbilityKind,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Json(
|
||||
new AbilityDefinitionsListResponse
|
||||
{
|
||||
SchemaVersion = AbilityDefinitionsListResponse.CurrentSchemaVersion,
|
||||
Abilities = abilities,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Registers prototype combat entity health store (NEO-80).</summary>
|
||||
public static class CombatEntityHealthServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers <see cref="ICombatEntityHealthStore"/> as an in-memory singleton.</summary>
|
||||
public static IServiceCollection AddCombatEntityHealthStore(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ICombatEntityHealthStore, InMemoryCombatEntityHealthStore>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Authoritative HP snapshot for a prototype combat target (NEO-80).</summary>
|
||||
/// <param name="TargetId">Normalized lowercase target id.</param>
|
||||
/// <param name="MaxHp">Catalog max HP for prototype dummies.</param>
|
||||
/// <param name="CurrentHp">Current HP after damage; floored at zero.</param>
|
||||
/// <param name="Defeated"><c>true</c> when <paramref name="CurrentHp"/> is zero.</param>
|
||||
public readonly record struct CombatEntityHealthSnapshot(
|
||||
string TargetId,
|
||||
int MaxHp,
|
||||
int CurrentHp,
|
||||
bool Defeated);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to validated <see cref="AbilityDefRow"/> entries loaded at startup (<see cref="AbilityDefinitionCatalog"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>E5.M1 (combat / hotbar / cast):</b> callers validating ability ids and resolving damage/cooldown metadata should depend on this interface
|
||||
/// rather than <see cref="AbilityDefinitionCatalog"/> so ability defs stay centralized.</para>
|
||||
/// <para><b>NEO-78:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
|
||||
/// </remarks>
|
||||
public interface IAbilityDefinitionRegistry
|
||||
{
|
||||
/// <summary>Attempts to resolve an ability by stable <c>id</c> (see <c>ability-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||
bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition);
|
||||
|
||||
/// <summary>Trims and lowercases <paramref name="rawAbilityId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog. <c>null</c>, empty, and whitespace return <c>false</c> without throwing.</summary>
|
||||
bool TryNormalizeKnown(string? rawAbilityId, [NotNullWhen(true)] out string normalized);
|
||||
|
||||
/// <summary>Every loaded definition, ordered by <see cref="AbilityDefRow.Id"/> (ordinal).</summary>
|
||||
IReadOnlyList<AbilityDefRow> GetDefinitionsInIdOrder();
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Server-owned HP store for prototype combat dummies keyed by
|
||||
/// <see cref="Targeting.PrototypeTargetRegistry"/> target id (NEO-80).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>NEO-81+:</b> combat resolution should depend on this interface rather than reaching into store internals.</para>
|
||||
/// <para>Re-hit deny on defeated targets belongs in <c>CombatOperations</c> (NEO-81), not this store.</para>
|
||||
/// </remarks>
|
||||
public interface ICombatEntityHealthStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the current snapshot for a known prototype target. Lazy-initializes HP to
|
||||
/// <see cref="PrototypeCombatConstants.MaxPrototypeTargetHp"/> on first access.
|
||||
/// </summary>
|
||||
bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Applies non-negative damage to a known prototype target. Lazy-initializes on first access.
|
||||
/// Floors <see cref="CombatEntityHealthSnapshot.CurrentHp"/> at zero.
|
||||
/// </summary>
|
||||
bool TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Restores a known prototype target to full HP and clears <see cref="CombatEntityHealthSnapshot.Defeated"/>.
|
||||
/// Creates the row when absent. Intended for tests and future dev fixtures — not HTTP in Slice 1.
|
||||
/// </summary>
|
||||
bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
using System.Collections.Concurrent;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Thread-safe in-memory HP for prototype combat dummies; empty at startup — lazy rows only (NEO-80).</summary>
|
||||
public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, int> currentHpById = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot)
|
||||
{
|
||||
var key = NormalizeTargetId(targetId);
|
||||
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
EnsureRowInitialized(key);
|
||||
snapshot = CreateSnapshot(key, currentHpById[key]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot)
|
||||
{
|
||||
snapshot = default;
|
||||
if (damage < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = NormalizeTargetId(targetId);
|
||||
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
EnsureRowInitialized(key);
|
||||
var current = currentHpById[key];
|
||||
var next = Math.Max(0, current - damage);
|
||||
currentHpById[key] = next;
|
||||
snapshot = CreateSnapshot(key, next);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot)
|
||||
{
|
||||
var key = NormalizeTargetId(targetId);
|
||||
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
currentHpById[key] = PrototypeCombatConstants.MaxPrototypeTargetHp;
|
||||
snapshot = CreateSnapshot(key, PrototypeCombatConstants.MaxPrototypeTargetHp);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRowInitialized(string normalizedId)
|
||||
{
|
||||
if (!currentHpById.ContainsKey(normalizedId))
|
||||
{
|
||||
currentHpById[normalizedId] = PrototypeCombatConstants.MaxPrototypeTargetHp;
|
||||
}
|
||||
}
|
||||
|
||||
private static CombatEntityHealthSnapshot CreateSnapshot(string targetId, int currentHp) =>
|
||||
new(
|
||||
targetId,
|
||||
PrototypeCombatConstants.MaxPrototypeTargetHp,
|
||||
currentHp,
|
||||
currentHp <= 0);
|
||||
|
||||
private static string NormalizeTargetId(string? targetId) =>
|
||||
targetId?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Frozen prototype combat tuning for E5.M1 Slice 1 (NEO-80).</summary>
|
||||
public static class PrototypeCombatConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared max HP for <see cref="Targeting.PrototypeTargetRegistry"/> combat dummies.
|
||||
/// Four <c>prototype_pulse</c> casts at 25 damage each defeat one dummy (100 HP).
|
||||
/// </summary>
|
||||
public const int MaxPrototypeTargetHp = 100;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c>
|
||||
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeE5M1AbilityCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M1_ABILITY_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedAbilityIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"prototype_pulse",
|
||||
"prototype_guard",
|
||||
"prototype_dash",
|
||||
"prototype_burst",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the E5M1 ability contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> abilityIdToSourceFile)
|
||||
{
|
||||
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedAbilityIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E5M1 expects exactly ability ids " +
|
||||
$"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -76,4 +76,16 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-io-row.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? RecipeIoRowSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/abilities</c> directory.
|
||||
/// </summary>
|
||||
public string? AbilitiesDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>ability-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of abilities directory}/schemas/ability-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? AbilityDefSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
|
|
@ -19,6 +20,7 @@ builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
|||
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
||||
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -26,6 +28,7 @@ _ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
|||
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||
|
||||
|
|
@ -45,6 +48,7 @@ app.MapInteractablesWorldApi();
|
|||
app.MapSkillDefinitionsWorldApi();
|
||||
app.MapItemDefinitionsWorldApi();
|
||||
app.MapRecipeDefinitionsWorldApi();
|
||||
app.MapAbilityDefinitionsWorldApi();
|
||||
app.MapResourceNodeDefinitionsWorldApi();
|
||||
app.MapPlayerInventoryApi();
|
||||
app.MapPlayerCraftApi();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@
|
|||
"ItemDefSchemaPath": "",
|
||||
"RecipesDirectory": "",
|
||||
"RecipeDefSchemaPath": "",
|
||||
"RecipeIoRowSchemaPath": ""
|
||||
"RecipeIoRowSchemaPath": "",
|
||||
"AbilitiesDirectory": "",
|
||||
"AbilityDefSchemaPath": ""
|
||||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,40 @@ On startup the host loads every **`*_recipes.json`** under the recipes directory
|
|||
|
||||
On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code.
|
||||
|
||||
## Ability catalog (`content/abilities`, NEO-77)
|
||||
|
||||
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||
|
||||
| Config | Meaning |
|
||||
|--------|---------|
|
||||
| **`Content:AbilitiesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/abilities`**. |
|
||||
| **`Content:AbilityDefSchemaPath`** | Optional override for **`ability-def.schema.json`**. When unset, **`{parent of abilities directory}/schemas/ability-def.schema.json`**. |
|
||||
|
||||
**Docker / CI:** include **`content/abilities`** and **`content/schemas/ability-def.schema.json`** in the mounted **`content/`** tree; set **`Content__AbilitiesDirectory`** when layout differs.
|
||||
|
||||
On success, **Information** logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use **`IAbilityDefinitionRegistry`** for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; do not inject **`AbilityDefinitionCatalog`** in new game code. Hotbar loadout and ability cast routes validate ability ids via **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (backed by the loaded catalog, not a separate static allowlist).
|
||||
|
||||
## Ability definitions (NEO-78)
|
||||
|
||||
**`GET /game/world/ability-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`abilities`**) backed by **`IAbilityDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, and **`abilityKind`** when present in content. Plan: [NEO-78 implementation plan](../../docs/plans/NEO-78-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/ability-definitions/`.
|
||||
|
||||
```bash
|
||||
curl -sS -i "http://localhost:5253/game/world/ability-definitions"
|
||||
```
|
||||
|
||||
## Combat entity health (NEO-80)
|
||||
|
||||
Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeTargetRegistry`** ids (**`prototype_target_alpha`**, **`prototype_target_beta`**) only. Max HP is frozen at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** — four **`prototype_pulse`** casts (25 damage each) defeat one dummy.
|
||||
|
||||
| Policy | Behavior |
|
||||
|--------|----------|
|
||||
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id. |
|
||||
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is **NEO-81** (`CombatOperations`). |
|
||||
| **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives a dummy for tests/fixtures — not exposed over HTTP in Slice 1. |
|
||||
| **HTTP read** | **`GET /game/world/combat-targets`** is **NEO-83**; cast wiring is **NEO-82**. |
|
||||
|
||||
Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md).
|
||||
|
||||
## Recipe definitions (NEO-68)
|
||||
|
||||
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue