Compare commits
120 Commits
9c35ad1e08
...
8e04cfc67b
| Author | SHA1 | Date |
|---|---|---|
|
|
8e04cfc67b | |
|
|
07ca427f2f | |
|
|
d0622701eb | |
|
|
8de08a67a8 | |
|
|
d6a80ed775 | |
|
|
434226a069 | |
|
|
6cd2c0cdda | |
|
|
b52428e898 | |
|
|
adca7befe0 | |
|
|
aa013d4be7 | |
|
|
4682db48c4 | |
|
|
cdde994c7e | |
|
|
68b9b9c8c8 | |
|
|
c6af6f7c41 | |
|
|
5c9e1d3fc3 | |
|
|
ab670d0e26 | |
|
|
e7a61a1b3f | |
|
|
15fcc09493 | |
|
|
8e44d81537 | |
|
|
751fea3db4 | |
|
|
1273296c73 | |
|
|
87b97cbd1f | |
|
|
5f0f703555 | |
|
|
2be35c1640 | |
|
|
2d4751021b | |
|
|
194078dd3b | |
|
|
1be4a7eed8 | |
|
|
87c27e231b | |
|
|
2221dc6b4b | |
|
|
db7c443b50 | |
|
|
8ebe83d701 | |
|
|
5a3904c6bc | |
|
|
9d6d358956 | |
|
|
46393b8ca5 | |
|
|
b148db8a61 | |
|
|
5c49029240 | |
|
|
c5115a3a13 | |
|
|
36adfd1897 | |
|
|
51e9f1f5d0 | |
|
|
b381cadf23 | |
|
|
308de6b50d | |
|
|
e914d5fb96 | |
|
|
bba1415d54 | |
|
|
9264b74188 | |
|
|
529cb616f5 | |
|
|
1636fdc2cb | |
|
|
8eccb14898 | |
|
|
6de6a1781f | |
|
|
3861cc5e0c | |
|
|
95d42803e1 | |
|
|
2fc3b8f750 | |
|
|
81b0e3fc4b | |
|
|
7188c5b558 | |
|
|
565906ff8c | |
|
|
1fb4368a2c | |
|
|
c279eed572 | |
|
|
f209636e7e | |
|
|
ccf4cf8e5c | |
|
|
018c8a7466 | |
|
|
27ec7c02a7 | |
|
|
d4a4795eb1 | |
|
|
966c54ac74 | |
|
|
b4f27b4a18 | |
|
|
4c82283bbc | |
|
|
b4b8536081 | |
|
|
2cd17e293a | |
|
|
458e9b10e8 | |
|
|
0556d5fa0f | |
|
|
fa7bcce78b | |
|
|
a578d9b684 | |
|
|
1bcaa1b350 | |
|
|
847cda97a3 | |
|
|
f39d12a093 | |
|
|
052dd7bc8a | |
|
|
0f846f90b9 | |
|
|
3834b2316a | |
|
|
35381ad749 | |
|
|
01dc48a50a | |
|
|
e681857778 | |
|
|
f58ab4a2eb | |
|
|
f23af01687 | |
|
|
acf9fe125f | |
|
|
ef5a577c4e | |
|
|
5dac6b2825 | |
|
|
0cc5bb8925 | |
|
|
0dfbe769e7 | |
|
|
6d2d3c040a | |
|
|
869d411b6f | |
|
|
4734df1bbc | |
|
|
2b66df86b8 | |
|
|
b60920b2e2 | |
|
|
97f8f31682 | |
|
|
78dbf1c454 | |
|
|
878454e3ed | |
|
|
1e1ca5519c | |
|
|
dc66b5cef4 | |
|
|
01dec2b6c8 | |
|
|
bd43951f80 | |
|
|
4921f2c25b | |
|
|
6748a848af | |
|
|
44fc8d7b62 | |
|
|
6656853473 | |
|
|
fdaf5b7231 | |
|
|
0384b63046 | |
|
|
2de699b7bd | |
|
|
8c1e0552b3 | |
|
|
fba6bff24f | |
|
|
f0a4183598 | |
|
|
ca1a15fce1 | |
|
|
2584947953 | |
|
|
b68d74b0b8 | |
|
|
b7f29dcd6a | |
|
|
17daf9a9ca | |
|
|
597c3873d4 | |
|
|
011289a154 | |
|
|
deed0c4314 | |
|
|
b8e0cce582 | |
|
|
dc5141ed03 | |
|
|
786513ba56 | |
|
|
3d43b00fa9 |
|
|
@ -50,6 +50,7 @@ Work through what applies to the diff (skip irrelevant sections briefly).
|
|||
6. **Observability** — Logging/metrics where failures would be hard to diagnose (server/runtime code).
|
||||
7. **Docs** — README or plan updates when behavior or run instructions change.
|
||||
8. **Plan & decomposition alignment** — Per **Plan and decomposition documentation** above: relevant `docs/plans/` and module/policy docs cited; implementation checked against them; **Documentation checked** section in the saved review file.
|
||||
9. **Full-stack (player-visible work)** — Per [full-stack-epic-decomposition](full-stack-epic-decomposition.md): if the PR claims a **prototype slice** or **player-facing feature** is complete, verify a **`client`** Linear issue exists in the backlog and manual QA covers **Godot** (not Bruno-only). Missing client counterpart → **should fix**; claiming epic slice done without client → **blocking** when merge message or plan says “prototype complete.”
|
||||
|
||||
## Code review document (required)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,8 +41,7 @@ Work through what applies (skip irrelevant briefly).
|
|||
5. **Implementation hooks** — IDs, modules (E2.M1, …), open vs agreed; what’s missing for tickets.
|
||||
6. **Links & anchors** — Broken paths; fragile `#anchors` (prefer stable HTML `id` where needed).
|
||||
7. **Next steps** — Obvious follow-up artifacts or epic/plan updates.
|
||||
|
||||
## Review document (required)
|
||||
8. **Full-stack backlogs** — For `docs/plans/*backlog*.md`: every player-visible story has a **`client`** Linear counterpart per [full-stack-epic-decomposition](full-stack-epic-decomposition.md); flag “optional follow-up” / “future client” without NEO-XX as **should fix**.
|
||||
|
||||
**Every** invocation must produce a **saved markdown file** under `docs/reviews/`, not only chat (same spirit as [code-review-agent](code-review-agent.md)).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
description: Epics deliver playable game features — prototype backlogs must pair server + client stories; forbid Bruno-only "optional UI" deferrals.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Full-stack epic decomposition (CRITICAL)
|
||||
|
||||
Neon Sprawl is a **game**, not a headless server. Prototype backlogs and epic slices must ship **Godot presentation** for every **player-visible** feature.
|
||||
|
||||
## When this rule applies
|
||||
|
||||
- Writing or editing **`docs/plans/*backlog*.md`**, **`docs/plans/E*S*-client*.md`**, or **implementation slices** in **`docs/decomposition/epics/epic_*.md`**
|
||||
- Creating **Linear issues** during decomposition (not at ad-hoc kickoff without client counterparts)
|
||||
- Reviewing whether an epic/module slice is **“decomposed”** or **“prototype complete”**
|
||||
|
||||
Reference: [decomposition README — Full-stack epics](../../docs/decomposition/README.md#full-stack-epics), [prototype backlog template](../../docs/plans/_prototype-backlog-template.md).
|
||||
|
||||
## Canonical pattern
|
||||
|
||||
Mirror **Epic 1** ([E1M3-prototype-backlog](../../docs/plans/E1M3-prototype-backlog.md)): server contract story immediately followed by client HUD/wiring (e.g. NEO-23 → NEO-24).
|
||||
|
||||
For large epics, a dedicated **client slice** file is OK ([E3S5-client-prototype-backlog.md](../../docs/plans/E3S5-client-prototype-backlog.md)) — but **client Linear issues must exist in the same decomposition pass** as server issues, with `blockedBy` links.
|
||||
|
||||
## Story classification
|
||||
|
||||
| Type | Client story required? | Examples |
|
||||
|------|------------------------|----------|
|
||||
| **Infrastructure** | **No** | JSON Schema, CI gates, server catalog load, DB migrations, comment-only telemetry hooks |
|
||||
| **Player-visible** | **Yes** | Inventory, gather/craft, quests, zone/PvP warnings, combat telegraphs, skill/perk UI, any AC with *player*, *visible*, *HUD*, *select*, *complete*, *understand* |
|
||||
| **Capstone** | **Yes** | “Playable loop in Godot without Bruno” (e.g. NEO-75) |
|
||||
|
||||
Tag **infrastructure-only** stories explicitly in the backlog so agents do not skip UI for the wrong reasons.
|
||||
|
||||
## Mandatory backlog contents
|
||||
|
||||
When creating `docs/plans/E*M*-prototype-backlog.md` (copy [\_prototype-backlog-template.md](../../docs/plans/_prototype-backlog-template.md)):
|
||||
|
||||
1. **Server + client story tables** — every player-visible slug lists **`Client counterpart: NEO-XX`** or points to a client slice slug.
|
||||
2. **Linear issues created together** — all server **and** client issues + **`blockedBy`** in one decomposition pass; label client issues **`client`** + module id (`E3.M1`, etc.).
|
||||
3. **Capstone client story** — at least one issue proving the slice is **playable in Godot** (manual QA in `docs/manual-qa/{KEY}.md`), unless the entire epic slice is infrastructure-only (rare).
|
||||
4. **Epic DoD updated** — prototype Definition of Done says **Godot**, not Bruno-only (see [epic_03 example](../../docs/decomposition/epics/epic_03_crafting_economy.md)).
|
||||
|
||||
## Forbidden language
|
||||
|
||||
Do **not** use in backlogs or Linear descriptions for player-visible work:
|
||||
|
||||
- “Godot HUD (**optional follow-up**)”
|
||||
- “for manual QA and **future client**” (without a linked **NEO-XX** client issue)
|
||||
- “client UI deferred” without a **created** client issue id
|
||||
|
||||
Use **Out of scope** only for genuinely non-prototype UX (e.g. drag-drop inventory, final art pass).
|
||||
|
||||
## Bruno vs Godot
|
||||
|
||||
- **Bruno** — proves HTTP contracts and server authority.
|
||||
- **Godot** — proves [vision prototype gates](../../neon_sprawl_vision.plan.md) (fun, readability, progression visibility).
|
||||
|
||||
Do **not** mark a **player-facing** epic slice **complete** when only Bruno passes.
|
||||
|
||||
## Kickoff and implementation
|
||||
|
||||
- **Story kickoff** for a **server** issue on a player-visible path: plan must **cross-link** the **client counterpart** Linear issue (even if not started). See [story-kickoff](story-kickoff.md).
|
||||
- **Manual QA** ([planning-implementation-docs](planning-implementation-docs.md)): player-visible **client** stories require `docs/manual-qa/{KEY}.md` with **Godot** steps, not API-only checks.
|
||||
- **Client scripts:** thin `main.gd`, HTTP client nodes — [godot-client-script-organization](godot-client-script-organization.md).
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
Before calling an epic slice **decomposed**:
|
||||
|
||||
- [ ] Every player-visible acceptance criterion maps to a **`client`** Linear issue
|
||||
- [ ] No forbidden deferral language in backlog
|
||||
- [ ] Capstone or client slice issue exists for playable verification
|
||||
- [ ] Epic / module docs and [documentation_and_implementation_alignment.md](../../docs/decomposition/modules/documentation_and_implementation_alignment.md) mention client backlog
|
||||
- [ ] [module_dependency_register.md](../../docs/decomposition/modules/module_dependency_register.md) note updated when issues are created
|
||||
|
||||
## Code review
|
||||
|
||||
Reviewers (human or [code-review-agent](code-review-agent.md)) **should fix** or **block** claims that a **player-facing** prototype slice is done when backlog has **no client issue** or manual QA is Bruno-only.
|
||||
|
|
@ -12,12 +12,22 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
|
|||
- During kickoff and while drafting the plan, **ask the user** about anything **unclear**, **underspecified**, or that **needs a decision** (scope, behavior, acceptance criteria, priorities, trade-offs, or what is out of scope). Do not guess or pick silent defaults when the issue, Linear notes, or repo context do not settle it.
|
||||
- Use **concrete questions**, often with short options when helpful; group related questions so the user can answer in one pass.
|
||||
- For **every** question or blocking decision presented to the user, state the **agent’s recommendation**: which option to pick (or what to do) and a **one-line rationale** tied to repo precedent, Linear AC, or decomposition docs. Do not list options without a stated preference unless there is genuinely no basis to recommend—in that case, say why.
|
||||
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists. Put the **agent recommendation** in the chat message that introduces the questions (`AskQuestion` has no recommendation field).
|
||||
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists. **`AskQuestion` has no recommendation field** — recommendations are **never** optional and **never** deferred to the plan alone.
|
||||
|
||||
### AskQuestion + recommendations (required order)
|
||||
|
||||
When you use **`AskQuestion`** for kickoff blocking decisions, follow **all** of this in the **same assistant turn**:
|
||||
|
||||
1. **Chat first (mandatory):** In the user-visible message, include a **Recommend:** line (or compact table) for **each** topic — **before** any `AskQuestion` tool call. Format: topic → recommended option → one-line rationale (repo precedent, Linear AC, or decomposition doc).
|
||||
2. **Then `AskQuestion`:** Invoke the tool only after step 1 is written in chat. A turn that calls **`AskQuestion` without step 1 is a kickoff violation** — do not draft or commit the implementation plan in that turn; fix the message and re-ask.
|
||||
3. **Label backup:** Mark the recommended option in the **`AskQuestion` option label** as well (e.g. `Engine only (recommended)`). This does **not** replace step 1; it is a fallback if the preamble is truncated.
|
||||
|
||||
**Invalid (do not do):** `AskQuestion` alone with bare option lists and no **Recommend:** preamble. **Invalid:** recommendations only inside `docs/plans/{KEY}-implementation-plan.md` before the user has answered.
|
||||
|
||||
### Clarifications gate (hard requirement)
|
||||
|
||||
- **Do not** create or update `docs/plans/{KEY}-implementation-plan.md` until clarification questions have been asked and the user has answered (or explicitly says no questions are needed).
|
||||
- If at least one **blocking decision** exists in planning, use **`AskQuestion`** (Agent mode) instead of prose-only questions.
|
||||
- If at least one **blocking decision** exists in planning, use **`AskQuestion`** (Agent mode) per **AskQuestion + recommendations (required order)** above — not prose-only lists, and not `AskQuestion` without a **Recommend:** preamble.
|
||||
- If kickoff has zero questions, explicitly record why in the plan under a **Kickoff clarifications** section with evidence from Linear + repo context.
|
||||
- **Do not commit** a kickoff plan until the clarification step above is complete.
|
||||
|
||||
|
|
@ -48,6 +58,8 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
|
|||
|
||||
- Do not add or change source, Godot scenes/project, game data pipelines, or automated tests until step 4 is done **and** the user has moved past planning in chat (e.g. explicit go-ahead to implement).
|
||||
|
||||
**Full-stack:** If the story is **server-only** but **player-visible** (HTTP API without Godot yet), the kickoff plan must **cross-link** the **client counterpart** Linear issue from the module backlog ([full-stack-epic-decomposition](full-stack-epic-decomposition.md)). Do not treat Bruno-only verification as prototype-complete.
|
||||
|
||||
## 4. Planning document
|
||||
|
||||
- Add **`docs/plans/{LINEAR_ISSUE_ID}-implementation-plan.md`** (example: `docs/plans/NEO-5-implementation-plan.md`). Create `docs/plans/` if it does not exist.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
|
|||
| **Code review** | [`.cursor/rules/code-review-agent.md`](.cursor/rules/code-review-agent.md) | PR / diff / pre-merge review; **always writes** `docs/reviews/YYYY-MM-DD-{slug}.md` with **Documentation checked** vs `docs/plans/` and `docs/decomposition/modules/`; **when suggestions are fixed, strike through those bullets + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); short chat pointer |
|
||||
| **Docs review** | [`.cursor/rules/docs-review-agent.md`](.cursor/rules/docs-review-agent.md) | Coherence / links / dev-guide fitness for `docs/` (especially `docs/game-design/` + decomposition); **writes** `docs/reviews/YYYY-MM-DD-{slug}.md`; **when suggestions are fixed, strike through + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); use when working in **documents** or @ mention |
|
||||
|
||||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Automated tests (AAA, mandatory):** **C#** — every new or changed **`[Fact]` / `[Theory]`** method in **`*Tests.cs`** must use full **Arrange → Act → Assert**: the three **`// Arrange` / `// Act` / `// Assert`** labels, **Act** = behavior under test only, **Assert** = all expectations including **`ReadFromJsonAsync`** (etc.) for verification (no blank line required after the phase comments) — [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), [testing-expectations](.cursor/rules/testing-expectations.md); snippets **`xut`** / **`xutc`**. **GdUnit (`client/test/`)** — same AAA with **`# Arrange` / `# Act` / `# Assert`** — [gdscript-style](.cursor/rules/gdscript-style.md#gdunit-test-layout-aaa). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** on Linear/story work, agents **commit as they go** on the story branch (kickoff plan, coherent batches)—**do not wait** for the user to say “commit”; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use the **GitHub MCP** **`create_pull_request`** tool (`user-github`) with a title starting **`NEO-123:`** ([commit-and-review](.cursor/rules/commit-and-review.md)); **do not use `gh` CLI for GitHub operations in this repo**. **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
|
||||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Full-stack epics:** prototype backlogs must pair **server + client** Linear issues for player-visible features — [`.cursor/rules/full-stack-epic-decomposition.md`](.cursor/rules/full-stack-epic-decomposition.md), template [`docs/plans/_prototype-backlog-template.md`](docs/plans/_prototype-backlog-template.md). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Automated tests (AAA, mandatory):** **C#** — every new or changed **`[Fact]` / `[Theory]`** method in **`*Tests.cs`** must use full **Arrange → Act → Assert**: the three **`// Arrange` / `// Act` / `// Assert`** labels, **Act** = behavior under test only, **Assert** = all expectations including **`ReadFromJsonAsync`** (etc.) for verification (no blank line required after the phase comments) — [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), [testing-expectations](.cursor/rules/testing-expectations.md); snippets **`xut`** / **`xutc`**. **GdUnit (`client/test/`)** — same AAA with **`# Arrange` / `# Act` / `# Assert`** — [gdscript-style](.cursor/rules/gdscript-style.md#gdunit-test-layout-aaa). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** on Linear/story work, agents **commit as they go** on the story branch (kickoff plan, coherent batches)—**do not wait** for the user to say “commit”; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use the **GitHub MCP** **`create_pull_request`** tool (`user-github`) with a title starting **`NEO-123:`** ([commit-and-review](.cursor/rules/commit-and-review.md)); **do not use `gh` CLI for GitHub operations in this repo**. **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md) — blocking decisions need a **Recommend:** preamble in chat **before** `AskQuestion`, plus **(recommended)** on the chosen option label; `AskQuestion` alone is a violation. After merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
|
||||
|
||||
**Commits tied to a Linear issue** must start the subject with the **issue id** and a colon (e.g. `NEO-8: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/linear-git-naming.md`](.cursor/rules/linear-git-naming.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
meta {
|
||||
name: POST craft deny insufficient materials
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-70: make_field_stim_mk0 without refined_plate_stock denies with insufficient_materials. Pre-request clears refined stock (inventory POST setup only) so the deny is stable after dotnet test / prior Bruno requests.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const { getInventory, sumItemQuantity, postMutation } = require("./scripts/inventory-api-helper.js");
|
||||
|
||||
async function clearItem(itemId) {
|
||||
let qty = sumItemQuantity(await getInventory(bru), itemId);
|
||||
while (qty > 0) {
|
||||
const removeQty = Math.min(qty, 99);
|
||||
const response = await postMutation(bru, {
|
||||
schemaVersion: 1,
|
||||
mutationKind: "remove",
|
||||
itemId,
|
||||
quantity: removeQty,
|
||||
});
|
||||
if (response.status !== 200 || response.data?.applied !== true) {
|
||||
throw new Error(`clear ${itemId} failed: ${response.status} ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
qty = sumItemQuantity(await getInventory(bru), itemId);
|
||||
}
|
||||
}
|
||||
|
||||
await clearItem("refined_plate_stock");
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipeId": "make_field_stim_mk0"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies with insufficient_materials", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.success).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("insufficient_materials");
|
||||
expect(body.inputsConsumed).to.be.an("array").that.is.empty;
|
||||
expect(body.outputsGranted).to.be.an("array").that.is.empty;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
meta {
|
||||
name: POST craft deny invalid quantity
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-70: non-positive quantity denies with invalid_quantity without consuming inputs.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const { ensureItemQuantity, getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
|
||||
await ensureItemQuantity(bru, "scrap_metal_bulk", 5);
|
||||
const inventory = await getInventory(bru);
|
||||
bru.setVar("scrapBeforeInvalidQuantityDeny", sumItemQuantity(inventory, "scrap_metal_bulk"));
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipeId": "refine_scrap_standard",
|
||||
"quantity": 0
|
||||
}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
|
||||
const inventory = await getInventory(bru);
|
||||
bru.setVar("scrapAfterInvalidQuantityDeny", sumItemQuantity(inventory, "scrap_metal_bulk"));
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies with invalid_quantity without consuming scrap", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.success).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("invalid_quantity");
|
||||
expect(bru.getVar("scrapAfterInvalidQuantityDeny")).to.equal(
|
||||
bru.getVar("scrapBeforeInvalidQuantityDeny"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
meta {
|
||||
name: POST craft deny inventory full
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-70: make_prototype_armor when equipment slot is occupied denies with inventory_full. Pre-request seeds materials and fills equipment via inventory POST (deny setup only — not part of spine).
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const { ensureItemQuantity } = require("./scripts/inventory-api-helper.js");
|
||||
|
||||
await ensureItemQuantity(bru, "refined_plate_stock", 8);
|
||||
await ensureItemQuantity(bru, "scrap_metal_bulk", 5);
|
||||
await ensureItemQuantity(bru, "prototype_armor_shell", 1);
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipeId": "make_prototype_armor"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies with inventory_full when equipment slot occupied", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.success).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("inventory_full");
|
||||
expect(body.inputsConsumed).to.be.an("array").that.is.empty;
|
||||
expect(body.outputsGranted).to.be.an("array").that.is.empty;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
meta {
|
||||
name: POST craft deny unknown recipe
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-70: unknown recipeId denies with unknown_recipe.
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipeId": "not_a_real_recipe"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies with unknown_recipe", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.success).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("unknown_recipe");
|
||||
expect(body.inputsConsumed).to.be.an("array").that.is.empty;
|
||||
expect(body.outputsGranted).to.be.an("array").that.is.empty;
|
||||
expect(body.xpGrantSummary).to.equal(null);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
meta {
|
||||
name: POST craft gather refine make spine
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. No inventory POST shortcuts for spine materials.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||
|
||||
const progressionBefore = await axios.get(`${baseUrl}/game/players/${playerId}/skill-progression`);
|
||||
const refineBefore = progressionBefore.data.skills.find((row) => row.id === "refine");
|
||||
bru.setVar("refineXpBeforeSpine", refineBefore ? refineBefore.xp : 0);
|
||||
|
||||
async function currentScrap() {
|
||||
return sumItemQuantity(await getInventory(bru), "scrap_metal_bulk");
|
||||
}
|
||||
|
||||
async function currentRefined() {
|
||||
return sumItemQuantity(await getInventory(bru), "refined_plate_stock");
|
||||
}
|
||||
|
||||
async function moveNear(x, z) {
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/move`,
|
||||
{ schemaVersion: 1, target: { x, y: 0.9, z } },
|
||||
jsonHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
async function tryGather(interactableId) {
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{ schemaVersion: 1, interactableId },
|
||||
jsonHeaders,
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`gather ${interactableId} HTTP ${response.status}`);
|
||||
}
|
||||
if (response.data?.allowed === true) {
|
||||
return;
|
||||
}
|
||||
if (response.data?.reasonCode === "node_depleted") {
|
||||
return;
|
||||
}
|
||||
throw new Error(`gather ${interactableId} denied: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
async function craft(recipeId) {
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/craft`,
|
||||
{ schemaVersion: 1, recipeId },
|
||||
jsonHeaders,
|
||||
);
|
||||
if (response.status !== 200 || response.data?.success !== true) {
|
||||
throw new Error(`craft ${recipeId} failed: ${response.status} ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const gatherPlan = [
|
||||
{ id: "prototype_urban_bulk_delta", x: 16, z: -2 },
|
||||
{ id: "prototype_bio_mat_gamma", x: 10, z: -2 },
|
||||
{ id: "prototype_subsurface_vein_beta", x: 16, z: -6 },
|
||||
{ id: "prototype_resource_node_alpha", x: 10, z: -6 },
|
||||
];
|
||||
|
||||
let scrap = await currentScrap();
|
||||
for (const node of gatherPlan) {
|
||||
if (scrap >= 11) {
|
||||
break;
|
||||
}
|
||||
await moveNear(node.x, node.z);
|
||||
await tryGather(node.id);
|
||||
scrap = await currentScrap();
|
||||
}
|
||||
|
||||
if (scrap < 11) {
|
||||
throw new Error(`spine needs >=11 scrap_metal_bulk, have ${scrap} after optional gathers`);
|
||||
}
|
||||
|
||||
if ((await currentRefined()) < 2) {
|
||||
await craft("refine_scrap_efficient");
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipeId": "make_field_stim_mk0"
|
||||
}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
|
||||
const inventory = await axios.get(`${baseUrl}/game/players/${playerId}/inventory`);
|
||||
const progression = await axios.get(`${baseUrl}/game/players/${playerId}/skill-progression`);
|
||||
|
||||
bru.setVar("spineInventory", inventory.data);
|
||||
bru.setVar("spineProgression", progression.data);
|
||||
}
|
||||
|
||||
tests {
|
||||
test("make_field_stim_mk0 succeeds after gather and refine", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.success).to.equal(true);
|
||||
expect(body.reasonCode).to.equal(null);
|
||||
expect(body.outputsGranted).to.deep.include({ itemId: "field_stim_mk0", quantity: 1 });
|
||||
expect(body.xpGrantSummary).to.be.an("object");
|
||||
expect(body.xpGrantSummary.skillId).to.equal("refine");
|
||||
expect(body.xpGrantSummary.amount).to.equal(10);
|
||||
expect(body.xpGrantSummary.sourceKind).to.equal("activity");
|
||||
});
|
||||
|
||||
test("inventory includes field_stim_mk0 after spine", function () {
|
||||
const inventory = bru.getVar("spineInventory");
|
||||
const total = inventory.bagSlots
|
||||
.filter((slot) => slot.itemId === "field_stim_mk0")
|
||||
.reduce((sum, slot) => sum + slot.quantity, 0);
|
||||
expect(total).to.be.at.least(1);
|
||||
});
|
||||
|
||||
test("refine skill XP increased after spine crafts", function () {
|
||||
const progression = bru.getVar("spineProgression");
|
||||
const refine = progression.skills.find((row) => row.id === "refine");
|
||||
const before = bru.getVar("refineXpBeforeSpine");
|
||||
expect(refine).to.be.an("object");
|
||||
expect(refine.xp - before).to.be.at.least(10);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
meta {
|
||||
name: craft
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-70 craft HTTP. CraftResponse emits explicit null for reasonCode / xpGrantSummary when absent (mirror inventory POST). Run order within folder: deny requests (seq 1–4) then spine (seq 5). Full collection runs after dotnet test on shared Postgres — insufficient-materials clears refined stock; spine reuses persisted scrap and tolerates node_depleted gathers. Deny setup requests may use inventory POST only where noted.
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
meta {
|
||||
name: POST interact depleted node deny
|
||||
type: http
|
||||
seq: 13
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request exhausts the node (10 successful gathers), then this interact should deny with node_depleted.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const nodeId = "prototype_urban_bulk_delta";
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/move`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
target: { x: 16, y: 0.9, z: 0 },
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
interactableId: nodeId,
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
if (!res.data.allowed) {
|
||||
throw new Error(`expected gather ${i + 1}/10 to succeed, got ${res.data.reasonCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/interact
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"interactableId": "prototype_urban_bulk_delta"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("depleted node interact denied with node_depleted", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.allowed).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("node_depleted");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
meta {
|
||||
name: POST interact gather then GET inventory
|
||||
type: http
|
||||
seq: 12
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-63 happy path: move near prototype_resource_node_alpha, interact once, then GET inventory and assert scrap_metal_bulk quantity >= 1.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/move`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
target: { x: 10, y: 0.9, z: -6 },
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
interactableId: "prototype_resource_node_alpha",
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/inventory
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("inventory includes scrap_metal_bulk after gather interact", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
const total = body.bagSlots
|
||||
.filter((s) => s.itemId === "scrap_metal_bulk")
|
||||
.reduce((sum, s) => sum + s.quantity, 0);
|
||||
expect(total).to.be.at.least(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
meta {
|
||||
name: POST interact - prototype_resource_node_alpha (NEO-41 gather XP)
|
||||
name: POST interact - prototype_resource_node_alpha (NEO-63 gather)
|
||||
type: http
|
||||
seq: 11
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-41: successful interact on `resource_node` kind grants `salvage` XP (`sourceKind: activity`, 10 XP) via same server path as POST skill-progression. Pre-request moves the dev player in horizontal range of the node so this request passes when run alone or when global `seq` order would otherwise leave the player far from the anchor. Optional: GET skill-progression to read `salvage.xp`.
|
||||
Server tests include a stub-registry case where `activity` is disallowed for `salvage` (interact still allowed; XP unchanged).
|
||||
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (recipe catalog boot NEO-66)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-66 loads content/recipes/*_recipes.json at startup (fail-fast). No recipe 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: recipe-catalog
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
meta {
|
||||
name: GET recipe definitions
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/world/recipe-definitions
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1 and recipes 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.recipes).to.be.an("array");
|
||||
expect(body.recipes.length).to.equal(8);
|
||||
});
|
||||
|
||||
test("recipes are ascending by id (ordinal)", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.recipes.map((x) => x.id);
|
||||
// Match server StringComparer.Ordinal (prototype ids are ASCII snake_case).
|
||||
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
expect(ids).to.eql(sorted);
|
||||
});
|
||||
|
||||
test("frozen prototype eight matches registry id order", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.recipes.map((x) => x.id);
|
||||
expect(ids).to.eql([
|
||||
"make_armor_quick",
|
||||
"make_contract_token",
|
||||
"make_field_stim_batch",
|
||||
"make_field_stim_mk0",
|
||||
"make_prototype_armor",
|
||||
"make_survey_drone_kit",
|
||||
"refine_scrap_efficient",
|
||||
"refine_scrap_standard",
|
||||
]);
|
||||
});
|
||||
|
||||
test("frozen prototype eight is present", function () {
|
||||
const body = res.getBody();
|
||||
const ids = new Set(body.recipes.map((x) => x.id));
|
||||
expect(ids.has("make_armor_quick")).to.equal(true);
|
||||
expect(ids.has("make_contract_token")).to.equal(true);
|
||||
expect(ids.has("make_field_stim_batch")).to.equal(true);
|
||||
expect(ids.has("make_field_stim_mk0")).to.equal(true);
|
||||
expect(ids.has("make_prototype_armor")).to.equal(true);
|
||||
expect(ids.has("make_survey_drone_kit")).to.equal(true);
|
||||
expect(ids.has("refine_scrap_efficient")).to.equal(true);
|
||||
expect(ids.has("refine_scrap_standard")).to.equal(true);
|
||||
});
|
||||
|
||||
test("refine_scrap_standard row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.recipes.find((x) => x.id === "refine_scrap_standard");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Refine Scrap (Standard)");
|
||||
expect(row.recipeKind).to.equal("process");
|
||||
expect(row.requiredSkillId).to.equal("refine");
|
||||
expect(row.inputs).to.eql([{ itemId: "scrap_metal_bulk", quantity: 5 }]);
|
||||
expect(row.outputs).to.eql([{ itemId: "refined_plate_stock", quantity: 1 }]);
|
||||
});
|
||||
|
||||
test("make_field_stim_mk0 row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.recipes.find((x) => x.id === "make_field_stim_mk0");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.recipeKind).to.equal("make");
|
||||
expect(row.inputs).to.eql([
|
||||
{ itemId: "refined_plate_stock", quantity: 2 },
|
||||
{ itemId: "scrap_metal_bulk", quantity: 1 },
|
||||
]);
|
||||
expect(row.outputs).to.eql([{ itemId: "field_stim_mk0", quantity: 1 }]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: recipe-definitions
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (resource-node catalog boot NEO-58)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-58 loads content/resource-nodes/*_resource_nodes.json and *_resource_yields.json at startup (fail-fast, Slice 2 gates, yield item cross-check against item catalog). No resource-node 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: resource-node-catalog
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
meta {
|
||||
name: GET resource node definitions
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/world/resource-node-definitions
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1 and nodes 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.nodes).to.be.an("array");
|
||||
expect(body.nodes.length).to.equal(4);
|
||||
});
|
||||
|
||||
test("nodes are ascending by id (ordinal)", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.nodes.map((x) => x.id);
|
||||
// Match server StringComparer.Ordinal (prototype ids are ASCII snake_case).
|
||||
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.nodes.map((x) => x.id);
|
||||
expect(ids).to.eql([
|
||||
"prototype_bio_mat_gamma",
|
||||
"prototype_resource_node_alpha",
|
||||
"prototype_subsurface_vein_beta",
|
||||
"prototype_urban_bulk_delta",
|
||||
]);
|
||||
});
|
||||
|
||||
test("frozen prototype four is present", function () {
|
||||
const body = res.getBody();
|
||||
const ids = new Set(body.nodes.map((x) => x.id));
|
||||
expect(ids.has("prototype_bio_mat_gamma")).to.equal(true);
|
||||
expect(ids.has("prototype_resource_node_alpha")).to.equal(true);
|
||||
expect(ids.has("prototype_subsurface_vein_beta")).to.equal(true);
|
||||
expect(ids.has("prototype_urban_bulk_delta")).to.equal(true);
|
||||
});
|
||||
|
||||
test("prototype_resource_node_alpha row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.nodes.find((x) => x.id === "prototype_resource_node_alpha");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Prototype Salvage Heap");
|
||||
expect(row.gatherLens).to.equal("consumer_salvage");
|
||||
expect(row.maxGathers).to.equal(10);
|
||||
expect(row.yield).to.be.an("object");
|
||||
expect(row.yield.itemId).to.equal("scrap_metal_bulk");
|
||||
expect(row.yield.quantity).to.equal(1);
|
||||
});
|
||||
|
||||
test("prototype_urban_bulk_delta yield quantity matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.nodes.find((x) => x.id === "prototype_urban_bulk_delta");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.gatherLens).to.equal("urban_bulk");
|
||||
expect(row.yield.quantity).to.equal(5);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: resource-node-definitions
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ If the server is **down**, boot **`GET`** fails silently (check Output for warni
|
|||
|
||||
On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactables`**; **`interactable_world_builder.gd`** spawns **walkable** prototype props plus **two** small glow markers per row under **`World/InteractionMarkers`**. **`interaction_radius_indicators.gd`** compares the snapped **`CharacterBody3D`** **`global_position`** to each descriptor’s **`anchor`** on **X/Z** with the same **`interactionRadius`** and inclusive **`<=`** rule as the server — **preview only**; **`POST /game/players/{id}/interact`** remains authoritative.
|
||||
|
||||
- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode` per response (lines prefixed with `Interaction [<id>]: …`). Only one HTTP POST runs at a time; if you press **E** then **R** while the first request is still in flight, the **latest** id is remembered and sent **immediately after** the first completes (last-wins coalescing — not a silent drop).
|
||||
- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **nearest in-range** **`resource_node`** from the interactables catalog (falls back to **`prototype_resource_node_alpha`** when none in range). **Output** prints `allowed` / `reasonCode` per response (lines prefixed with `Interaction [<id>]: …`). Only one HTTP POST runs at a time; if you press **E** then **R** while the first request is still in flight, the **latest** id is remembered and sent **immediately after** the first completes (last-wins coalescing — not a silent drop).
|
||||
- **Inspector:** match **`base_url`** on **`InteractablesCatalogClient`**, **`InteractionRequestClient`**, and **`PositionAuthorityClient`** to the running server.
|
||||
- **No server:** catalog **GET** fails → **Output** errors; props and glow markers do not spawn (expected prototype degradation).
|
||||
|
||||
|
|
@ -134,6 +134,26 @@ This story hydrates bindings only; cast execution and cooldown behavior stay in
|
|||
|
||||
Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cast deny/accept HUD: [`docs/manual-qa/NEO-28.md`](../docs/manual-qa/NEO-28.md).
|
||||
|
||||
## Inventory snapshot HUD (NEO-72)
|
||||
|
||||
- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http).
|
||||
- **`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.
|
||||
- **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).
|
||||
|
||||
## 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.
|
||||
- **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**).
|
||||
- **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).
|
||||
|
||||
### Manual check (NEO-24)
|
||||
|
||||
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
|
||||
|
|
@ -193,7 +213,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`**, **`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), **`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.
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,11 @@ hotbar_slot_8={
|
|||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":56,"physical_keycode":56,"key_label":0,"unicode":56,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
inventory_refresh={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":73,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[physics]
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
|
||||
[ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"]
|
||||
[ext_resource type="Script" path="res://scripts/interactables_catalog_client.gd" id="13_ix_cat"]
|
||||
[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"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||
geometry_collision_mask = 1
|
||||
|
|
@ -1089,6 +1092,15 @@ script = ExtResource("5_ix")
|
|||
[node name="TargetSelectionClient" type="Node" parent="." unique_id=2500004]
|
||||
script = ExtResource("11_tgt_sel")
|
||||
|
||||
[node name="ItemDefinitionsClient" type="Node" parent="." unique_id=2500006]
|
||||
script = ExtResource("14_item_defs")
|
||||
|
||||
[node name="InventoryClient" type="Node" parent="." unique_id=2500007]
|
||||
script = ExtResource("15_inventory")
|
||||
|
||||
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
|
||||
script = ExtResource("16_skill_prog")
|
||||
|
||||
[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
|
||||
|
||||
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
|
||||
|
|
@ -1148,11 +1160,25 @@ theme_override_font_sizes/font_size = 15
|
|||
autowrap_mode = 3
|
||||
text = "Cast: —"
|
||||
|
||||
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008]
|
||||
offset_left = 8.0
|
||||
offset_top = 376.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 402.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.88, 0.95, 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 = "Gather: —"
|
||||
|
||||
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
|
||||
offset_left = 8.0
|
||||
offset_top = 384.0
|
||||
offset_top = 408.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 460.0
|
||||
offset_bottom = 484.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
|
||||
|
|
@ -1161,3 +1187,33 @@ theme_override_constants/outline_size = 6
|
|||
theme_override_font_sizes/font_size = 14
|
||||
autowrap_mode = 3
|
||||
text = "Cooldowns: —"
|
||||
|
||||
[node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007]
|
||||
offset_left = 8.0
|
||||
offset_top = 496.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 644.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
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
|
||||
theme_override_font_sizes/font_size = 14
|
||||
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
|
||||
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
|
||||
theme_override_font_sizes/font_size = 14
|
||||
autowrap_mode = 3
|
||||
text = "Skills:
|
||||
Loading…"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
uid://d14jp7qtpqpfj
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
extends Node
|
||||
|
||||
## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype).
|
||||
## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] →
|
||||
## [code]prototype_resource_node_alpha[/code] (stable contract ids — must match server registry).
|
||||
## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → nearest in-range
|
||||
## [code]resource_node[/code] ([code]main.gd[/code] +
|
||||
## [code]prototype_interactable_picker.gd[/code]).
|
||||
## Key handling is delegated from
|
||||
## [code]main.gd[/code] [method Node._unhandled_key_input] (see [method post_interact_terminal] /
|
||||
## [method post_interact_resource]) so the embedded Game dock still receives E/R reliably.
|
||||
## [method post_interact_id]) so the embedded Game dock still receives E/R reliably.
|
||||
|
||||
signal interaction_result_received(interactable_id: String, allowed: bool, reason_code: String)
|
||||
signal interaction_request_failed(interactable_id: String, reason: String)
|
||||
|
||||
const ID_TERMINAL := "prototype_terminal"
|
||||
const ID_RESOURCE_NODE := "prototype_resource_node_alpha"
|
||||
|
|
@ -44,6 +48,10 @@ func post_interact_resource() -> void:
|
|||
_post_interact(ID_RESOURCE_NODE)
|
||||
|
||||
|
||||
func post_interact_id(interactable_id: String) -> void:
|
||||
_post_interact(interactable_id)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
|
@ -66,6 +74,7 @@ func _start_post(interactable_id: String) -> void:
|
|||
if err != OK:
|
||||
push_warning("InteractionRequestClient: POST failed to start (%s)" % err)
|
||||
_busy = false
|
||||
interaction_request_failed.emit(interactable_id, "POST failed to start (%s)" % err)
|
||||
_try_flush_pending()
|
||||
|
||||
|
||||
|
|
@ -88,11 +97,19 @@ func _on_request_completed(
|
|||
var d: Dictionary = parsed
|
||||
var allowed: bool = bool(d.get("allowed", false))
|
||||
var reason: Variant = d.get("reasonCode", null)
|
||||
var reason_str := ""
|
||||
if not allowed and reason != null:
|
||||
reason_str = str(reason)
|
||||
if allowed:
|
||||
print("Interaction [%s]: allowed=true (reasonCode omitted on success)" % id_echo)
|
||||
else:
|
||||
print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, str(reason)])
|
||||
print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, reason_str])
|
||||
interaction_result_received.emit(id_echo, allowed, reason_str)
|
||||
_try_flush_pending()
|
||||
return
|
||||
interaction_request_failed.emit(id_echo, "non-JSON body")
|
||||
_try_flush_pending()
|
||||
return
|
||||
print("Interaction [%s]: HTTP %s body=%s" % [id_echo, response_code, text])
|
||||
interaction_request_failed.emit(id_echo, "HTTP %s" % response_code)
|
||||
_try_flush_pending()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
extends Node
|
||||
|
||||
## NEO-72: prototype HTTP client for [code]GET /game/players/{id}/inventory[/code] (NEO-55).
|
||||
|
||||
signal inventory_received(snapshot: Dictionary)
|
||||
signal inventory_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("InventoryClient: %s" % reason)
|
||||
_busy = false
|
||||
inventory_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_inventory_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
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("InventoryClient: %s" % reason)
|
||||
inventory_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("InventoryClient: %s" % reason404)
|
||||
inventory_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("InventoryClient: %s" % reason_code)
|
||||
inventory_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_inventory_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("InventoryClient: %s" % reason_json)
|
||||
inventory_sync_failed.emit(reason_json)
|
||||
return
|
||||
inventory_received.emit(snapshot as Dictionary)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo72inventory01
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
extends Node
|
||||
|
||||
## NEO-72: fetches [code]GET /game/world/item-definitions[/code] (NEO-53).
|
||||
## Caches id → display metadata for inventory HUD labels.
|
||||
|
||||
signal definitions_ready(definitions_by_id: Dictionary)
|
||||
|
||||
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 _definitions_by_id: Dictionary = {}
|
||||
|
||||
|
||||
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/item-definitions" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
push_warning("ItemDefinitionsClient: GET failed to start (%s)" % err)
|
||||
_busy = false
|
||||
|
||||
|
||||
func display_name_for(item_id: String) -> String:
|
||||
var row: Variant = _definitions_by_id.get(item_id, null)
|
||||
if row is Dictionary:
|
||||
var dn: Variant = (row as Dictionary).get("displayName", "")
|
||||
if dn is String and not (dn as String).is_empty():
|
||||
return dn as String
|
||||
return item_id
|
||||
|
||||
|
||||
func definitions_map() -> Dictionary:
|
||||
return _definitions_by_id.duplicate(true)
|
||||
|
||||
|
||||
static func build_definitions_map(root: Dictionary) -> Dictionary:
|
||||
var out: Dictionary = {}
|
||||
var raw_items: Variant = root.get("items", null)
|
||||
if raw_items == null or not raw_items is Array:
|
||||
return out
|
||||
for item in raw_items as Array:
|
||||
if not item is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = item
|
||||
var id: Variant = row.get("id", null)
|
||||
if id == null or not id is String or (id as String).is_empty():
|
||||
continue
|
||||
out[id as String] = {
|
||||
"displayName": str(row.get("displayName", "")),
|
||||
"inventorySlotKind": str(row.get("inventorySlotKind", "")),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_warning("ItemDefinitionsClient: HTTP failed (result=%s)" % result)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
push_warning("ItemDefinitionsClient: HTTP %s" % response_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
push_warning("ItemDefinitionsClient: non-JSON body")
|
||||
return
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
push_warning("ItemDefinitionsClient: schemaVersion mismatch")
|
||||
return
|
||||
_definitions_by_id = build_definitions_map(root)
|
||||
definitions_ready.emit(_definitions_by_id.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo72itemdefs01
|
||||
|
|
@ -39,9 +39,12 @@ const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55
|
|||
const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12
|
||||
|
||||
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
|
||||
const PrototypeInteractablePicker := preload("res://scripts/prototype_interactable_picker.gd")
|
||||
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
|
||||
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"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -74,6 +77,14 @@ var _ability_cast_client: Node = null
|
|||
var _cooldown_state: RefCounted = null
|
||||
var _cooldown_client: Node = null
|
||||
var _cooldown_poll_timer: Timer = null
|
||||
var _last_inventory_snapshot: Dictionary = {}
|
||||
var _inventory_error: String = ""
|
||||
var _interactables_catalog: Array = []
|
||||
var _last_progression_snapshot: Dictionary = {}
|
||||
var _progression_error: String = ""
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -88,6 +99,12 @@ var _cooldown_poll_timer: Timer = null
|
|||
@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 _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel
|
||||
@onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
|
@ -129,6 +146,9 @@ func _ready() -> void:
|
|||
_authority.call("sync_from_server")
|
||||
_target_client.call("request_sync_from_server")
|
||||
_setup_hotbar_loadout_sync()
|
||||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
|
|
@ -306,6 +326,286 @@ func _ensure_cooldown_poll_timer() -> void:
|
|||
add_child(_cooldown_poll_timer)
|
||||
|
||||
|
||||
func _setup_inventory_sync() -> void:
|
||||
# NEO-72: item defs for display names, then inventory GET for bag/equipment HUD.
|
||||
_apply_authority_http_config_to_client(_item_defs_client)
|
||||
_apply_authority_http_config_to_client(_inventory_client)
|
||||
if _item_defs_client.has_signal("definitions_ready"):
|
||||
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
|
||||
if _inventory_client.has_signal("inventory_received"):
|
||||
_inventory_client.connect("inventory_received", Callable(self, "_on_inventory_received"))
|
||||
if _inventory_client.has_signal("inventory_sync_failed"):
|
||||
_inventory_client.connect(
|
||||
"inventory_sync_failed", Callable(self, "_on_inventory_sync_failed")
|
||||
)
|
||||
_render_inventory_label()
|
||||
if _item_defs_client.has_method("request_sync_from_server"):
|
||||
_item_defs_client.call("request_sync_from_server")
|
||||
if _inventory_client.has_method("request_sync_from_server"):
|
||||
_inventory_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _apply_authority_http_config_to_client(client: Node) -> void:
|
||||
if not is_instance_valid(client) or not is_instance_valid(_authority):
|
||||
return
|
||||
var authority_base_url: Variant = _authority.get("base_url")
|
||||
var authority_player_id: Variant = _authority.get("dev_player_id")
|
||||
if authority_base_url is String and not (authority_base_url as String).is_empty():
|
||||
client.set("base_url", authority_base_url)
|
||||
if authority_player_id is String and not (authority_player_id as String).is_empty():
|
||||
if client.get("dev_player_id") != null:
|
||||
client.set("dev_player_id", authority_player_id)
|
||||
|
||||
|
||||
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
|
||||
if not _inventory_error.is_empty():
|
||||
return
|
||||
if not _last_inventory_snapshot.is_empty():
|
||||
_render_inventory_label()
|
||||
|
||||
|
||||
func _on_inventory_received(snapshot: Dictionary) -> void:
|
||||
_inventory_error = ""
|
||||
_last_inventory_snapshot = snapshot.duplicate(true)
|
||||
_render_inventory_label()
|
||||
_finalize_pending_gather_feedback()
|
||||
|
||||
|
||||
func _on_inventory_sync_failed(reason: String) -> void:
|
||||
_inventory_error = reason
|
||||
_last_inventory_snapshot = {}
|
||||
_render_inventory_label()
|
||||
if _gather_awaiting_inventory_finalize:
|
||||
_clear_gather_session()
|
||||
_render_gather_feedback_label("Gather: inventory refresh failed")
|
||||
|
||||
|
||||
func _render_inventory_label() -> void:
|
||||
if not is_instance_valid(_inventory_label):
|
||||
return
|
||||
var header := "Inventory: (refresh: I)"
|
||||
if not _inventory_error.is_empty():
|
||||
_inventory_label.text = "%s\nerror — %s" % [header, _inventory_error]
|
||||
return
|
||||
if _last_inventory_snapshot.is_empty():
|
||||
_inventory_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var lines: PackedStringArray = [header, "Bag:"]
|
||||
var bag_lines: PackedStringArray = _format_inventory_slot_lines(
|
||||
_last_inventory_snapshot.get("bagSlots", []), false
|
||||
)
|
||||
if bag_lines.is_empty():
|
||||
lines.append(" (empty)")
|
||||
else:
|
||||
for line in bag_lines:
|
||||
lines.append(line)
|
||||
lines.append("Equipment:")
|
||||
var equip_lines: PackedStringArray = _format_inventory_slot_lines(
|
||||
_last_inventory_snapshot.get("equipmentSlots", []), true
|
||||
)
|
||||
if equip_lines.is_empty():
|
||||
lines.append(" slot 0: — empty")
|
||||
else:
|
||||
for line in equip_lines:
|
||||
lines.append(line)
|
||||
_inventory_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _format_inventory_slot_lines(
|
||||
raw_slots: Variant, always_show_empty_stub: bool
|
||||
) -> PackedStringArray:
|
||||
var out: PackedStringArray = PackedStringArray()
|
||||
if raw_slots == null or not raw_slots is Array:
|
||||
if always_show_empty_stub:
|
||||
out.append(" slot 0: — empty")
|
||||
return out
|
||||
for slot_variant in raw_slots as Array:
|
||||
if not slot_variant is Dictionary:
|
||||
continue
|
||||
var slot: Dictionary = slot_variant
|
||||
var slot_index: int = int(slot.get("slotIndex", -1))
|
||||
var quantity: int = int(slot.get("quantity", 0))
|
||||
if quantity <= 0:
|
||||
if always_show_empty_stub and slot_index == 0:
|
||||
out.append(" slot 0: — empty")
|
||||
continue
|
||||
var item_id: String = str(slot.get("itemId", ""))
|
||||
var label: String = _inventory_item_label(item_id)
|
||||
out.append(" slot %d: %s x%d" % [slot_index, label, quantity])
|
||||
return out
|
||||
|
||||
|
||||
func _inventory_item_label(item_id: String) -> String:
|
||||
if not is_instance_valid(_item_defs_client):
|
||||
return item_id
|
||||
if not _item_defs_client.has_method("display_name_for"):
|
||||
return item_id
|
||||
return str(_item_defs_client.call("display_name_for", item_id))
|
||||
|
||||
|
||||
func _request_inventory_refresh() -> void:
|
||||
if not is_instance_valid(_inventory_client):
|
||||
return
|
||||
if _inventory_client.has_method("request_sync_from_server"):
|
||||
_inventory_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_skill_progression_sync() -> void:
|
||||
# NEO-73: salvage XP/level HUD row; boot hydrate + refresh after gather.
|
||||
_apply_authority_http_config_to_client(_skill_progression_client)
|
||||
if _skill_progression_client.has_signal("progression_received"):
|
||||
_skill_progression_client.connect(
|
||||
"progression_received", Callable(self, "_on_progression_received")
|
||||
)
|
||||
if _skill_progression_client.has_signal("progression_sync_failed"):
|
||||
_skill_progression_client.connect(
|
||||
"progression_sync_failed", Callable(self, "_on_progression_sync_failed")
|
||||
)
|
||||
_render_skill_progression_label()
|
||||
if _skill_progression_client.has_method("request_sync_from_server"):
|
||||
_skill_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_gather_interact_feedback() -> void:
|
||||
if not is_instance_valid(_interaction_client):
|
||||
return
|
||||
if _interaction_client.has_signal("interaction_result_received"):
|
||||
_interaction_client.connect(
|
||||
"interaction_result_received", Callable(self, "_on_interaction_result_for_gather")
|
||||
)
|
||||
if _interaction_client.has_signal("interaction_request_failed"):
|
||||
_interaction_client.connect(
|
||||
"interaction_request_failed",
|
||||
Callable(self, "_on_interaction_request_failed_for_gather")
|
||||
)
|
||||
_render_gather_feedback_label()
|
||||
|
||||
|
||||
func _on_progression_received(snapshot: Dictionary) -> void:
|
||||
_progression_error = ""
|
||||
_last_progression_snapshot = snapshot.duplicate(true)
|
||||
_render_skill_progression_label()
|
||||
|
||||
|
||||
func _on_progression_sync_failed(reason: String) -> void:
|
||||
_progression_error = reason
|
||||
_last_progression_snapshot = {}
|
||||
_render_skill_progression_label()
|
||||
|
||||
|
||||
func _render_skill_progression_label() -> void:
|
||||
if not is_instance_valid(_skill_progression_label):
|
||||
return
|
||||
var header := "Skills:"
|
||||
if not _progression_error.is_empty():
|
||||
_skill_progression_label.text = "%s\nerror — %s" % [header, _progression_error]
|
||||
return
|
||||
if _last_progression_snapshot.is_empty():
|
||||
_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
|
||||
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]
|
||||
|
||||
|
||||
func _skill_row(skill_id: String) -> Dictionary:
|
||||
if not is_instance_valid(_skill_progression_client):
|
||||
return {}
|
||||
if not _skill_progression_client.has_method("skill_row"):
|
||||
return {}
|
||||
var row: Variant = _skill_progression_client.call(
|
||||
"skill_row", skill_id, _last_progression_snapshot
|
||||
)
|
||||
return row as Dictionary
|
||||
|
||||
|
||||
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_gather_feedback_label.text = text
|
||||
|
||||
|
||||
func _clear_gather_session() -> void:
|
||||
_gather_pending_interactable_id = ""
|
||||
_gather_awaiting_inventory_finalize = false
|
||||
|
||||
|
||||
func _on_interaction_result_for_gather(
|
||||
interactable_id: String, allowed: bool, reason_code: String
|
||||
) -> void:
|
||||
if _gather_pending_interactable_id.is_empty():
|
||||
return
|
||||
if interactable_id != _gather_pending_interactable_id:
|
||||
if _gather_awaiting_inventory_finalize:
|
||||
return
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id):
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not allowed:
|
||||
_clear_gather_session()
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
_render_gather_feedback_label("Gather: denied (no reasonCode)")
|
||||
else:
|
||||
_render_gather_feedback_label("Gather: denied — %s" % rc)
|
||||
return
|
||||
_gather_awaiting_inventory_finalize = true
|
||||
_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")
|
||||
|
||||
|
||||
func _on_interaction_request_failed_for_gather(interactable_id: String, _reason: String) -> void:
|
||||
if _gather_pending_interactable_id.is_empty():
|
||||
return
|
||||
if interactable_id != _gather_pending_interactable_id:
|
||||
if not _gather_awaiting_inventory_finalize:
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not _gather_awaiting_inventory_finalize:
|
||||
_clear_gather_session()
|
||||
|
||||
|
||||
func _total_bag_qty(item_id: String, snapshot: Dictionary = {}) -> int:
|
||||
var source: Dictionary = snapshot
|
||||
if source.is_empty():
|
||||
source = _last_inventory_snapshot
|
||||
var bag: Variant = source.get("bagSlots", null)
|
||||
if bag == null or not bag is Array:
|
||||
return 0
|
||||
var total: int = 0
|
||||
for slot_variant in bag as Array:
|
||||
if not slot_variant is Dictionary:
|
||||
continue
|
||||
var slot: Dictionary = slot_variant
|
||||
if str(slot.get("itemId", "")) != item_id:
|
||||
continue
|
||||
total += int(slot.get("quantity", 0))
|
||||
return total
|
||||
|
||||
|
||||
func _finalize_pending_gather_feedback() -> void:
|
||||
if not _gather_awaiting_inventory_finalize:
|
||||
return
|
||||
var pre_qty: int = _gather_pre_scrap_qty
|
||||
_clear_gather_session()
|
||||
var new_qty: int = _total_bag_qty(SCRAP_METAL_BULK_ID)
|
||||
var delta: int = new_qty - pre_qty
|
||||
if delta <= 0:
|
||||
_render_gather_feedback_label("Gather: ok (no scrap delta)")
|
||||
return
|
||||
var label: String = _inventory_item_label(SCRAP_METAL_BULK_ID)
|
||||
_render_gather_feedback_label("Gather: +%d %s" % [delta, label])
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
|
||||
if (
|
||||
is_instance_valid(_cooldown_client)
|
||||
|
|
@ -427,32 +727,65 @@ func _on_move_rejected(reason_code: String) -> void:
|
|||
## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action.
|
||||
## For a true **freed-node** occluder test, reload the scene or remove the body in the editor.
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
for slot_digit in range(1, 9):
|
||||
var action_name := "hotbar_slot_%d" % slot_digit
|
||||
if event.is_action_pressed(action_name):
|
||||
_request_hotbar_cast_slot(slot_digit - 1)
|
||||
return
|
||||
# NEO-25: route interact keys here — `InteractionRequestClient._input` is unreliable in the
|
||||
# embedded Game dock / focus order; `_unhandled_key_input` on the scene root still fires.
|
||||
if event.is_action_pressed("interact"):
|
||||
_forward_interact_post("post_interact_terminal")
|
||||
if _try_route_gameplay_key_input(event):
|
||||
return
|
||||
if event.is_action_pressed("interact_secondary"):
|
||||
_forward_interact_post("post_interact_resource")
|
||||
return
|
||||
if event is InputEventKey:
|
||||
var k := event as InputEventKey
|
||||
if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E):
|
||||
_forward_interact_post("post_interact_terminal")
|
||||
return
|
||||
if k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R):
|
||||
_forward_interact_post("post_interact_resource")
|
||||
return
|
||||
if not event.is_action_pressed("dev_toggle_occluder_obstacle"):
|
||||
return
|
||||
call_deferred("_dev_toggle_obstacle_smoke_deferred")
|
||||
|
||||
|
||||
## NEO-25 / NEO-72: route interact + inventory keys here — `InteractionRequestClient._input`
|
||||
## is unreliable in the embedded Game dock; `_unhandled_key_input` on the scene root still fires.
|
||||
func _try_route_gameplay_key_input(event: InputEvent) -> bool:
|
||||
var hotbar_slot := _hotbar_slot_index_from_event(event)
|
||||
if hotbar_slot >= 0:
|
||||
_request_hotbar_cast_slot(hotbar_slot)
|
||||
return true
|
||||
if _try_interact_key_input(event):
|
||||
return true
|
||||
if _try_inventory_refresh_input(event):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _try_interact_key_input(event: InputEvent) -> bool:
|
||||
if event.is_action_pressed("interact"):
|
||||
_forward_interact_post("post_interact_terminal")
|
||||
return true
|
||||
if event.is_action_pressed("interact_secondary"):
|
||||
_forward_interact_resource_nearest()
|
||||
return true
|
||||
if event is InputEventKey:
|
||||
var k := event as InputEventKey
|
||||
if k.pressed and not k.echo:
|
||||
if k.physical_keycode == KEY_E or k.keycode == KEY_E:
|
||||
_forward_interact_post("post_interact_terminal")
|
||||
return true
|
||||
if k.physical_keycode == KEY_R or k.keycode == KEY_R:
|
||||
_forward_interact_resource_nearest()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _hotbar_slot_index_from_event(event: InputEvent) -> int:
|
||||
for slot_digit in range(1, 9):
|
||||
if event.is_action_pressed("hotbar_slot_%d" % slot_digit):
|
||||
return slot_digit - 1
|
||||
return -1
|
||||
|
||||
|
||||
func _try_inventory_refresh_input(event: InputEvent) -> bool:
|
||||
if event.is_action_pressed("inventory_refresh"):
|
||||
_request_inventory_refresh()
|
||||
return true
|
||||
if event is InputEventKey:
|
||||
var k := event as InputEventKey
|
||||
if k.pressed and not k.echo and (k.physical_keycode == KEY_I or k.keycode == KEY_I):
|
||||
_request_inventory_refresh()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _request_hotbar_cast_slot(slot_index: int) -> void:
|
||||
if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"):
|
||||
return
|
||||
|
|
@ -505,6 +838,19 @@ func _forward_interact_post(method: String) -> void:
|
|||
_interaction_client.call(method)
|
||||
|
||||
|
||||
func _forward_interact_resource_nearest() -> void:
|
||||
if not is_instance_valid(_interaction_client) or not is_instance_valid(_player):
|
||||
return
|
||||
var node_id: String = PrototypeInteractablePicker.nearest_resource_node_id(
|
||||
_interactables_catalog, _player.global_position
|
||||
)
|
||||
_gather_pre_scrap_qty = _total_bag_qty(SCRAP_METAL_BULK_ID)
|
||||
_gather_pending_interactable_id = node_id
|
||||
_gather_awaiting_inventory_finalize = false
|
||||
if _interaction_client.has_method("post_interact_id"):
|
||||
_interaction_client.call("post_interact_id", node_id)
|
||||
|
||||
|
||||
func _dev_toggle_obstacle_smoke_deferred() -> void:
|
||||
var obstacle := _dev_obstacle_smoke
|
||||
if obstacle == null or not is_instance_valid(obstacle):
|
||||
|
|
@ -558,6 +904,7 @@ func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
|
|||
|
||||
|
||||
func _on_interactables_catalog_ready(descriptors: Array) -> void:
|
||||
_interactables_catalog = descriptors.duplicate(true)
|
||||
var groups: Variant = load("res://scripts/interactable_world_builder.gd").call(
|
||||
"build_from_catalog", descriptors, _interactables_root, _radius_preview
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
extends Object
|
||||
|
||||
## NEO-73: client-side nearest [code]resource_node[/code] picker for gather interact ([kbd]R[/kbd]).
|
||||
## Horizontal X/Z distance + inclusive radius — same rule as
|
||||
## [code]interaction_radius_indicators.gd[/code].
|
||||
|
||||
const KIND_RESOURCE_NODE := "resource_node"
|
||||
const FALLBACK_RESOURCE_NODE_ID := "prototype_resource_node_alpha"
|
||||
|
||||
const KNOWN_RESOURCE_NODE_IDS: Array[String] = [
|
||||
"prototype_resource_node_alpha",
|
||||
"prototype_subsurface_vein_beta",
|
||||
"prototype_bio_mat_gamma",
|
||||
"prototype_urban_bulk_delta",
|
||||
]
|
||||
|
||||
|
||||
static func nearest_resource_node_id(descriptors: Array, world: Vector3) -> String:
|
||||
var best_id := ""
|
||||
var best_dist_sq: float = INF
|
||||
for descriptor_variant in descriptors:
|
||||
if not descriptor_variant is Dictionary:
|
||||
continue
|
||||
var descriptor: Dictionary = descriptor_variant
|
||||
if str(descriptor.get("kind", "")) != KIND_RESOURCE_NODE:
|
||||
continue
|
||||
var interactable_id: String = str(descriptor.get("interactableId", ""))
|
||||
if interactable_id.is_empty():
|
||||
continue
|
||||
var dist_sq: float = _horizontal_distance_sq_to_descriptor(descriptor, world)
|
||||
var radius: float = float(descriptor.get("interactionRadius", 0.0))
|
||||
if dist_sq > radius * radius:
|
||||
continue
|
||||
if (
|
||||
best_id.is_empty()
|
||||
or dist_sq < best_dist_sq - 1e-9
|
||||
or (absf(dist_sq - best_dist_sq) <= 1e-9 and interactable_id < best_id)
|
||||
):
|
||||
best_dist_sq = dist_sq
|
||||
best_id = interactable_id
|
||||
if best_id.is_empty():
|
||||
return FALLBACK_RESOURCE_NODE_ID
|
||||
return best_id
|
||||
|
||||
|
||||
static func descriptor_kind(descriptors: Array, interactable_id: String) -> String:
|
||||
for descriptor_variant in descriptors:
|
||||
if not descriptor_variant is Dictionary:
|
||||
continue
|
||||
var descriptor: Dictionary = descriptor_variant
|
||||
if str(descriptor.get("interactableId", "")) == interactable_id:
|
||||
return str(descriptor.get("kind", ""))
|
||||
return ""
|
||||
|
||||
|
||||
static func is_resource_node_id(descriptors: Array, interactable_id: String) -> bool:
|
||||
if descriptor_kind(descriptors, interactable_id) == KIND_RESOURCE_NODE:
|
||||
return true
|
||||
return KNOWN_RESOURCE_NODE_IDS.has(interactable_id)
|
||||
|
||||
|
||||
static func _horizontal_distance_sq_to_descriptor(descriptor: Dictionary, world: Vector3) -> float:
|
||||
var anchor_variant: Variant = descriptor.get("anchor", null)
|
||||
if not anchor_variant is Dictionary:
|
||||
return INF
|
||||
var anchor: Dictionary = anchor_variant
|
||||
var dx: float = world.x - float(anchor.get("x", 0.0))
|
||||
var dz: float = world.z - float(anchor.get("z", 0.0))
|
||||
return dx * dx + dz * dz
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo73ixpicker01
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
extends Node
|
||||
|
||||
## NEO-73: prototype HTTP client for [code]GET /game/players/{id}/skill-progression[/code] (NEO-37).
|
||||
|
||||
signal progression_received(snapshot: Dictionary)
|
||||
signal progression_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("SkillProgressionClient: %s" % reason)
|
||||
_busy = false
|
||||
progression_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var skills: Variant = snapshot.get("skills", null)
|
||||
if skills == null or not skills is Array:
|
||||
return {}
|
||||
for row_variant in skills as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == skill_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_progression_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
var skills: Variant = root.get("skills", null)
|
||||
if skills == null or not skills is Array:
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("SkillProgressionClient: %s" % reason)
|
||||
progression_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("SkillProgressionClient: %s" % reason404)
|
||||
progression_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("SkillProgressionClient: %s" % reason_code)
|
||||
progression_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_progression_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("SkillProgressionClient: %s" % reason_json)
|
||||
progression_sync_failed.emit(reason_json)
|
||||
return
|
||||
progression_received.emit(snapshot as Dictionary)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo73skillprog01
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-73: allowed resource-node interact triggers inventory + skill refresh.
|
||||
|
||||
const IxClient := preload("res://scripts/interaction_request_client.gd")
|
||||
const InventoryClient := preload("res://scripts/inventory_client.gd")
|
||||
const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd")
|
||||
const Picker := preload("res://scripts/prototype_interactable_picker.gd")
|
||||
|
||||
|
||||
class MockInteractTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_body: String = ""
|
||||
var body_json: String = '{"schemaVersion":1,"allowed":true}'
|
||||
var response_code: int = 200
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_body = request_data
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, response_code, PackedStringArray(), body_bytes
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class SpySyncClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class GatherRefreshHarness:
|
||||
extends Node
|
||||
var inventory_sync_calls: int = 0
|
||||
var skill_sync_calls: int = 0
|
||||
var pending_gather_interactable_id: String = ""
|
||||
var awaiting_inventory_finalize: bool = false
|
||||
var _inventory: SpySyncClient
|
||||
var _skill: SpySyncClient
|
||||
var _ix: Node
|
||||
|
||||
func setup(parent: Node, ix_transport: Node) -> void:
|
||||
_inventory = SpySyncClient.new()
|
||||
_skill = SpySyncClient.new()
|
||||
_ix = IxClient.new()
|
||||
_ix.set("injected_http", ix_transport)
|
||||
parent.add_child(_ix)
|
||||
parent.add_child(_inventory)
|
||||
parent.add_child(_skill)
|
||||
_ix.connect("interaction_result_received", Callable(self, "_on_interaction_result"))
|
||||
|
||||
func queue_resource_gather(interactable_id: String) -> void:
|
||||
pending_gather_interactable_id = interactable_id
|
||||
awaiting_inventory_finalize = false
|
||||
|
||||
func _clear_gather_session() -> void:
|
||||
pending_gather_interactable_id = ""
|
||||
awaiting_inventory_finalize = false
|
||||
|
||||
func _on_interaction_result(
|
||||
interactable_id: String, allowed: bool, _reason_code: String
|
||||
) -> void:
|
||||
if pending_gather_interactable_id.is_empty():
|
||||
return
|
||||
if interactable_id != pending_gather_interactable_id:
|
||||
if awaiting_inventory_finalize:
|
||||
return
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not Picker.is_resource_node_id([], interactable_id):
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not allowed:
|
||||
_clear_gather_session()
|
||||
return
|
||||
awaiting_inventory_finalize = true
|
||||
_inventory.request_sync_from_server()
|
||||
_skill.request_sync_from_server()
|
||||
|
||||
func post_resource_alpha() -> void:
|
||||
queue_resource_gather("prototype_resource_node_alpha")
|
||||
_ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
|
||||
func emit_terminal_allowed() -> void:
|
||||
_ix.set("_current_interactable_id", "prototype_terminal")
|
||||
var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
|
||||
_ix.call("_on_request_completed", 0, 200, PackedStringArray(), body_bytes)
|
||||
|
||||
|
||||
func test_allowed_resource_interact_triggers_both_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._ix)
|
||||
# Act
|
||||
harness.post_resource_alpha()
|
||||
# Assert
|
||||
assert_signal(harness._ix).is_emitted("interaction_result_received")
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_denied_resource_interact_skips_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
transport.body_json = '{"schemaVersion":1,"allowed":false,"reasonCode":"node_depleted"}'
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.post_resource_alpha()
|
||||
# Assert
|
||||
assert_that(harness._inventory.sync_calls).is_equal(0)
|
||||
assert_that(harness._skill.sync_calls).is_equal(0)
|
||||
|
||||
|
||||
func test_terminal_result_clears_orphaned_gather_pending() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
harness.queue_resource_gather("prototype_resource_node_alpha")
|
||||
# Act
|
||||
harness.emit_terminal_allowed()
|
||||
# Assert
|
||||
assert_that(harness.pending_gather_interactable_id).is_empty()
|
||||
assert_that(harness.awaiting_inventory_finalize).is_false()
|
||||
assert_that(harness._inventory.sync_calls).is_equal(0)
|
||||
assert_that(harness._skill.sync_calls).is_equal(0)
|
||||
|
||||
|
||||
func test_terminal_after_gather_success_keeps_awaiting_inventory() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.post_resource_alpha()
|
||||
harness.emit_terminal_allowed()
|
||||
# Assert
|
||||
assert_that(harness.awaiting_inventory_finalize).is_true()
|
||||
assert_that(harness.pending_gather_interactable_id).is_equal("prototype_resource_node_alpha")
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cpjyg7kctbh2x
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://ii06cl13tfkv
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-25: `InteractionRequestClient` POST bodies for E vs R contract ids.
|
||||
## NEO-25 / NEO-73: `InteractionRequestClient` POST bodies, result/failure signals.
|
||||
|
||||
const IxClient := preload("res://scripts/interaction_request_client.gd")
|
||||
|
||||
|
|
@ -12,19 +12,23 @@ class MockHttpTransport:
|
|||
)
|
||||
var last_url: String = ""
|
||||
var last_body: String = ""
|
||||
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
|
||||
var last_method: HTTPClient.Method = HTTPClient.Method.METHOD_GET
|
||||
var body_json: String = '{"schemaVersion":1,"allowed":true}'
|
||||
var response_code: int = 200
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
method: HTTPClient.Method = HTTPClient.Method.METHOD_GET,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
last_body = request_data
|
||||
last_method = method
|
||||
var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
|
||||
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, response_code, PackedStringArray(), body_bytes
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
|
|
@ -106,6 +110,47 @@ func test_post_interact_resource_public_entrypoint() -> void:
|
|||
assert_that(transport.last_body).contains("prototype_resource_node_alpha")
|
||||
|
||||
|
||||
func test_interaction_result_emits_allow_signal() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
var ix := _make_ix(transport)
|
||||
monitor_signals(ix)
|
||||
# Act
|
||||
ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
"interaction_result_received", "prototype_resource_node_alpha", true, ""
|
||||
)
|
||||
|
||||
|
||||
func test_interaction_result_emits_deny_signal() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":1,"allowed":false,"reasonCode":"node_depleted"}'
|
||||
var ix := _make_ix(transport)
|
||||
monitor_signals(ix)
|
||||
# Act
|
||||
ix.call("post_interact_id", "prototype_urban_bulk_delta")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
"interaction_result_received", "prototype_urban_bulk_delta", false, "node_depleted"
|
||||
)
|
||||
|
||||
|
||||
func test_interaction_request_failed_emits_on_http_error() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 500
|
||||
var ix := _make_ix(transport)
|
||||
monitor_signals(ix)
|
||||
# Act
|
||||
ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
"interaction_request_failed", "prototype_resource_node_alpha", "HTTP 500"
|
||||
)
|
||||
|
||||
|
||||
func test_last_wins_second_press_before_first_completes() -> void:
|
||||
# Arrange
|
||||
var transport := HoldFirstThenAutoTransport.new()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-72: `InventoryClient` GET parse + failure signals.
|
||||
|
||||
const InventoryClient := preload("res://scripts/inventory_client.gd")
|
||||
|
||||
var _inventory_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_inventory(snapshot: Dictionary) -> void:
|
||||
_inventory_capture = snapshot
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _empty_inventory_json() -> String:
|
||||
var bag_parts: PackedStringArray = PackedStringArray()
|
||||
for i in range(24):
|
||||
bag_parts.append('{"slotIndex":%d,"quantity":0}' % i)
|
||||
var bag: String = ", ".join(bag_parts)
|
||||
return (
|
||||
(
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","bagSlots":[%s],'
|
||||
+ '"equipmentSlots":[{"slotIndex":0,"quantity":0}]}'
|
||||
)
|
||||
% bag
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = InventoryClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_inventory_json_empty_grid() -> void:
|
||||
# Arrange
|
||||
var json := _empty_inventory_json()
|
||||
# Act
|
||||
var snapshot: Variant = InventoryClient.parse_inventory_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var bag: Variant = (snapshot as Dictionary).get("bagSlots", null)
|
||||
assert_that(bag is Array).is_true()
|
||||
assert_that((bag as Array).size()).is_equal(24)
|
||||
|
||||
|
||||
func test_request_sync_emits_inventory_received() -> void:
|
||||
# Arrange
|
||||
_inventory_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _empty_inventory_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("inventory_received", Callable(self, "_capture_inventory"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_received")
|
||||
assert_that(_inventory_capture.has("bagSlots")).is_true()
|
||||
assert_that((_inventory_capture["bagSlots"] as Array).size()).is_equal(24)
|
||||
assert_that((_inventory_capture["equipmentSlots"] as Array).size()).is_equal(1)
|
||||
assert_that(transport.last_url).contains("/inventory")
|
||||
|
||||
|
||||
func test_http_404_emits_inventory_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_malformed_json_emits_inventory_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = "[]"
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_sync_failed")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cneo72invtest01
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-72: `ItemDefinitionsClient` parses world item defs and resolves display names.
|
||||
|
||||
const ItemDefsClient := preload("res://scripts/item_definitions_client.gd")
|
||||
|
||||
const _SAMPLE_BODY_JSON := (
|
||||
'{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",'
|
||||
+ '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}'
|
||||
)
|
||||
|
||||
|
||||
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 = ""
|
||||
var _queue: Array[Dictionary] = []
|
||||
|
||||
func enqueue(result: int, code: int, body: String) -> void:
|
||||
_queue.append({"result": result, "code": code, "body": body})
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
if _queue.is_empty():
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
var r: Dictionary = _queue.pop_front()
|
||||
request_completed.emit(
|
||||
r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = ItemDefsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_build_definitions_map_extracts_display_name() -> void:
|
||||
# Arrange
|
||||
var root := {
|
||||
"schemaVersion": 1,
|
||||
"items":
|
||||
[
|
||||
{
|
||||
"id": "scrap_metal_bulk",
|
||||
"displayName": "Scrap Metal (Bulk)",
|
||||
"inventorySlotKind": "bag",
|
||||
}
|
||||
],
|
||||
}
|
||||
# Act
|
||||
var built: Dictionary = ItemDefsClient.build_definitions_map(root)
|
||||
# Assert
|
||||
assert_that(built.has("scrap_metal_bulk")).is_true()
|
||||
assert_that(built["scrap_metal_bulk"]["displayName"]).is_equal("Scrap Metal (Bulk)")
|
||||
|
||||
|
||||
func test_request_sync_hits_definitions_endpoint_and_emits() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _SAMPLE_BODY_JSON)
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(transport.last_url).contains("/game/world/item-definitions")
|
||||
assert_signal(c).is_emitted("definitions_ready")
|
||||
|
||||
|
||||
func test_display_name_for_after_request_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _SAMPLE_BODY_JSON)
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(c.call("display_name_for", "scrap_metal_bulk")).is_equal("Scrap Metal (Bulk)")
|
||||
|
||||
|
||||
func test_http_error_does_not_emit_definitions_ready() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 500, "{}")
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).wait_until(400).is_not_emitted("definitions_ready")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cneo72itemdeftest01
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-73: nearest in-range resource node picker for gather interact.
|
||||
|
||||
const Picker := preload("res://scripts/prototype_interactable_picker.gd")
|
||||
|
||||
|
||||
static func _descriptor(id: String, kind: String, x: float, z: float, radius: float) -> Dictionary:
|
||||
return {
|
||||
"interactableId": id,
|
||||
"kind": kind,
|
||||
"anchor": {"x": x, "y": 0.5, "z": z},
|
||||
"interactionRadius": radius,
|
||||
}
|
||||
|
||||
|
||||
func test_nearest_resource_node_picks_closest_in_range() -> void:
|
||||
# Arrange
|
||||
var descriptors: Array = [
|
||||
_descriptor("prototype_terminal", "terminal", 0.0, 0.0, 3.0),
|
||||
_descriptor("prototype_resource_node_alpha", "resource_node", 12.0, -6.0, 3.0),
|
||||
_descriptor("prototype_subsurface_vein_beta", "resource_node", 18.0, -6.0, 3.0),
|
||||
]
|
||||
var world := Vector3(17.0, 0.9, -6.0)
|
||||
# Act
|
||||
var picked: String = Picker.nearest_resource_node_id(descriptors, world)
|
||||
# Assert
|
||||
assert_that(picked).is_equal("prototype_subsurface_vein_beta")
|
||||
|
||||
|
||||
func test_nearest_resource_node_falls_back_to_alpha_when_out_of_range() -> void:
|
||||
# Arrange
|
||||
var descriptors: Array = [
|
||||
_descriptor("prototype_resource_node_alpha", "resource_node", 12.0, -6.0, 3.0),
|
||||
]
|
||||
var world := Vector3(-5.0, 0.9, -5.0)
|
||||
# Act
|
||||
var picked: String = Picker.nearest_resource_node_id(descriptors, world)
|
||||
# Assert
|
||||
assert_that(picked).is_equal("prototype_resource_node_alpha")
|
||||
|
||||
|
||||
func test_is_resource_node_id_uses_known_ids_when_catalog_empty() -> void:
|
||||
# Arrange
|
||||
# Act
|
||||
var is_node: bool = Picker.is_resource_node_id([], "prototype_bio_mat_gamma")
|
||||
# Assert
|
||||
assert_that(is_node).is_true()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cf8xqlajsa2pv
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-73: `SkillProgressionClient` GET parse + failure signals.
|
||||
|
||||
const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd")
|
||||
|
||||
var _progression_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_progression(snapshot: Dictionary) -> void:
|
||||
_progression_capture = snapshot
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _trio_progression_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","skills":['
|
||||
+ '{"id":"salvage","xp":10,"level":1},'
|
||||
+ '{"id":"refine","xp":0,"level":1},'
|
||||
+ '{"id":"intrusion","xp":0,"level":1}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = SkillProgressionClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_progression_json_reads_salvage_row() -> void:
|
||||
# Arrange
|
||||
var json := _trio_progression_json()
|
||||
# Act
|
||||
var snapshot: Variant = SkillProgressionClient.parse_progression_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = SkillProgressionClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("skill_row", "salvage", snapshot as Dictionary)
|
||||
assert_that(int(row.get("xp", -1))).is_equal(10)
|
||||
assert_that(int(row.get("level", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_request_sync_emits_progression_received() -> void:
|
||||
# Arrange
|
||||
_progression_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _trio_progression_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("progression_received", Callable(self, "_capture_progression"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("progression_received")
|
||||
assert_that(_progression_capture.has("skills")).is_true()
|
||||
assert_that(transport.last_url).contains("/skill-progression")
|
||||
|
||||
|
||||
func test_http_404_emits_progression_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bkn5ajmbq4rvg
|
||||
|
|
@ -8,11 +8,17 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
| [`skills/`](skills/) | Skill catalogs; each row matches [`schemas/skill-def.schema.json`](schemas/skill-def.schema.json) — **stable `id`**, **`allowedXpSourceKinds`** for [E2.M1](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md) / [E2.M2](../docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
|
||||
| [`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) |
|
||||
| [`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.
|
||||
|
||||
**Prototype Slice 2 — resource nodes (NEO-57):** CI expects **exactly four** `nodeDefId` values aligned to [E3.M1 gather lens freeze](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md#prototype-slice-2-freeze-e3m1-01) — one row per **`gatherLens`**; exactly one yield row per node referencing **`scrap_metal_bulk`** only. **Do not rename** `nodeDefId` after ship. See [E3M1-prototype-backlog.md](../docs/plans/E3M1-prototype-backlog.md) and [NEO-57 plan](../docs/plans/NEO-57-implementation-plan.md).
|
||||
|
||||
**Prototype Slice 1 — items (NEO-50):** CI expects **exactly six** item rows with ids **`scrap_metal_bulk`**, **`refined_plate_stock`**, **`field_stim_mk0`**, **`survey_drone_kit`**, **`contract_handoff_token`**, **`prototype_armor_shell`** — one row per **`prototypeRole`** (`material` through `equip_stub`). **Do not rename** ids after ship—change **`displayName`** only. See [E3.M3 — Designer note](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#designer-note-stack-slot-and-v1-scope) and [freeze table](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#prototype-slice-1-freeze-neo-50).
|
||||
|
||||
**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 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,87 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"recipes": [
|
||||
{
|
||||
"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 }]
|
||||
},
|
||||
{
|
||||
"id": "refine_scrap_efficient",
|
||||
"displayName": "Refine Scrap (Efficient)",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
|
||||
},
|
||||
{
|
||||
"id": "make_field_stim_mk0",
|
||||
"displayName": "Make Field Stim Mk0",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 2 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 1 }
|
||||
],
|
||||
"outputs": [{ "itemId": "field_stim_mk0", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_field_stim_batch",
|
||||
"displayName": "Make Field Stim (Batch)",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 5 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 3 }
|
||||
],
|
||||
"outputs": [{ "itemId": "field_stim_mk0", "quantity": 3 }]
|
||||
},
|
||||
{
|
||||
"id": "make_prototype_armor",
|
||||
"displayName": "Make Prototype Armor Shell",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 8 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 5 }
|
||||
],
|
||||
"outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_armor_quick",
|
||||
"displayName": "Make Armor (Quick)",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 4 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 3 }
|
||||
],
|
||||
"outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_survey_drone_kit",
|
||||
"displayName": "Make Survey Drone Kit",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 3 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 2 }
|
||||
],
|
||||
"outputs": [{ "itemId": "survey_drone_kit", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_contract_token",
|
||||
"displayName": "Make Contract Handoff Token",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 2 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 1 }
|
||||
],
|
||||
"outputs": [{ "itemId": "contract_handoff_token", "quantity": 1 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeDefId": "prototype_resource_node_alpha",
|
||||
"displayName": "Prototype Salvage Heap",
|
||||
"gatherLens": "consumer_salvage",
|
||||
"maxGathers": 10
|
||||
},
|
||||
{
|
||||
"nodeDefId": "prototype_subsurface_vein_beta",
|
||||
"displayName": "Prototype Subsurface Vein",
|
||||
"gatherLens": "subsurface",
|
||||
"maxGathers": 10
|
||||
},
|
||||
{
|
||||
"nodeDefId": "prototype_bio_mat_gamma",
|
||||
"displayName": "Prototype Bio Mat",
|
||||
"gatherLens": "bio",
|
||||
"maxGathers": 10
|
||||
},
|
||||
{
|
||||
"nodeDefId": "prototype_urban_bulk_delta",
|
||||
"displayName": "Prototype Urban Bulk",
|
||||
"gatherLens": "urban_bulk",
|
||||
"maxGathers": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"yields": [
|
||||
{
|
||||
"nodeDefId": "prototype_resource_node_alpha",
|
||||
"itemId": "scrap_metal_bulk",
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"nodeDefId": "prototype_subsurface_vein_beta",
|
||||
"itemId": "scrap_metal_bulk",
|
||||
"quantity": 2
|
||||
},
|
||||
{
|
||||
"nodeDefId": "prototype_bio_mat_gamma",
|
||||
"itemId": "scrap_metal_bulk",
|
||||
"quantity": 3
|
||||
},
|
||||
{
|
||||
"nodeDefId": "prototype_urban_bulk_delta",
|
||||
"itemId": "scrap_metal_bulk",
|
||||
"quantity": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/recipe-def.json",
|
||||
"title": "RecipeDef",
|
||||
"description": "Single recipe row for catalogs (e.g. content/recipes/*_recipes.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "displayName", "recipeKind", "requiredSkillId", "inputs", "outputs"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable recipe key for craft HTTP, quests, telemetry, and catalog lookup."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating character data."
|
||||
},
|
||||
"recipeKind": {
|
||||
"type": "string",
|
||||
"description": "process = refine/intermediate; make = finished good.",
|
||||
"enum": ["process", "make"]
|
||||
},
|
||||
"requiredSkillId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "SkillDef id gating craft execution (prototype Slice 3: refine on all rows)."
|
||||
},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/recipe-io-row.json"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/recipe-io-row.json"
|
||||
}
|
||||
},
|
||||
"requiredSkillLevel": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Optional level gate stub (unused in prototype Slice 3 rows)."
|
||||
},
|
||||
"stationTag": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional station/bench requirement stub (unused in prototype Slice 3)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/recipe-io-row.json",
|
||||
"title": "RecipeIoRow",
|
||||
"description": "Single input or output row on a RecipeDef (content/recipes/*_recipes.json).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["itemId", "quantity"],
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Item def id from content/items (prototype Slice 3: frozen six-item catalog)."
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Units consumed (input) or granted (output) per craft execution."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/resource-node-def.json",
|
||||
"title": "ResourceNodeDef",
|
||||
"description": "Single resource node row for catalogs (e.g. content/resource-nodes/*_resource_nodes.json). nodeDefId is stable forever—rename displayName only. See docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["nodeDefId", "displayName", "gatherLens", "maxGathers"],
|
||||
"properties": {
|
||||
"nodeDefId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable node key; must match interactableId for prototype wiring."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating state."
|
||||
},
|
||||
"gatherLens": {
|
||||
"type": "string",
|
||||
"description": "Prototype Slice 2 gather lens (one per frozen node). CI requires exactly one row per lens.",
|
||||
"enum": ["consumer_salvage", "subsurface", "bio", "urban_bulk"]
|
||||
},
|
||||
"maxGathers": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Successful gathers before node instance is depleted (prototype Slice 2)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/resource-yield-row.json",
|
||||
"title": "ResourceYieldRow",
|
||||
"description": "Fixed yield row for a resource node (content/resource-nodes/*_resource_yields.json). One row per nodeDefId in prototype Slice 2.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["nodeDefId", "itemId", "quantity"],
|
||||
"properties": {
|
||||
"nodeDefId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Resource node this yield applies to."
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Item def id from content/items (prototype Slice 2: scrap_metal_bulk only)."
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Items granted per successful gather."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,19 @@ This workspace contains detailed planning artifacts derived from the finalized m
|
|||
3. Use stable IDs (`E1`, `E1.M1`, etc.) for core game epics; use **`CT.M1`**-style IDs for [content tooling](tooling/internal_authoring.md) so tooling stays out of the E1–E9 sequence.
|
||||
4. Every new slice must reference telemetry and validation where applicable.
|
||||
|
||||
## Full-stack epics
|
||||
|
||||
**Epics deliver playable game features**, not server-only vertical slices. Enforced by **[`.cursor/rules/full-stack-epic-decomposition.md`](../../.cursor/rules/full-stack-epic-decomposition.md)** (`alwaysApply`).
|
||||
|
||||
When decomposing a prototype backlog:
|
||||
|
||||
1. Start from **[`docs/plans/_prototype-backlog-template.md`](../plans/_prototype-backlog-template.md)**.
|
||||
2. **Pair server and client stories** where the feature is player-visible (mirror Epic 1: NEO-23 + NEO-24).
|
||||
3. **Create all Linear issues and `blockedBy` links in the same decomposition pass** — including client slices — not as undocumented “optional follow-ups.”
|
||||
4. **Bruno / manual QA** prove contracts; **Godot** proves the prototype fun/readability gates in [vision](../../neon_sprawl_vision.plan.md).
|
||||
|
||||
Epic 3 established a dedicated client slice file: [E3S5-client-prototype-backlog.md](../plans/E3S5-client-prototype-backlog.md) (NEO-72–NEO-75). Apply the same pattern to other epics (e.g. planned E2 client slice for skill/perk HUD).
|
||||
|
||||
## Linear alignment
|
||||
|
||||
Engineering work is tracked as **issues** in **Linear** (team **NEO**), grouped under **projects** that mirror epics and modules. **Do not** recreate a Jira-style **Feature** issue tier—module- or slice-level grouping is **labels on the issue** only.
|
||||
|
|
@ -50,6 +63,6 @@ Template for new epics or major revisions: [epics/_epic_template.md](epics/_epic
|
|||
|
||||
## Next Actions
|
||||
|
||||
1. Expand slices into **Linear issues** under epic/module **projects**; tag **module / slice** scope with **labels** (not a duplicate Feature tier). Keep module IDs aligned with the master plan.
|
||||
1. Expand slices into **Linear issues** under epic/module **projects** when writing a **prototype backlog** — copy **[`_prototype-backlog-template.md`](../plans/_prototype-backlog-template.md)**; follow **[full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md)**. **Create all issues and `blockedBy` links in the same decomposition pass**, not at story kickoff. Set **estimate 1…N** on each issue to match slug execution order so the project board can sort by **Estimate** (ascending). Tag **`server`** / **`client`** and module scope with **labels**. Keep module IDs aligned with the master plan.
|
||||
2. Populate `modules/module_dependency_register.md` as contracts stabilize.
|
||||
3. Refine `milestones/phase_execution_plan.md` with dates and owners when execution starts.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
- Master epic reference: `neon_sprawl_vision.plan.md`
|
||||
- Related modules: `<E#.M# list>`
|
||||
|
||||
## Ownership and success (Level 1)
|
||||
|
||||
- **Ownership focus:** <Design + Engineering + **Client presentation (Godot)** when player-visible>
|
||||
- **Success criteria:** <outcome> — **playable in the client** for prototype slices, not server/Bruno-only proof.
|
||||
|
||||
## Objective
|
||||
|
||||
<One paragraph stating the player/business outcome this epic must deliver.>
|
||||
|
|
@ -20,25 +25,29 @@
|
|||
|
||||
## Implementation Slices (Backlog-Ready)
|
||||
|
||||
<!-- Full-stack policy: [.cursor/rules/full-stack-epic-decomposition.md](../../../.cursor/rules/full-stack-epic-decomposition.md) -->
|
||||
<!-- Backlog template: [docs/plans/_prototype-backlog-template.md](../../plans/_prototype-backlog-template.md) -->
|
||||
|
||||
### Slice 1 - <Name>
|
||||
|
||||
- Scope: <smallest shippable outcome>
|
||||
- Scope (server / content): <smallest shippable authoritative outcome>
|
||||
- Scope (client): <Godot HUD / wiring — **required if player-visible**; write "N/A — infrastructure only" otherwise>
|
||||
- Dependencies: <required modules or prior slices>
|
||||
- Acceptance criteria:
|
||||
- <criterion 1>
|
||||
- <criterion 2>
|
||||
- Telemetry hooks:
|
||||
- <events/KPIs this slice must emit>
|
||||
- Server: <criterion>
|
||||
- Client (Godot): <criterion — **required if player-visible**>
|
||||
- Telemetry hooks: <events/KPIs>
|
||||
- **Linear backlog:** `docs/plans/…` — create **server + client** issues in one pass; label client issues **`client`**.
|
||||
|
||||
### Slice 2 - <Name>
|
||||
|
||||
- Scope: <smallest shippable outcome>
|
||||
- Dependencies: <required modules or prior slices>
|
||||
- Scope (server / content): …
|
||||
- Scope (client): …
|
||||
- Dependencies: …
|
||||
- Acceptance criteria:
|
||||
- <criterion 1>
|
||||
- <criterion 2>
|
||||
- Telemetry hooks:
|
||||
- <events/KPIs this slice must emit>
|
||||
- Server: …
|
||||
- Client (Godot): …
|
||||
- Telemetry hooks: …
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
|
|
@ -47,7 +56,8 @@
|
|||
|
||||
## Definition of Done
|
||||
|
||||
- Functional behavior validated.
|
||||
- Prototype slices meet pass/fail gates **in the Godot client**, not Bruno-only ([vision — Prototype Scope](../../../neon_sprawl_vision.plan.md)).
|
||||
- **Server + client** Linear backlogs exist for every player-visible slice ([full-stack decomposition rule](../../../.cursor/rules/full-stack-epic-decomposition.md)).
|
||||
- Dependencies documented and stable.
|
||||
- Telemetry events emitted and visible.
|
||||
- No blocker defects for target phase.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
## Ownership and success (Level 1)
|
||||
|
||||
- **Ownership focus:** Economy Design + Content Engineering
|
||||
- **Success criteria:** Crafting is a primary progression lane with healthy supply-demand loops and consistent item relevance.
|
||||
- **Ownership focus:** Economy Design + Content Engineering + **Client presentation (Godot)**
|
||||
- **Success criteria:** Crafting is a primary progression lane with healthy supply-demand loops and consistent item relevance — **playable in the client**, not server/Bruno-only proof.
|
||||
|
||||
## Objective
|
||||
|
||||
|
|
@ -82,6 +82,20 @@ Implement gathering from world nodes, item/inventory representation, recipe exec
|
|||
- Player completes gather to refine to usable item used in combat or quest.
|
||||
- Telemetry hooks: `item_crafted`, `craft_failed` with reason, time-to-first-craft.
|
||||
|
||||
<a id="epic-3-slice-5"></a>
|
||||
|
||||
### Slice 5 - Client economy integration (prototype)
|
||||
|
||||
- Scope: Godot clients + HUD for **E3.M3** inventory, **E3.M1** gather feedback, **E3.M2** craft UI; playable **gather → refine → make** session in the client (vision prototype gates).
|
||||
- Dependencies: Slice 1–3 server APIs landed ([NEO-55](https://linear.app/neon-sprawl/issue/NEO-55), [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63), [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70)); [E2.M2](https://linear.app/neon-sprawl/issue/NEO-37) skill-progression GET for XP visibility.
|
||||
- Acceptance criteria:
|
||||
- Inventory HUD reflects server state after gather and craft.
|
||||
- Player completes **gather → refine → usable item** in Godot without Bruno.
|
||||
- **Salvage** / **refine** skill progression visible after gather/craft (Epic 2 Slice 3 integration AC).
|
||||
- Telemetry hooks: reuse Slice 1–3 server hook sites; client dev **`print`** / HUD feedback only until E9.M1 ingest.
|
||||
|
||||
**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).
|
||||
|
||||
<a id="epic-3-slice-4"></a>
|
||||
|
||||
### Slice 4 - Sinks and economy policy (pre-production)
|
||||
|
|
@ -101,5 +115,6 @@ Implement gathering from world nodes, item/inventory representation, recipe exec
|
|||
|
||||
## Definition of Done
|
||||
|
||||
- Prototype gather-craft loop meets plan pass/fail gates.
|
||||
- Pre-production adds sinks and policy with dashboard visibility.
|
||||
- Prototype gather-craft loop meets plan pass/fail gates **in the Godot client** (Slice 5), not Bruno-only.
|
||||
- Slices 1–3 + Slice 5 together satisfy [vision prototype in-scope features](../../../neon_sprawl_vision.plan.md) for crafting loop and progression visibility.
|
||||
- Pre-production adds sinks and policy with dashboard visibility (Slice 4).
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
**Mastery catalogs ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45)):** the same script validates `content/mastery/*_mastery.json` against [`content/schemas/mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json), cross-checks track `skillId` against loaded `SkillDef` ids, rejects **duplicate `perkId`**, enforces **branch integrity** (tier branch sets match; `perkIds` reference defined perks), and (Slice 4) requires **exactly one** track for **`salvage`** only.
|
||||
|
||||
**Item catalogs ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** the same script validates each row in `content/items/*_items.json` against [`content/schemas/item-def.schema.json`](../../../content/schemas/item-def.schema.json), rejects **duplicate `id`** across files, and (Slice 1) enforces the **frozen six-item** id set plus **exactly one row per `prototypeRole`** (`material` through `equip_stub`). Extend the script when additional catalogs and schemas ship.
|
||||
**Item catalogs ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** the same script validates each row in `content/items/*_items.json` against [`content/schemas/item-def.schema.json`](../../../content/schemas/item-def.schema.json), rejects **duplicate `id`** across files, and (Slice 1) enforces the **frozen six-item** id set plus **exactly one row per `prototypeRole`** (`material` through `equip_stub`).
|
||||
|
||||
**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.
|
||||
|
||||
**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,41 +7,105 @@
|
|||
| **Module ID** | E3.M1 |
|
||||
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | In Progress (see [dependency register](module_dependency_register.md); prototype gather XP anchor [NEO-41](../../plans/NEO-41-implementation-plan.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)) |
|
||||
| **Linear** | Label **`E3.M1`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |
|
||||
|
||||
## Purpose
|
||||
|
||||
World resource nodes, gather interactions, and yield outputs feeding inventory ([E3.M3](E3_M3_ItemizationAndInventorySchema.md)) and XP ([E2.M2](E2_M2_XpAwardAndLevelEngine.md)).
|
||||
**Authority:** [Client vs server authority](client_server_authority.md) — the server resolves **whether** a gather succeeds, **what** items are granted, **remaining node capacity**, and **skill XP** from gather; the client sends **interact intent** via [E1.M3 — InteractionAndTargetingLayer](E1_M3_InteractionAndTargetingLayer.md) (`POST …/interact`) and displays outcomes.
|
||||
|
||||
World resource nodes, gather interactions, and yield outputs feeding inventory ([E3.M3](E3_M3_ItemizationAndInventorySchema.md)) and skill XP ([E2.M2](E2_M2_XpAwardAndLevelEngine.md)). Ecology / respawn pacing per zone defers to [E4.M2 — SpawnEcologyController](E4_M2_SpawnEcologyController.md).
|
||||
|
||||
## Boundary: E1.M3 vs E3.M1
|
||||
|
||||
| Topic | E1.M3 (today) | E3.M1 (this module) |
|
||||
|--------|----------------|---------------------|
|
||||
| **Primary job** | Interactable eligibility (range, known id, kind) | **Gather resolution**: yields, depletion, inventory + XP side effects |
|
||||
| **Wire / HTTP** | `POST …/interact` + `InteractionResponse` | No separate gather route in prototype — E3.M1 hooks **after** E1.M3 allows interact |
|
||||
| **Registry** | `PrototypeInteractableRegistry` / `GET …/interactables` | **`ResourceNodeDef`** + **`ResourceYieldTable`** content; instance capacity store |
|
||||
| **Denials** | `out_of_range`, `unknown_interactable` | Adds **`node_depleted`**, `inventory_full`, etc. via gather engine |
|
||||
|
||||
**Shared policy:** horizontal reach on **X/Z** matches [NEO-9](../../plans/NEO-9-implementation-plan.md) / E1.M3 — gather does not redefine range math.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Spawn/manage nodes per `ResourceNodeDef`; resolve `GatherResult` via `ResourceYieldTable`; integrate interaction range via [E1.M3](E1_M3_InteractionAndTargetingLayer.md).
|
||||
- Author **`ResourceNodeDef`** and **`ResourceYieldTable`** content; load and expose defs to server subsystems.
|
||||
- Track per-instance **remaining gathers** (prototype capacity/depletion); deny when exhausted.
|
||||
- Resolve **`GatherResult`**: item grants into [E3.M3](E3_M3_ItemizationAndInventorySchema.md) inventory, **`salvage`** skill XP via [E2.M2](E2_M2_XpAwardAndLevelEngine.md) grant operations.
|
||||
- Integrate with **`resource_node`** interactables in `PrototypeInteractableRegistry` (world anchors until [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)).
|
||||
|
||||
**Deferred:** zone spawn profiles, regen timers, probabilistic yields, cross-skill unlock nodes — [gathering.md](../../game-design/gathering.md) open questions.
|
||||
|
||||
## Key contracts
|
||||
|
||||
| Contract | Role |
|
||||
|----------|------|
|
||||
| `ResourceNodeDef` | Node type, capacity, regen. |
|
||||
| `GatherResult` | Items and depletion state. |
|
||||
| `ResourceYieldTable` | Probabilities or fixed yields. |
|
||||
| Contract | Kind | Role |
|
||||
|----------|------|------|
|
||||
| `ResourceNodeDef` | Content | Node type, max gathers, gather lens, link to yield table. |
|
||||
| `ResourceYieldTable` | Content | Fixed item outputs per gather (`itemId`, quantity). |
|
||||
| `GatherResult` | Server-internal (prototype) | Success/deny, granted items, depletion snapshot, reason codes. |
|
||||
|
||||
### Contract notes ([contracts.md](contracts.md))
|
||||
|
||||
- **Content** defs live under `content/resource-nodes/` with JSON Schema in CI ([E3M1-01](../../plans/E3M1-prototype-backlog.md#e3m1-01--prototype-resourcenodedef--yield-tables--schemas--ci)).
|
||||
- **`GatherResult`** may promote to **wire** JSON or Protobuf when the client needs explicit gather payloads; until then, prototype uses **`InteractionResponse`** + inventory GET for verification.
|
||||
- **Denials:** stable **`reasonCode`** strings on interact deny (`node_depleted`, `inventory_full`, …) consistent with [E1.M3](E1_M3_InteractionAndTargetingLayer.md) patterns.
|
||||
|
||||
**Logical fields (illustrative):**
|
||||
|
||||
| Contract | Illustrative fields |
|
||||
|----------|---------------------|
|
||||
| `ResourceNodeDef` | `nodeDefId`; `displayName`; `gatherLens` (`consumer_salvage`, `subsurface`, `bio`, `urban_bulk`); `maxGathers` — see `content/resource-nodes/*_resource_nodes.json` |
|
||||
| `ResourceYieldTable` | `nodeDefId`; `itemId`; `quantity` — prototype: one row per node in `*_resource_yields.json` |
|
||||
| `GatherResult` | `success`; `reasonCode`; `grantsApplied[]`; `remainingGathers`; optional `xpGrantSummary` |
|
||||
|
||||
## Module dependencies
|
||||
|
||||
- **E1.M3** — InteractionAndTargetingLayer.
|
||||
- **E2.M2** — XpAwardAndLevelEngine.
|
||||
- **E1.M3** — InteractionAndTargetingLayer (`POST …/interact`, interactable descriptors).
|
||||
- **E2.M2** — XpAwardAndLevelEngine (skill XP grant operations).
|
||||
- **E3.M3** — ItemizationAndInventorySchema (inventory grants) — logical dependency for Slice 2 yields; register lists E1.M3 + E2.M2 only so gather XP anchor ([NEO-41](../../plans/NEO-41-implementation-plan.md)) could land before inventory.
|
||||
|
||||
## Dependents (by design)
|
||||
|
||||
- **E3.M2** — RefinementAndRecipeExecution (inputs).
|
||||
- **E4.M2** — SpawnEcologyController.
|
||||
- **E3.M2** — RefinementAndRecipeExecution (crafted inputs from gathered materials).
|
||||
- **E4.M2** — SpawnEcologyController (respawn / ecology).
|
||||
|
||||
## Designer note: gather lenses and yields
|
||||
|
||||
Prototype Slice 2 uses **four** nodes — one per [gather lens](../../game-design/gathering.md#node-lenses-design-facing). All prototype yields grant **`scrap_metal_bulk`** (different quantities per node are allowed) so the gather→refine loop can start without expanding the item catalog. **Skill XP** stays **`salvage`** + **`activity`** (10 XP) on every node until per-lens skill mapping is a deliberate content pass.
|
||||
|
||||
**Depletion:** each interactable instance has **`maxGathers`** successful collects; at zero, interact returns **`node_depleted`**. Regen on timer is **out of scope** for Slice 2 ([E4.M2](E4_M2_SpawnEcologyController.md)).
|
||||
|
||||
## Prototype Slice 2 freeze (E3M1-01)
|
||||
|
||||
The **first shipped four-node spine** is **frozen** for downstream references (recipes, ecology, quests). **`nodeDefId`** must match **`interactableId`** in `PrototypeInteractableRegistry` for prototype wiring.
|
||||
|
||||
| `nodeDefId` / `interactableId` | `gatherLens` | Notes |
|
||||
|-------------------------------|--------------|--------|
|
||||
| **`prototype_resource_node_alpha`** | `consumer_salvage` | NEO-9/NEO-41 anchor **(12, −6)** |
|
||||
| **`prototype_subsurface_vein_beta`** | `subsurface` | NEO-63 anchor **(18, −6)** |
|
||||
| **`prototype_bio_mat_gamma`** | `bio` | NEO-63 anchor **(12, 0)** |
|
||||
| **`prototype_urban_bulk_delta`** | `urban_bulk` | NEO-63 anchor **(18, 0)** |
|
||||
|
||||
**Rules:** Do **not** rename these ids after ship. CI enforces **exactly four** node defs and valid yield item references. Relaxing the gate belongs in a new Linear issue when the roster grows.
|
||||
|
||||
## Related implementation slices
|
||||
|
||||
Epic 3 **Slice 2** — 4–6 node types; `resource_gathered`, `gather_node_depleted`.
|
||||
Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_economy.md#epic-3-slice-2): 4–6 node types, yield tables, `resource_gathered`, `gather_node_depleted`.
|
||||
|
||||
**Prototype implementation anchor (NEO-41):** until **`GatherResult`** / yield tables exist, the repo treats a **successful** **`POST …/interact`** on an entry with **`kind: resource_node`** (`prototype_resource_node_alpha`) as **gather completion** for **skill XP** only — **`salvage`** + **`activity`** via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) (same rules as NEO-38). Migrate this hook when a dedicated gather pipeline lands.
|
||||
**Interact path (NEO-63):** **`POST …/interact`** on **`kind: resource_node`** delegates to **`GatherOperations`** — success grants inventory + **`salvage`** XP + depletion; denies return **`allowed: false`** + **`reasonCode`** (`node_depleted`, `inventory_full`, …). **Four** anchors in **`PrototypeInteractableRegistry`**. Plan: [NEO-63 implementation plan](../../plans/NEO-63-implementation-plan.md); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63).
|
||||
|
||||
**Gather engine (NEO-62):** **`GatherOperations.TryGather`** → **`GatherResult`** (`success`, `reasonCode`, `grantsApplied[]`, `remainingGathers`, optional `xpGrantSummary`) — inventory + **`salvage`** XP + depletion commit in one orchestrated path; server-internal until promoted to wire. Plan: [NEO-62 implementation plan](../../plans/NEO-62-implementation-plan.md); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62).
|
||||
|
||||
**Gather telemetry hooks (NEO-64):** comment-only **`resource_gathered`** + **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** (`TODO(E9.M1)`; no ingest). **`gather_node_depleted`** fires on success when **`remainingGathers`** reaches **0** — not on pre-gather depletion deny. Plan: [NEO-64 implementation plan](../../plans/NEO-64-implementation-plan.md); [server README — Gather telemetry hooks (NEO-64)](../../../server/README.md#gather-telemetry-hooks-neo-64); manual QA: [`NEO-64`](../../manual-qa/NEO-64.md).
|
||||
|
||||
**Resource node definitions HTTP (NEO-60):** **`GET /game/world/resource-node-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`**; each row includes nested **`yield`** summary. Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. Plan: [NEO-60 implementation plan](../../plans/NEO-60-implementation-plan.md).
|
||||
|
||||
**Resource node instance depletion store (NEO-61):** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init remaining gathers from catalog **`maxGathers`**, atomic decrement, **`node_depleted`** / **`unknown_node`** reason codes; Postgres **`V006`** or in-memory fallback. Plan: [NEO-61 implementation plan](../../plans/NEO-61-implementation-plan.md).
|
||||
|
||||
**Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64). **Client (Slice 5):** gather feedback **landed** — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-02** [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73): **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73 implementation plan](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section.
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 3.
|
||||
- [gathering.md](../../game-design/gathering.md)
|
||||
- [Module dependency register](module_dependency_register.md)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,25 @@
|
|||
| **Module ID** | E3.M2 |
|
||||
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **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 |
|
||||
|
||||
## Implementation snapshot
|
||||
|
||||
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP), same persistence and **`allowedXpSourceKinds`** rules as **`POST …/skill-progression`**. **No** `CraftRequest` / **`CraftResult`** route yet; when this module adds authoritative craft/refine success, call that helper once on success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)).
|
||||
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP). **`CraftOperations.TryCraft`** (NEO-69) invokes the same grant path on craft success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69)).
|
||||
|
||||
**E3M2-01 (NEO-65):** Frozen 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 plan](../../plans/NEO-65-implementation-plan.md)). **E3M2-02 (NEO-66):** fail-fast server load under `server/NeonSprawl.Server/Game/Crafting/` — `RecipeDefinitionCatalogLoader`, `RecipeDefinitionCatalog`, cross-check vs item + skill catalogs, Slice 3 gate; [NEO-66 plan](../../plans/NEO-66-implementation-plan.md); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **E3M2-03 (NEO-67):** injectable **`IRecipeDefinitionRegistry`** + DI; [NEO-67 plan](../../plans/NEO-67-implementation-plan.md). **E3M2-04 (NEO-68):** **`GET /game/world/recipe-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/recipe-definitions/`. Plan: [NEO-68 implementation plan](../../plans/NEO-68-implementation-plan.md). **E3M2-05 (NEO-69):** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP; [NEO-69 plan](../../plans/NEO-69-implementation-plan.md); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **E3M2-06 (NEO-70):** **`POST /game/players/{id}/craft`** — wire **`CraftResponse`** backed by **`CraftOperations`**; Bruno gather→refine→make spine; [NEO-70 plan](../../plans/NEO-70-implementation-plan.md). **E3M2-07 (NEO-71):** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`**; [NEO-71 plan](../../plans/NEO-71-implementation-plan.md); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71).
|
||||
|
||||
### `CraftResult` / wire `CraftResponse` (NEO-69 engine, NEO-70 HTTP)
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
| **`Success`** | `true` when inputs removed, outputs granted, and refine XP applied. |
|
||||
| **`ReasonCode`** | Stable deny string when **`Success`** is `false`; omitted on success. |
|
||||
| **`InputsConsumed`** | Scaled `{ itemId, quantity }` rows removed on success. |
|
||||
| **`OutputsGranted`** | Scaled `{ itemId, quantity }` rows added on success. |
|
||||
| **`XpGrantSummary`** | Compact refine XP summary (`refine`, **10**, `activity`) on success. |
|
||||
|
||||
Promoted to wire JSON as **`CraftResponse`** on **`POST /game/players/{id}/craft`** (NEO-70). Telemetry hooks: **`item_crafted`** / **`craft_failed`** in **`CraftOperations.TryCraft`** (NEO-71).
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
@ -42,9 +56,39 @@ Implements the processing and crafting pipeline: raw-to-refined flows and recipe
|
|||
- **E6.M4** — [RewardParityEnforcer](E6_M4_RewardParityEnforcer.md): PvP ↔ PvE/crafting equivalents involving craft outputs.
|
||||
- **Epic 2 integration** — Callers award crafting XP via [E2.M2](E2_M2_XpAwardAndLevelEngine.md) at activity sites.
|
||||
|
||||
## Designer note: prototype recipe shape
|
||||
|
||||
Each **`RecipeDef`** row under `content/recipes/*.json` declares:
|
||||
|
||||
- **`recipeKind`** — **`process`** (refine / intermediate) or **`make`** (finished good).
|
||||
- **`requiredSkillId`** — prototype uses **`refine`** for all eight rows (no separate Make skill in the skill trio).
|
||||
- **`inputs`** / **`outputs`** — arrays of `{ itemId, quantity }` referencing only frozen [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) item ids.
|
||||
- **`requiredSkillLevel`** — **omitted** in Slice 3 (no level gates until a deliberate content pass).
|
||||
|
||||
**Prototype v1:** no station tags, craft failure RNG, or quality rolls — deterministic success when inputs and bag/equipment capacity allow.
|
||||
|
||||
## Prototype Slice 3 freeze (E3M2-01)
|
||||
|
||||
The **first shipped eight-recipe spine** under `content/recipes/*.json` is **frozen** for downstream references (craft HTTP, Bruno spine, quest objectives) until a deliberate migration or follow-up issue expands the roster.
|
||||
|
||||
| `RecipeDef.id` | `recipeKind` | Primary inputs → output | Demo role |
|
||||
|----------------|--------------|-------------------------|-----------|
|
||||
| **`refine_scrap_standard`** | `process` | 5× **`scrap_metal_bulk`** → 1× **`refined_plate_stock`** | Default refine after gather |
|
||||
| **`refine_scrap_efficient`** | `process` | 10× **`scrap_metal_bulk`** → 2× **`refined_plate_stock`** | Branching refine efficiency |
|
||||
| **`make_field_stim_mk0`** | `make` | 2× **`refined_plate_stock`** + 1× **`scrap_metal_bulk`** → 1× **`field_stim_mk0`** | Primary gather→refine→combat consumable |
|
||||
| **`make_field_stim_batch`** | `make` | 5× **`refined_plate_stock`** + 3× **`scrap_metal_bulk`** → 3× **`field_stim_mk0`** | Batch make variant |
|
||||
| **`make_prototype_armor`** | `make` | 8× **`refined_plate_stock`** + 5× **`scrap_metal_bulk`** → 1× **`prototype_armor_shell`** | Equip-stub output |
|
||||
| **`make_armor_quick`** | `make` | 4× **`refined_plate_stock`** + 3× **`scrap_metal_bulk`** → 1× **`prototype_armor_shell`** | Alternate armor path (branching) |
|
||||
| **`make_survey_drone_kit`** | `make` | 3× **`refined_plate_stock`** + 2× **`scrap_metal_bulk`** → 1× **`survey_drone_kit`** | Utility output |
|
||||
| **`make_contract_token`** | `make` | 2× **`refined_plate_stock`** + 1× **`scrap_metal_bulk`** → 1× **`contract_handoff_token`** | Quest token output |
|
||||
|
||||
**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)) enforces **exactly** these eight ids, at least one **`process`** and one **`make`** row, **`requiredSkillId: refine`** on every row, input/output **`itemId`** cross-check against the frozen item catalog, and duplicate-`id` rejection across all recipe JSON files. Implementation: [NEO-65 plan](../../plans/NEO-65-implementation-plan.md).
|
||||
|
||||
## Related implementation slices
|
||||
|
||||
See Epic 3 **Slice 3 — Recipes and crafting pipeline**: gather → refine → item used in combat or quest; telemetry `item_crafted`, `craft_failed`, time-to-first-craft.
|
||||
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).
|
||||
|
||||
## Risks and telemetry
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures.
|
|||
|
||||
**Player inventory HTTP (NEO-55):** **`GET` / `POST /game/players/{id}/inventory`** — versioned snapshot + **`mutationKind`** add/remove backed by **`PlayerInventoryOperations`**; Bruno `bruno/neon-sprawl-server/inventory/`. Plan: [NEO-55 implementation plan](../../plans/NEO-55-implementation-plan.md).
|
||||
|
||||
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks).
|
||||
**Inventory telemetry hooks (NEO-56):** comment-only **`item_created`** (successful add) and **`inventory_transfer_denied`** + **`reasonCode`** anchors in **`PlayerInventoryOperations`**; [server README — Player inventory (NEO-56)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http). Plan: [NEO-56 implementation plan](../../plans/NEO-56-implementation-plan.md); manual QA [`NEO-56`](../../manual-qa/NEO-56.md).
|
||||
|
||||
**Client inventory HUD (NEO-72):** Godot **`inventory_client.gd`** + **`item_definitions_client.gd`** — boot **`GET`** inventory snapshot + cached item defs for **`displayName`**; **`UICanvas/InventoryLabel`**; manual refresh **`I`** ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section.
|
||||
|
||||
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks). **Client (Slice 5):** inventory HUD landed — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-01** [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72).
|
||||
|
||||
## Source anchors
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,9 @@ 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 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
|
||||
| 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/`. **Still planned:** telemetry hook sites (NEO-56). | [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), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.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. **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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -42,13 +42,19 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | In Progress |
|
||||
| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Planned |
|
||||
| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | Ready |
|
||||
| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Ready |
|
||||
| E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | In Progress |
|
||||
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
|
||||
| E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned |
|
||||
|
||||
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), and **NEO-55** (`GET`/`POST /game/players/{id}/inventory`) moved the register row to **In Progress**; later slices update the alignment table as they land.
|
||||
**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`), **NEO-62** (`GatherOperations` + `GatherResult`), **NEO-63** (interact → gather engine + four registry anchors + Bruno), **NEO-64** (comment-only **`resource_gathered`** / **`gather_node_depleted`** telemetry hook sites in `GatherOperations.TryGather`) — E3M1-01–**E3M1-08** complete; register row **Ready**.
|
||||
|
||||
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01**–**E3M3-07** complete; **NEO-72** (client inventory HUD — `inventory_client.gd`, `item_definitions_client.gd`, `InventoryLabel`; [`NEO-72` manual QA](../../manual-qa/NEO-72.md)) — Epic 3 Slice 5 **E3S5-01** client landed; register row **In Progress** until Slice 2+; later slices update the alignment table as they land.
|
||||
|
||||
**E3.M2 note:** Epic 3 **Slice 3** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); label **`E3.M2`**. See [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md), [E3_M2_RefinementAndRecipeExecution.md](E3_M2_RefinementAndRecipeExecution.md). **NEO-42** (refine XP prep helper) is **Done**; **NEO-65**–**NEO-71** complete — **NEO-71** (comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in `CraftOperations.TryCraft`) — E3M2-01–**E3M2-07** complete; register row **Ready**. Upstream: E3.M1 **Ready**, E3.M3 inventory landed.
|
||||
|
||||
**E3 Slice 5 (client) note:** Epic 3 **client economy integration** — [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) → [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75); labels **`client`** + module ids. See [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md), [epic_03 Slice 5](../epics/epic_03_crafting_economy.md#epic-3-slice-5). Required for prototype **playable** gather→craft loop (not Bruno-only).
|
||||
|
||||
### Epic 4 — World Topology
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
# Manual QA — NEO-56 (inventory telemetry hook sites)
|
||||
|
||||
Reference: [implementation plan](../plans/NEO-56-implementation-plan.md), [NEO-54 implementation plan](../plans/NEO-54-implementation-plan.md) (inventory engine), [NEO-55 implementation plan](../plans/NEO-55-implementation-plan.md) (inventory HTTP).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`) if exercising Bruno; not required for code review only.
|
||||
- This story adds **comments only**; inventory behavior matches NEO-54/NEO-55.
|
||||
|
||||
## Code review check
|
||||
|
||||
- [ ] Open `server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs`.
|
||||
- [ ] **`TryAddStack`:** confirm **`item_created`** comment block immediately before **`Applied`** return (successful add only).
|
||||
- [ ] **`TryAddStack` / `TryRemoveStack`:** confirm **`inventory_transfer_denied`** hook on post-mutation **`Denied`** returns (`inventory_full`, `insufficient_quantity`).
|
||||
- [ ] **`Deny`:** confirm **`inventory_transfer_denied`** hook covers early validation denies (`invalid_quantity`, `invalid_item`).
|
||||
- [ ] Confirm **`TODO(E9.M1)`** markers and planned payload fields; **no** new `ILogger` or metrics calls.
|
||||
- [ ] Confirm **no** duplicate hook comments in `PlayerInventoryApi.cs`.
|
||||
- [ ] Open `PlayerInventoryReasonCodes.cs` — summary cross-references NEO-56 hook anchor.
|
||||
- [ ] Open [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../server/README.md#player-inventory-neo-54-store-neo-55-http) — NEO-56 telemetry subsection present.
|
||||
|
||||
## Regression (optional)
|
||||
|
||||
```bash
|
||||
cd server && dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Expect all tests pass (comments-only change).
|
||||
|
||||
## Bruno (optional sanity)
|
||||
|
||||
From `bruno/neon-sprawl-server/inventory/` against a running server:
|
||||
|
||||
- **Get inventory** — 200 empty grid for `dev-local-1`.
|
||||
- **Post inventory add** — `applied: true` unchanged.
|
||||
- **Post inventory add deny invalid item** — `applied: false`, `reasonCode: invalid_item` unchanged.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# NEO-60 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-60 |
|
||||
| Title | E3.M1: GET world resource-node-definitions + Bruno |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-60/e3m1-get-world-resource-node-definitions-bruno |
|
||||
| Plan | `docs/plans/NEO-60-implementation-plan.md` |
|
||||
| Branch | `NEO-60-e3m1-get-world-resource-node-definitions-bruno` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Server built and configured with default `Content:ResourceNodesDirectory` pointing at repo `content/resource-nodes` (local dev / `InMemoryWebApplicationFactory` tests use the same layout).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start **`NeonSprawl.Server`** (e.g. `dotnet run` from `server/NeonSprawl.Server`).
|
||||
2. **`GET /game/world/resource-node-definitions`** — expect **200** and **`Content-Type`** containing **`application/json`**. Example (default dev URL from `Properties/launchSettings.json` and Bruno `environments/Local.bru`; change the host/port if yours differs):
|
||||
|
||||
```bash
|
||||
curl -sS -i "http://localhost:5253/game/world/resource-node-definitions"
|
||||
```
|
||||
|
||||
3. Parse JSON — expect **`schemaVersion`** === **1**, **`nodes`** array length **4**.
|
||||
4. Confirm **`id`** values match the frozen prototype four in ordinal order: **`prototype_bio_mat_gamma`**, **`prototype_resource_node_alpha`**, **`prototype_subsurface_vein_beta`**, **`prototype_urban_bulk_delta`**.
|
||||
5. Spot-check **`prototype_resource_node_alpha`**: **`displayName`** “Prototype Salvage Heap”, **`gatherLens`** **`consumer_salvage`**, **`maxGathers`** **10**, **`yield.itemId`** **`scrap_metal_bulk`**, **`yield.quantity`** **1**.
|
||||
6. Spot-check **`prototype_urban_bulk_delta`**: **`gatherLens`** **`urban_bulk`**, **`yield.quantity`** **5**.
|
||||
7. Optional: run **`bruno/neon-sprawl-server/resource-node-definitions/Get resource node definitions.bru`** against the same **`baseUrl`** (see `environments/Local.bru`).
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Manual QA — NEO-63 (gather via interact → inventory)
|
||||
|
||||
Reference: [implementation plan](../plans/NEO-63-implementation-plan.md), [NEO-62 implementation plan](../plans/NEO-62-implementation-plan.md) gather engine, [NEO-55](./NEO-55.md) inventory GET.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`).
|
||||
- Player **`dev-local-1`** (default seed).
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:5253
|
||||
ID=dev-local-1
|
||||
```
|
||||
|
||||
## Happy path — gather → inventory + XP
|
||||
|
||||
Move in range of **`prototype_resource_node_alpha`** (anchor **12, −6**; stand at **10, −6**):
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"target":{"x":10,"y":0.9,"z":-6}}'
|
||||
```
|
||||
|
||||
Interact:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"interactableId":"prototype_resource_node_alpha"}'
|
||||
```
|
||||
|
||||
Expect **HTTP 200**, **`allowed`:** **true**.
|
||||
|
||||
Inventory should show **`scrap_metal_bulk`** quantity **1** (or +1 per repeat):
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/inventory"
|
||||
```
|
||||
|
||||
Skill progression should show **`salvage`** **`xp`** **+10** per successful gather:
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/skill-progression"
|
||||
```
|
||||
|
||||
## Depletion deny
|
||||
|
||||
Uses **`prototype_urban_bulk_delta`** (anchor **18, 0**) so earlier Bruno interaction requests on alpha do not pre-deplete capacity in the shared CI Postgres store. Move in range (**16, 0**), exhaust with **10** successful interacts on delta, then interact again:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"target":{"x":16,"y":0.9,"z":0}}'
|
||||
```
|
||||
|
||||
(Repeat interact on **`prototype_urban_bulk_delta`** ten times with **`allowed: true`**, then:)
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"interactableId":"prototype_urban_bulk_delta"}'
|
||||
```
|
||||
|
||||
Expect **HTTP 200**, **`allowed`:** **false**, **`reasonCode`:** **`node_depleted`**. Inventory and XP totals unchanged on that denied request.
|
||||
|
||||
## Control — terminal
|
||||
|
||||
Move near **`prototype_terminal`**, interact with **`prototype_terminal`**: **`allowed`:** **true**; no **`scrap_metal_bulk`** added; no gather XP from terminal alone.
|
||||
|
||||
## Bruno
|
||||
|
||||
Run folder `bruno/neon-sprawl-server/interaction/` — **`Post interact gather then get inventory.bru`** and **`Post interact depleted node deny.bru`**.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Manual QA — NEO-64 (gather telemetry hook sites)
|
||||
|
||||
Reference: [implementation plan](../plans/NEO-64-implementation-plan.md), [NEO-62 implementation plan](../plans/NEO-62-implementation-plan.md) (gather engine), [NEO-63 implementation plan](../plans/NEO-63-implementation-plan.md) (interact wiring).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`) if exercising Bruno; not required for code review only.
|
||||
- This story adds **comments only**; gather behavior matches NEO-62/NEO-63.
|
||||
|
||||
## Code review check
|
||||
|
||||
- [ ] Open `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs`.
|
||||
- [ ] Confirm **`resource_gathered`** comment block on the successful gather path (after depletion commit, before **`GatherResult`** return).
|
||||
- [ ] Confirm **`gather_node_depleted`** comment block is inside `if (remainingAfterGather == 0)` (not on pre-gather **`node_depleted`** deny).
|
||||
- [ ] Confirm **`TODO(E9.M1)`** markers and planned payload fields; **no** new `ILogger` or metrics calls.
|
||||
- [ ] Confirm **no** duplicate hook comments in `InteractionApi.cs`.
|
||||
- [ ] Open `GatherResult.cs` — summary cross-references NEO-64 hook anchor in **`TryGather`**.
|
||||
- [ ] Open [server README — Gather telemetry hooks (NEO-64)](../../server/README.md#gather-telemetry-hooks-neo-64) — NEO-64 subsection present.
|
||||
|
||||
## Regression (optional)
|
||||
|
||||
```bash
|
||||
cd server && dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Expect all tests pass (comments-only change).
|
||||
|
||||
## Bruno (optional sanity)
|
||||
|
||||
From `bruno/neon-sprawl-server/interaction/` against a running server:
|
||||
|
||||
- **Post interact prototype resource node gather** — `allowed: true` unchanged.
|
||||
- **Post interact gather then get inventory** — scrap quantity increases unchanged.
|
||||
- **Post interact depleted node deny** — `allowed: false`, `reasonCode: node_depleted` unchanged.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# NEO-68 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-68 |
|
||||
| Title | E3.M2: GET world recipe-definitions + Bruno |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-68/e3m2-get-world-recipe-definitions-bruno |
|
||||
| Plan | `docs/plans/NEO-68-implementation-plan.md` |
|
||||
| Branch | `NEO-68-get-world-recipe-definitions-bruno` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Server built and configured with default content paths pointing at repo `content/recipes`, `content/items`, and `content/skills` (local dev / `InMemoryWebApplicationFactory` tests use the same layout).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start **`NeonSprawl.Server`** (e.g. `dotnet run` from `server/NeonSprawl.Server`).
|
||||
2. **`GET /game/world/recipe-definitions`** — expect **200** and **`Content-Type`** containing **`application/json`**. Example (default dev URL from `Properties/launchSettings.json` and Bruno `environments/Local.bru`; change the host/port if yours differs):
|
||||
|
||||
```bash
|
||||
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
|
||||
```
|
||||
|
||||
3. Parse JSON — expect **`schemaVersion`** === **1**, **`recipes`** array length **8**.
|
||||
4. Confirm **`id`** values match the frozen prototype eight in **registry id order** (ordinal):
|
||||
|
||||
**`make_armor_quick`**, **`make_contract_token`**, **`make_field_stim_batch`**, **`make_field_stim_mk0`**, **`make_prototype_armor`**, **`make_survey_drone_kit`**, **`refine_scrap_efficient`**, **`refine_scrap_standard`**
|
||||
|
||||
(same sequence as `RecipeDefinitionsWorldApiTests.FrozenEightInIdOrder`).
|
||||
5. Spot-check **`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 }]`.
|
||||
6. Spot-check **`make_field_stim_mk0`**: **`recipeKind`** **`make`**, two **`inputs`** rows (`refined_plate_stock` × 2, `scrap_metal_bulk` × 1), **`outputs`** `[{ itemId: field_stim_mk0, quantity: 1 }]`.
|
||||
7. Optional: run **`bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru`** against the same **`baseUrl`** (see `environments/Local.bru`).
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# NEO-72 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-72 |
|
||||
| Title | E3.M3: Client inventory snapshot HUD (E3S5-01) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-72/e3m3-client-inventory-snapshot-hud-e3s5-01 |
|
||||
| Plan | `docs/plans/NEO-72-implementation-plan.md` |
|
||||
| Branch | `NEO-72-client-inventory-snapshot-hud` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Server running with default dev player (`dev-local-1`) and item catalog loaded (NEO-51–NEO-55 on `main`).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). **`InventoryLabel`** (below cooldown HUD) shows **`Inventory: (refresh: I)`**, **`Bag: (empty)`**, and **`Equipment:`** / **`slot 0: — empty`** on a fresh dev player.
|
||||
3. Seed inventory via Bruno or curl:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}'
|
||||
```
|
||||
|
||||
4. Press **`I`** (or **`inventory_refresh`**). HUD lists occupied bag slot with **`Scrap Metal (Bulk) x5`** (display name from NEO-53 defs).
|
||||
5. Stop the server; press **`I`**. HUD shows **`error — …`** and Output shows **`InventoryClient:`** **`push_warning`**.
|
||||
6. Restart server; press **`I`** again — HUD returns to synced inventory lines.
|
||||
|
||||
## Notes
|
||||
|
||||
- **`I`** is handled in **`main.gd`** **`_unhandled_key_input`** (same reliability pattern as **E** / **R** interact keys).
|
||||
- POST inventory mutations from UI are out of scope; use Bruno/curl to seed stock for this story.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# NEO-73 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-73 |
|
||||
| Title | E3.M1: Client gather feedback on interact (E3S5-02) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-73/e3m1-client-gather-feedback-on-interact-e3s5-02 |
|
||||
| Plan | `docs/plans/NEO-73-implementation-plan.md` |
|
||||
| Branch | `NEO-73-e3m1-client-gather-feedback-on-interact` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Server running with default dev player (`dev-local-1`); gather via interact landed (NEO-63); inventory HUD landed (NEO-72).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage: level …, xp …`** after boot (often **`level 1, xp 0`** on fresh player).
|
||||
3. **WASD** to **`prototype_resource_node_alpha`** anchor **(12, −6)** until the resource glow pair brightens. Press **R** (do **not** press **I** first).
|
||||
4. **`GatherFeedbackLabel`** shows **`Gather: +1 Scrap Metal (Bulk)`** (or agreed display name). **`InventoryLabel`** updates scrap quantity without manual refresh. **`SkillProgressionLabel`** **`salvage`** xp increases by **10**.
|
||||
5. Walk to **`prototype_subsurface_vein_beta`** **(18, −6)**; press **R**. Feedback shows **`+2`** scrap delta; inventory and salvage xp update again.
|
||||
6. Repeat at **`prototype_bio_mat_gamma`** **(12, 0)** (**+3**) and **`prototype_urban_bulk_delta`** **(18, 0)** (**+5**) if nodes still have capacity.
|
||||
7. Deplete **`prototype_urban_bulk_delta`**: press **R** ten times while in range, then once more. **`GatherFeedbackLabel`** shows **`Gather: denied — node_depleted`** (inventory unchanged on last press — verify with **I** if needed).
|
||||
8. Press **E** at terminal: **`GatherFeedbackLabel`** stays on last gather message (terminal does not overwrite gather line).
|
||||
|
||||
## Notes
|
||||
|
||||
- **R** posts the **nearest in-range** `resource_node` from the interactables catalog; walk to each anchor before pressing **R**.
|
||||
- Output still prints **`Interaction [<id>]: …`** lines for dev debugging.
|
||||
|
|
@ -117,7 +117,7 @@ Working backlog for **Epic 2 — Slice 4** ([mastery and pacing](../decompositio
|
|||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (optional future issue).
|
||||
- Godot HUD — decompose per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) (planned **E2 client slice**; skill/perk UI not covered by E3 Slice 5 alone).
|
||||
- Gameplay stat application.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
# E3.M1 — Prototype story backlog (ResourceNodeAndGatherLoop)
|
||||
|
||||
Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-2)). Decomposition and contracts: [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E3.M1`**; add **`server`** / **`Story`** as listed per issue.
|
||||
|
||||
**Precursor (do not re-scope):** [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) grants **`salvage`** skill XP on successful **`POST …/interact`** when interactable **`kind`** is **`resource_node`** — **no** `GatherResult`, yields, inventory, or depletion yet. Slice 2 **migrates** XP into the gather engine and adds item grants + node capacity.
|
||||
|
||||
**Upstream (must be landed):** [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) Slice 1 — frozen item catalog, **`IPlayerInventoryStore`**, inventory operations ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)–[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56)). **E1.M3** interact + range ([NEO-9](https://linear.app/neon-sprawl/issue/NEO-9), [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25)). **E2.M2** grant path ([NEO-38](https://linear.app/neon-sprawl/issue/NEO-38)).
|
||||
|
||||
**Prototype gather spine (frozen in E3M1-01):** **four** `ResourceNodeDef` rows — one per [gather lens](../game-design/gathering.md#node-lenses-design-facing) — each with a fixed **`ResourceYieldTable`** entry awarding **`scrap_metal_bulk`** (primary material from [E3.M3 freeze](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#prototype-slice-1-freeze-neo-50)). World placement stays on **`PrototypeInteractableRegistry`** until zone graph ([E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md)) ships.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E3M1-01 | [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) |
|
||||
| E3M1-02 | [NEO-58](https://linear.app/neon-sprawl/issue/NEO-58) |
|
||||
| E3M1-03 | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) |
|
||||
| E3M1-04 | [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) |
|
||||
| E3M1-05 | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) |
|
||||
| E3M1-06 | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) |
|
||||
| E3M1-07 | [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) |
|
||||
| E3M1-08 | [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) |
|
||||
|
||||
**Dependency graph in Linear:** E3M1-02 blocked by E3M1-01. E3M1-03 blocked by E3M1-02. E3M1-04 blocked by E3M1-03. E3M1-05 blocked by E3M1-03. E3M1-06 blocked by E3M1-05 and E3M1-03 (inventory from E3.M3). E3M1-07 blocked by E3M1-06. E3M1-08 blocked by E3M1-06 (may parallel E3M1-07).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Depends on |
|
||||
|-------|------|------------|
|
||||
| 1 | **E3M1-01** | E3.M3 item catalog (NEO-50) |
|
||||
| 2 | **E3M1-02** | E3M1-01 |
|
||||
| 3 | **E3M1-03** | E3M1-02 |
|
||||
| 4 | **E3M1-04** | E3M1-03 |
|
||||
| 5 | **E3M1-05** | E3M1-03 |
|
||||
| 6 | **E3M1-06** | E3M1-05, E3.M3 inventory store (NEO-54+) |
|
||||
| 7 | **E3M1-07** | E3M1-06 |
|
||||
| 8 | **E3M1-08** | E3M1-06 |
|
||||
|
||||
**Downstream (separate modules):** [E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) recipes consume gathered materials; [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) refine XP hook runs after craft success; [E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md) owns respawn/ecology pacing later.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Node count | **4** frozen defs (epic allows 4–6) | One per gather lens; enough variety without bloating prototype registry |
|
||||
| Gather wire surface | Keep **`POST …/interact`** ([E1.M3](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md)) | No separate gather HTTP in prototype; E3.M1 owns resolution **after** interact eligibility |
|
||||
| Yields | **Fixed** quantities per gather (no RNG) | Deterministic tests; probability tables are pre-production |
|
||||
| Depleted node | **`Allowed: false`** + **`reasonCode: node_depleted`** | Prevents “free” taps; clearer than silent no-op |
|
||||
| XP | **Migrate** NEO-41 inline grant into gather engine on successful yield | Single call site for gather completion side effects |
|
||||
| Skill per node | All prototype nodes award **`salvage`** + **`activity`** (10 XP) | Matches NEO-41; per-lens skill mapping is content follow-up |
|
||||
|
||||
---
|
||||
|
||||
### E3M1-01 — Prototype ResourceNodeDef + yield tables + schemas + CI
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **four** frozen resource nodes and their **fixed** yield tables before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/resource-node-def.schema.json` and `content/schemas/resource-yield-row.schema.json` (two row schemas; catalogs in `prototype_resource_nodes.json` + `prototype_resource_yields.json` — [NEO-57](NEO-57-implementation-plan.md)).
|
||||
- `content/resource-nodes/prototype_resource_nodes.json` with stable **`nodeDefId`** values aligned to interactable ids (see module doc freeze table).
|
||||
- `content/resource-nodes/prototype_resource_yields.json` — one yield row per `nodeDefId`, referencing only **`scrap_metal_bulk`** from the E3.M3 freeze.
|
||||
- `scripts/validate_content.py`: schema validation, duplicate ids, exact four-node allowlist, **`gatherLens`** coverage, cross-check that every yield **`itemId`** exists in `content/items/`.
|
||||
- Designer note in [E3_M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, depletion store, inventory grants, HTTP.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates resource-node JSON against schema.
|
||||
- [x] Exactly four prototype node ids; duplicate `nodeDefId` fails CI.
|
||||
- [x] Every yield references a valid frozen item id.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
**Landed:** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) — content + CI only; server load is E3M1-02.
|
||||
|
||||
---
|
||||
|
||||
### E3M1-02 — Server resource-node catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/resource-nodes/*.json` with CI-parity validation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Gathering/` (path TBD in plan).
|
||||
- Fail-fast on malformed files, duplicate ids, unknown yield item ids (cross-check **`IItemDefinitionRegistry`** at startup).
|
||||
- Unit tests (AAA) for loader happy path and failure modes.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-instance depletion, HTTP, interact wiring.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Server refuses boot when resource-node catalog invalid (mirror E3.M3 / E2.M1 catalog behavior).
|
||||
- [x] Catalog resolves `ResourceNodeDef` and yield row by `nodeDefId`.
|
||||
|
||||
**Landed:** [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — injectable registry + DI; server loader was [NEO-58](https://linear.app/neon-sprawl/issue/NEO-58).
|
||||
|
||||
---
|
||||
|
||||
### E3M1-03 — Resource node definition registry + DI
|
||||
|
||||
**Goal:** Injectable `IResourceNodeDefinitionRegistry` consumed by gather engine and interact integration.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `ResourceNodeDefinitionRegistry` implementation + DI registration.
|
||||
- Unit tests (AAA): lookup, unknown id, yield metadata, capacity fields.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP, persistence of depletion state.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Host resolves registry from DI; unknown `nodeDefId` distinguishable from valid ids in tests.
|
||||
|
||||
---
|
||||
|
||||
### E3M1-04 — GET world resource-node-definitions
|
||||
|
||||
**Goal:** Versioned read-only catalog for Bruno, client preview, and tooling — mirror [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) item-definitions pattern.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `GET /game/world/resource-node-definitions` + DTOs + API tests (AAA).
|
||||
- Bruno folder `bruno/neon-sprawl-server/resource-node-definitions/`.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-instance remaining capacity (instance state is E3M1-05).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] GET returns all four prototype defs with schema version field and yield summary fields needed for QA.
|
||||
- [x] Bruno happy path documented.
|
||||
|
||||
**Landed:** [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) — HTTP read model + Bruno; per-instance depletion is E3M1-05.
|
||||
|
||||
---
|
||||
|
||||
### E3M1-05 — World node instance depletion store
|
||||
|
||||
**Goal:** Server-authoritative **remaining gathers** per node instance (`interactableId`), with structured deny when depleted.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `IResourceNodeInstanceStore` (or equivalent) keyed by **`interactableId`** (matches [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) descriptor ids).
|
||||
- Initialize capacity from `ResourceNodeDef.maxGathers` (or equivalent field name frozen in E3M1-01).
|
||||
- Decrement on successful gather commit; **`node_depleted`** when zero.
|
||||
- Persistence: in-memory + Postgres + migration (mirror NEO-54 / NEO-8 policy).
|
||||
- Unit + integration tests (AAA).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Item grants, skill XP, HTTP beyond store tests.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative.
|
||||
- [x] Postgres + in-memory parity tests.
|
||||
|
||||
**Landed:** [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — instance depletion store; gather engine is E3M1-06.
|
||||
|
||||
---
|
||||
|
||||
### E3M1-06 — GatherResult engine (yields + inventory + skill XP)
|
||||
|
||||
**Goal:** Single server operation that resolves **`GatherResult`**: validate node + capacity, grant items via **`PlayerInventoryOperations`**, award **`salvage`** XP via **`SkillProgressionGrantOperations`**, commit depletion.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `GatherOperations` / `GatherResult` types in `server/NeonSprawl.Server/Game/Gathering/`.
|
||||
- Stable deny reason codes: `node_depleted`, `inventory_full`, `unknown_node`, etc.
|
||||
- Unit tests (AAA) with stub inventory + progression stores.
|
||||
- Document **`GatherResult`** shape in module doc (server-internal until promoted to wire).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- `InteractionApi` wiring (E3M1-07).
|
||||
- Client HUD.
|
||||
- Regen / respawn timers ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
|
||||
- [x] Full bag returns stable `reasonCode` without partial silent loss.
|
||||
- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP).
|
||||
- [x] Depletion store decrements atomically with successful grant.
|
||||
|
||||
**Landed:** [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — gather engine; interact wiring is E3M1-07.
|
||||
|
||||
### E3M1-07 — Interact path integration + registry expansion + Bruno
|
||||
|
||||
**Goal:** Replace NEO-41 inline XP block with gather engine; register **four** prototype resource nodes in **`PrototypeInteractableRegistry`**; extend Bruno/manual QA for gather → inventory.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `InteractionApi`: on `resource_node` kind, call gather engine; map engine deny → `InteractionResponse` with **`Allowed: false`** + `reasonCode` (including **`node_depleted`**).
|
||||
- Remove duplicate XP grant path once gather engine covers it.
|
||||
- Add three new interactable anchors (positions TBD in plan; keep **`prototype_resource_node_alpha`** anchor).
|
||||
- Bruno: move near node → interact → GET inventory shows **`scrap_metal_bulk`**; depleted node deny.
|
||||
- `server/README.md`; integration tests (AAA).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot gather HUD — **[Epic 3 Slice 5](E3S5-client-prototype-backlog.md#e3s5-02--client-gather-feedback-on-interact-e3m1)** ([NEO-73](https://linear.app/neon-sprawl/issue/NEO-73)).
|
||||
- New gather-specific HTTP route.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] In-range interact on fresh node increases inventory quantity and **`salvage`** XP.
|
||||
- [x] Depleted node returns **`node_depleted`** without inventory or XP change.
|
||||
- [x] Terminal interact unchanged (no gather side effects).
|
||||
- [x] Bruno documents happy path + depletion deny for at least one node.
|
||||
|
||||
**Landed:** [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — interact gather wiring; telemetry is E3M1-08.
|
||||
|
||||
---
|
||||
|
||||
### E3M1-08 — `resource_gathered` / `gather_node_depleted` telemetry hook sites
|
||||
|
||||
**Goal:** Comment-only hooks on gather success and depletion for future E9.M1 catalog events.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Hook placement in gather engine (and interact deny branch if applicable — **out of scope per NEO-64 kickoff:** engine-only; no **`InteractionApi`** hooks).
|
||||
- `TODO(E9.M1)` comments; no production logging.
|
||||
- README + module doc pointer.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Telemetry ingest, dashboards.
|
||||
- Interact deny-branch hook sites (**NEO-64 kickoff** — pre-gather **`node_depleted`** deny uses interact **`reasonCode`** only).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Hook sites documented in code and `server/README.md`.
|
||||
- [x] Matches Epic 3 Slice 2 telemetry vocabulary in [epic_03](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-2).
|
||||
|
||||
**Landed:** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) — comment-only **`resource_gathered`** / **`gather_node_depleted`** hooks in **`GatherOperations.TryGather`**; Epic 3 Slice 2 / E3.M1 backlog complete.
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- **[E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md)** Slice 3 decomposed — [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md) ([NEO-65](https://linear.app/neon-sprawl/issue/NEO-65)–[NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)).
|
||||
- **Epic 3 Slice 5 (client)** — [E3S5-client-prototype-backlog.md](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)); gather HUD is **E3S5-02** [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73).
|
||||
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
|
||||
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` when interact + inventory surfaces are testable (E3M1-07+).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [E3_M1_ResourceNodeAndGatherLoop.md](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)
|
||||
- [E3M3-prototype-backlog.md](E3M3-prototype-backlog.md)
|
||||
- [epic_03_crafting_economy.md](../decomposition/epics/epic_03_crafting_economy.md)
|
||||
- [gathering.md](../game-design/gathering.md)
|
||||
- [NEO-41 implementation plan](NEO-41-implementation-plan.md)
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
# E3.M2 — Prototype story backlog (RefinementAndRecipeExecution)
|
||||
|
||||
Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-3)). Decomposition and contracts: [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E3.M2`**; add **`server`** / **`Story`** as listed per issue.
|
||||
|
||||
**Precursor (do not re-scope):** [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** — **10** **`refine`** XP with **`sourceKind: activity`** on successful craft/refine completion. Slice 3 **calls** that helper from the real **`CraftResult`** success path; NEO-42 does **not** own recipe validation, inventory mutation, or craft HTTP.
|
||||
|
||||
**Upstream (must be landed):** [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) Slice 1 — frozen item catalog, **`IPlayerInventoryStore`**, **`PlayerInventoryOperations`**, inventory HTTP ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)–[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56)). [E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) Slice 2 — gather → **`scrap_metal_bulk`** ([NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)–[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). **E2.M2** grant path ([NEO-38](https://linear.app/neon-sprawl/issue/NEO-38)).
|
||||
|
||||
**Prototype craft spine (frozen in E3M2-01):** **eight** `RecipeDef` rows proving **gather → process → make** on the frozen six-item catalog — **two** process (**refine**) recipes, **six** make recipes covering every **`prototypeRole`** output except **`material`** / **`intermediate`** (those are inputs). All recipes use **`requiredSkillId: refine`** (no separate **Make** skill row in the prototype skill trio). Station tags, skill level gates, and batch-quantity POST are **out of scope** for Slice 3.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E3M2-01 | [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) |
|
||||
| E3M2-02 | [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) |
|
||||
| E3M2-03 | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) |
|
||||
| E3M2-04 | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) |
|
||||
| E3M2-05 | [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) |
|
||||
| E3M2-06 | [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) |
|
||||
| E3M2-07 | [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) |
|
||||
|
||||
**Dependency graph in Linear:** E3M2-02 (NEO-66) blocked by E3M2-01 (NEO-65). E3M2-03 (NEO-67) blocked by E3M2-02 (NEO-66). E3M2-04 (NEO-68) blocked by E3M2-03 (NEO-67). E3M2-05 (NEO-69) blocked by E3M2-03 (NEO-67) and E3.M3 inventory store (NEO-54). E3M2-06 (NEO-70) blocked by E3M2-05 (NEO-69). E3M2-07 (NEO-71) blocked by E3M2-05 (NEO-69; may parallel E3M2-06).
|
||||
|
||||
**Board order:** each issue carries **estimate 1–7** matching slug order (E3M2-01 = 1 … E3M2-07 = 7). On the Epic 3 project board, use **Display options → Ordering → Estimate** (ascending) so Todo reads top-to-bottom in delivery order.
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Depends on |
|
||||
|-------|------|------------|
|
||||
| 1 | **E3M2-01** | E3.M3 item catalog (NEO-50) |
|
||||
| 2 | **E3M2-02** | E3M2-01 |
|
||||
| 3 | **E3M2-03** | E3M2-02 |
|
||||
| 4 | **E3M2-04** | E3M2-03 |
|
||||
| 5 | **E3M2-05** | E3M2-03, E3.M3 inventory store (NEO-54+) |
|
||||
| 6 | **E3M2-06** | E3M2-05 |
|
||||
| 7 | **E3M2-07** | E3M2-05 |
|
||||
|
||||
**Downstream (separate modules):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) quest steps requiring crafted items; [E3.M4](../decomposition/modules/E3_M4_SinkAndDurabilityLifecycle.md) durability sinks; [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) bench/station placement later.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Recipe count | **8** frozen defs (epic minimum 8–12) | Two refine paths + six make outputs without expanding the item catalog |
|
||||
| Skill id | **`refine`** on every recipe | Prototype skill trio has no separate **Make** skill; matches [NEO-42](NEO-42-implementation-plan.md) |
|
||||
| Skill level gates | **None** in prototype | `requiredSkillLevel` omitted or **0**; gates are a content follow-up |
|
||||
| Station / bench tags | **None** in prototype | Craft always allowed via HTTP; zone/bench gating defers to [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) |
|
||||
| Craft wire surface | **`POST /game/players/{id}/craft`** | Mirror [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) inventory POST pattern; no station interact in Slice 3 |
|
||||
| Request shape | **`recipeId`** + optional **`quantity`** default **1** | One craft execution per POST; multi-output comes from recipe **`outputs`** |
|
||||
| Atomic commit | Consume **all** inputs, then grant **all** outputs, then XP | Roll back input removal if output grant fails (mirror gather engine rollback) |
|
||||
| XP | **`RefineActivitySkillXpGrant`** once per successful craft | NEO-42 already landed; E3M2-05 owns the call site |
|
||||
| RNG / quality / failure | **None** — deterministic success when inputs + bag space allow | Soft craft failure and quality tiers are pre-production ([crafting.md](../game-design/crafting.md)) |
|
||||
| Primary demo loop | Gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** → **`make_field_stim_mk0`** | Proves combat-relevant consumable from world nodes |
|
||||
|
||||
---
|
||||
|
||||
### E3M2-01 — Prototype RecipeDef catalog + schemas + CI
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **eight** frozen recipes before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/recipe-def.schema.json` and `content/schemas/recipe-io-row.schema.json` (input/output row shape).
|
||||
- `content/recipes/prototype_recipes.json` with stable **`id`** values (see module doc freeze table).
|
||||
- `scripts/validate_content.py`: schema validation, duplicate ids, exact eight-recipe allowlist, **`recipeKind`** coverage (**`process`** + **`make`**), cross-check that every **`itemId`** in inputs/outputs exists in `content/items/`, every **`requiredSkillId`** exists in `content/skills/`.
|
||||
- Designer note in [E3_M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, craft engine, HTTP.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates recipe JSON against schema.
|
||||
- [x] Exactly eight prototype recipe ids; duplicate `id` fails CI.
|
||||
- [x] Every input/output references a valid frozen item id; every recipe references **`refine`**.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
**Landed ([NEO-65](https://linear.app/neon-sprawl/issue/NEO-65)):** [`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), CI gates in [`validate_content.py`](../../scripts/validate_content.py); plan [NEO-65-implementation-plan.md](NEO-65-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E3M2-02 — Server recipe catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/recipes/*.json` with CI-parity validation.
|
||||
|
||||
**Kickoff note (NEO-66):** Duplicate **`PROTOTYPE_SLICE3_RECIPE_IDS`** (and Slice 3 gate rules) in C# with an explicit **“keep in sync with `scripts/validate_content.py`”** comment — mirror [NEO-51](../../plans/NEO-51-implementation-plan.md) **`PrototypeSlice1ItemCatalogRules`** / **`PROTOTYPE_SLICE1_ITEM_IDS`**.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Crafting/` (path TBD in plan).
|
||||
- Fail-fast on malformed files, duplicate ids, unknown item ids (cross-check **`IItemDefinitionRegistry`**), unknown skill ids (cross-check **`ISkillDefinitionRegistry`**).
|
||||
- Unit tests (AAA) for loader happy path and failure modes.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-player craft state, HTTP, inventory mutation.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Server refuses boot when recipe catalog invalid (mirror E3.M1 / E3.M3 catalog behavior).
|
||||
- [x] Catalog resolves `RecipeDef` and I/O rows by recipe `id`.
|
||||
|
||||
**Landed ([NEO-66](https://linear.app/neon-sprawl/issue/NEO-66)):** `server/NeonSprawl.Server/Game/Crafting/` — `RecipeDefinitionCatalogLoader`, `RecipeDefinitionCatalog`, `PrototypeSlice3RecipeCatalogRules`; plan [NEO-66-implementation-plan.md](NEO-66-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E3M2-03 — Recipe definition registry + DI
|
||||
|
||||
**Goal:** Injectable `IRecipeDefinitionRegistry` consumed by craft engine and HTTP.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `RecipeDefinitionRegistry` implementation + DI registration.
|
||||
- Unit tests (AAA): lookup, unknown id, input/output metadata, **`recipeKind`**.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP, persistence.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Host resolves registry from DI; unknown `recipeId` distinguishable from valid ids in tests.
|
||||
|
||||
**Landed ([NEO-67](https://linear.app/neon-sprawl/issue/NEO-67)):** `IRecipeDefinitionRegistry` + `RecipeDefinitionRegistry` in `server/NeonSprawl.Server/Game/Crafting/`; plan [NEO-67-implementation-plan.md](NEO-67-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E3M2-04 — GET world recipe-definitions
|
||||
|
||||
**Goal:** Versioned read-only catalog for Bruno, client preview, and tooling — mirror [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) item-definitions pattern.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `GET /game/world/recipe-definitions` + DTOs + API tests (AAA).
|
||||
- Bruno folder `bruno/neon-sprawl-server/recipe-definitions/`.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-player craft history or cooldown state.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] GET returns all eight prototype recipes with schema version field and input/output summary fields needed for QA.
|
||||
- [x] Bruno happy path documented.
|
||||
|
||||
**Landed ([NEO-68](https://linear.app/neon-sprawl/issue/NEO-68)):** `GET /game/world/recipe-definitions` — `RecipeDefinitionsWorldApi` + DTOs in `server/NeonSprawl.Server/Game/Crafting/`; plan [NEO-68-implementation-plan.md](NEO-68-implementation-plan.md); manual QA [`NEO-68`](../manual-qa/NEO-68.md); Bruno `bruno/neon-sprawl-server/recipe-definitions/`.
|
||||
|
||||
---
|
||||
|
||||
### E3M2-05 — CraftResult engine (consume inputs, grant outputs, refine XP)
|
||||
|
||||
**Goal:** Single server operation that resolves **`CraftResult`**: validate recipe + inventory, atomically remove inputs and add outputs via **`PlayerInventoryOperations`**, award **`refine`** XP via **`RefineActivitySkillXpGrant`** on success.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `CraftOperations` / `CraftResult` types in `server/NeonSprawl.Server/Game/Crafting/`.
|
||||
- Stable deny reason codes: `unknown_recipe`, `insufficient_materials`, `inventory_full`, `invalid_quantity` (if quantity validated).
|
||||
- Rollback input removal when output grant fails.
|
||||
- Unit tests (AAA) with stub inventory + progression stores; NEO-42 grant invoked on success path.
|
||||
- Document **`CraftResult`** shape in module doc (server-internal until promoted to wire).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Craft HTTP (E3M2-06).
|
||||
- Client HUD.
|
||||
- Skill level gates, station checks, craft failure RNG.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Successful craft removes configured inputs and adds outputs deterministically.
|
||||
- [x] Insufficient materials returns stable `reasonCode` with **no** inventory change.
|
||||
- [x] Full bag on output returns stable `reasonCode`; inputs are **not** consumed (or are rolled back).
|
||||
- [x] **`refine`** skill XP increases by **10** on success (NEO-42).
|
||||
- [x] **`make_prototype_armor`** respects equipment slot rules from E3.M3 when bag is full but equip slot empty.
|
||||
|
||||
**Landed ([NEO-69](https://linear.app/neon-sprawl/issue/NEO-69)):** `CraftOperations.TryCraft` + `CraftResult` in `server/NeonSprawl.Server/Game/Crafting/`; plan [NEO-69-implementation-plan.md](NEO-69-implementation-plan.md); [server README — Craft engine (NEO-69)](../../server/README.md#craft-engine-neo-69).
|
||||
|
||||
---
|
||||
|
||||
### E3M2-06 — Craft HTTP + Bruno gather→refine→make spine
|
||||
|
||||
**Goal:** Versioned **`POST /game/players/{id}/craft`** wired to craft engine; Bruno documents full prototype loop from gather through combat-relevant output.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `POST /game/players/{id}/craft` + DTOs + API tests (AAA).
|
||||
- Known-player gate via `IPositionStateStore` (NEO-37 / NEO-55 pattern).
|
||||
- Bruno `bruno/neon-sprawl-server/craft/` with spine: seed or gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** → **`make_field_stim_mk0`** → GET inventory + GET skill-progression shows **`refine`** XP delta.
|
||||
- Bruno deny paths: unknown recipe, insufficient materials, inventory full on output.
|
||||
- `server/README.md`; integration tests (AAA) where practical.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot craft UI — **[Epic 3 Slice 5](E3S5-client-prototype-backlog.md#e3s5-03--client-craft-ui--recipe-list-e3m2)** ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)).
|
||||
- Station interact / world bench placement.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] POST success returns structured **`CraftResult`** (or HTTP envelope) matching engine outcome.
|
||||
- [x] Bruno spine completes without manual inventory POST shortcuts (gather interact allowed for scrap).
|
||||
- [x] At least one deny path documented per major `reasonCode`.
|
||||
- [x] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
|
||||
|
||||
**Landed ([NEO-70](https://linear.app/neon-sprawl/issue/NEO-70)):** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + `CraftWireDtos`; Bruno `bruno/neon-sprawl-server/craft/`; plan [NEO-70-implementation-plan.md](NEO-70-implementation-plan.md); [server README — Craft HTTP (NEO-70)](../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70).
|
||||
|
||||
---
|
||||
|
||||
### E3M2-07 — `item_crafted` / `craft_failed` telemetry hook sites
|
||||
|
||||
**Goal:** Comment-only hooks on craft success and structured deny for future E9.M1 catalog events.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Hook placement in **`CraftOperations`** (success + deny before commit — engine-only, mirror NEO-64 gather pattern).
|
||||
- `TODO(E9.M1)` comments; no production logging.
|
||||
- README + module doc pointer; note **`time-to-first-craft`** is a future metric derived from these events + session context (no separate hook in prototype).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Telemetry ingest, dashboards.
|
||||
- HTTP-layer duplicate hooks if engine owns the contract.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Hook sites documented in code and `server/README.md`.
|
||||
- [x] Matches Epic 3 Slice 3 telemetry vocabulary in [epic_03](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-3).
|
||||
|
||||
**Landed ([NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)):** comment-only **`item_crafted`** / **`craft_failed`** hook sites in **`CraftOperations.TryCraft`**; plan [NEO-71-implementation-plan.md](NEO-71-implementation-plan.md); [server README — Craft telemetry hooks (NEO-71)](../../server/README.md#craft-telemetry-hooks-neo-71).
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- Epic 3 **prototype server** gather/craft spine complete when **E3M2-07** lands; **playable client loop** completes with **[Epic 3 Slice 5](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)).
|
||||
- Slice 4 (**E3.M4** + **E3.M5**) sinks/policy remains pre-production.
|
||||
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
|
||||
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` when craft HTTP lands (E3M2-06+) — **[NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) waived** manual QA at kickoff (Bruno + README only).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [E3_M2_RefinementAndRecipeExecution.md](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md)
|
||||
- [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md)
|
||||
- [E3M3-prototype-backlog.md](E3M3-prototype-backlog.md)
|
||||
- [epic_03_crafting_economy.md](../decomposition/epics/epic_03_crafting_economy.md)
|
||||
- [crafting.md](../game-design/crafting.md)
|
||||
- [NEO-42 implementation plan](NEO-42-implementation-plan.md)
|
||||
|
|
@ -162,7 +162,7 @@ Working backlog for **Epic 3 — Slice 1** ([items and inventory MVP](../decompo
|
|||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (optional follow-up).
|
||||
- Godot HUD — **[Epic 3 Slice 5](E3S5-client-prototype-backlog.md#e3s5-01--client-inventory-snapshot-hud-e3m3)** ([NEO-72](https://linear.app/neon-sprawl/issue/NEO-72)).
|
||||
- Gather node depletion ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
|
@ -196,7 +196,9 @@ Working backlog for **Epic 3 — Slice 1** ([items and inventory MVP](../decompo
|
|||
|
||||
## After this backlog
|
||||
|
||||
- Decompose **[E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)** Slice 2 (gather yields → inventory) once E3M3-05+ land.
|
||||
- **[E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)** Slice 2 decomposed — [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md) ([NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)–[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)).
|
||||
- **[E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md)** Slice 3 decomposed — [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md) ([NEO-65](https://linear.app/neon-sprawl/issue/NEO-65)–[NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)).
|
||||
- **Epic 3 Slice 5 (client)** — [E3S5-client-prototype-backlog.md](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)); inventory HUD is **E3S5-01** [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72).
|
||||
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
|
||||
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` when HTTP surfaces land (E3M3-06+).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
# Epic 3 — Slice 5: Client economy integration (prototype)
|
||||
|
||||
Working backlog for **Epic 3 — Slice 5** ([client economy integration](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-5)). Completes the **full stack** for gather → inventory → craft in the **Godot client** — not Bruno-only proof.
|
||||
|
||||
**Policy (from decomposition):** Epics deliver **playable game features**, not server-only vertical slices. Slices 1–3 landed server + content + Bruno; **Slice 5** is the required client half. Slice 4 (E3.M4/M5 sinks) remains pre-production.
|
||||
|
||||
**Labels (Linear):** every issue carries module label(s) **`E3.M1`** / **`E3.M2`** / **`E3.M3`** as listed; add **`client`** + **`Story`**.
|
||||
|
||||
**Pattern:** Mirror [E1.M3 client pairing](../plans/E1M3-prototype-backlog.md#e1m3-02--client-tab-target--lock-ui-synced-to-server) — thin HTTP client nodes, `main.gd` wiring, HUD labels, GdUnit doubles, `docs/manual-qa/{KEY}.md`.
|
||||
|
||||
**Upstream (server must be landed):**
|
||||
|
||||
| Module | Minimum server stories |
|
||||
|--------|-------------------------|
|
||||
| **E3.M3** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) inventory GET/POST; [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) item defs (display names) |
|
||||
| **E3.M1** | [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) interact → gather; [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) resource-node defs (optional preview) |
|
||||
| **E3.M2** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) recipe defs GET; [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) craft POST |
|
||||
| **E2.M2** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) skill-progression GET (XP visibility) |
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E3S5-01 | [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) |
|
||||
| E3S5-02 | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) |
|
||||
| E3S5-03 | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) |
|
||||
| E3S5-04 | [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) |
|
||||
|
||||
**Dependency graph in Linear:** E3S5-02 (NEO-73) blocked by E3S5-01 (NEO-72) and [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63). E3S5-03 (NEO-74) blocked by E3S5-01 (NEO-72), [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68), and [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70). E3S5-04 (NEO-75) blocked by E3S5-02 (NEO-73) and E3S5-03 (NEO-74).
|
||||
|
||||
**Board order:** estimates **1–4** matching slug order. On the Epic 3 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Depends on |
|
||||
|-------|------|------------|
|
||||
| 1 | **E3S5-01** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55), [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) |
|
||||
| 2 | **E3S5-02** | E3S5-01, [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) |
|
||||
| 3 | **E3S5-03** | E3S5-01, [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68), [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) |
|
||||
| 4 | **E3S5-04** | E3S5-02, E3S5-03 |
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| HUD fidelity | **Prototype debug panels** (Labels / simple lists) — not final art | Matches NEO-24/NEO-28 cast/target HUD pattern; polish is pre-production |
|
||||
| Inventory refresh | **GET after mutating actions** (interact gather, craft POST) | Server is source of truth; no client-side stack math |
|
||||
| Craft UI scope | **List all prototype recipes** + single **Craft** button per row | No station placement, batch quantity slider, or drag-drop in Slice 5 |
|
||||
| Skill XP display | **Salvage + refine levels/XP** on HUD; refresh after gather/craft | Epic 2 Slice 3 AC + vision **progression gate** |
|
||||
| Gather wire | **Reuse `POST …/interact`** — no new gather HTTP on client | E3.M1 prototype contract; extend `interaction_request_client.gd` |
|
||||
| Authority | Client **displays only**; all mutations via existing server routes | [client_server_authority.md](../decomposition/modules/client_server_authority.md) |
|
||||
|
||||
---
|
||||
|
||||
### E3S5-01 — Client inventory snapshot HUD (E3.M3)
|
||||
|
||||
**Goal:** Godot **`GET /game/players/{id}/inventory`** client + readable bag/equipment panel; boot hydrate + manual refresh entrypoint for later stories.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `inventory_client.gd` (or equivalent): GET snapshot, parse v1 envelope, emit signal with slots/stacks.
|
||||
- HUD: `InventoryLabel` or scroll-friendly list under `UICanvas` showing **bag** + **equipment** rows (item id, quantity, slot kind).
|
||||
- Optional: resolve **`displayName`** via cached **`GET /game/world/item-definitions`** (NEO-53) for readable labels.
|
||||
- GdUnit: HTTP double, parse happy path + error path.
|
||||
- `client/README.md` section; input binding for **refresh** (e.g. **I**) if useful for QA.
|
||||
- `docs/manual-qa/NEO-72.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Drag-drop, split stacks, equip UX polish.
|
||||
- POST inventory mutations from UI (craft/gather stories own refresh triggers).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Boot (or first open) shows current server inventory for `dev_player_id`.
|
||||
- [x] HUD lists all non-empty bag slots and equipment stub slot from GET body.
|
||||
- [x] Failed GET surfaces visible error/warning (match interaction client pattern).
|
||||
|
||||
---
|
||||
|
||||
### E3S5-02 — Client gather feedback on interact (E3.M1)
|
||||
|
||||
**Goal:** After successful **resource_node** interact, player **sees** inventory change and **salvage** XP movement — not Output-panel-only proof.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend gather interact path: on `allowed=true` for resource nodes, call **inventory refresh** + **skill-progression GET** (`salvage` row).
|
||||
- Feedback line: e.g. `GatherFeedbackLabel` — “+N scrap_metal_bulk” (derive delta from before/after inventory or document server-truth refresh-only copy).
|
||||
- Map **`node_depleted`** deny to readable HUD message (not silent).
|
||||
- Wire all four prototype resource node ids (not only `prototype_resource_node_alpha`) if interact keys or targeting supports them; document bindings.
|
||||
- GdUnit: mock interact 200 → refresh invoked.
|
||||
- `docs/manual-qa/NEO-73.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- New gather HTTP route; node respawn visuals; gather VFX.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] In-range interact on fresh node updates inventory HUD without manual refresh.
|
||||
- [x] Depleted node shows **`node_depleted`** (or agreed copy) on HUD.
|
||||
- [x] **Salvage** skill level or XP visible and changes after successful gather (Epic 2 Slice 3 visibility).
|
||||
|
||||
---
|
||||
|
||||
### E3S5-03 — Client craft UI + recipe list (E3.M2)
|
||||
|
||||
**Goal:** Player selects a prototype recipe and **`POST /game/players/{id}/craft`** from Godot; sees success/deny and updated inventory + **refine** XP.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `recipe_definitions_client.gd`: GET world recipe defs (NEO-68).
|
||||
- `craft_client.gd`: POST `{ recipeId, quantity? }`; parse success/deny + `reasonCode`.
|
||||
- HUD: simple recipe list (id / displayName, inputs summary); **Craft** per row for qty **1**.
|
||||
- On success: refresh inventory + skill-progression (**refine**); show feedback line.
|
||||
- Deny paths documented in manual QA: insufficient materials, bag full, unknown recipe.
|
||||
- GdUnit: POST payload shape, deny parsing.
|
||||
- `docs/manual-qa/NEO-74.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Station/bench world objects; craft quantity spinner; recipe filtering by skill level.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
### E3S5-04 — Playable gather→refine→make loop in client (capstone)
|
||||
|
||||
**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**
|
||||
|
||||
- `main.gd` integration checklist: interact → inventory → craft panel flow documented in **`client/README.md`**.
|
||||
- **`docs/manual-qa/NEO-75.md`**: numbered session script (move to node, gather ×N, refine, make stim, verify consumable in bag).
|
||||
- Optional: toggle/collapse HUD sections so prototype scene stays usable (document layout).
|
||||
- Cross-link Epic 3 Slice 5 complete in module docs + alignment register when done.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Quest UI (E7); combat consumable use (E5 follow-up); art pass.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- Epic 3 **prototype** is **full-stack complete** for gather/craft/inventory (Slices 1–3 server + Slice 5 client).
|
||||
- Slice 4 (**E3.M4** + **E3.M5**) sinks/policy remains **pre-production**.
|
||||
- Apply the same **server + client pairing** pattern when decomposing other epics (see [decomposition README](../decomposition/README.md#full-stack-epics)).
|
||||
- Track delivery in Linear; keep `blockedBy` synchronized if scope changes.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [epic_03_crafting_economy.md](../decomposition/epics/epic_03_crafting_economy.md)
|
||||
- [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md), [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md), [E3M3-prototype-backlog.md](E3M3-prototype-backlog.md)
|
||||
- [client_server_authority.md](../decomposition/modules/client_server_authority.md)
|
||||
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# NEO-56 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-56 |
|
||||
| **Title** | E3.M3: item_created / transfer-failure telemetry hook sites |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-56/e3m3-item-created-transfer-failure-telemetry-hook-sites |
|
||||
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-07) |
|
||||
| **Branch** | `NEO-56-item-created-transfer-failure-telemetry-hooks` |
|
||||
| **Precursors** | [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) — inventory GET/POST HTTP (**Done**) |
|
||||
| **Pattern** | [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) / [NEO-40](https://linear.app/neon-sprawl/issue/NEO-40) — comment-only engine anchors; [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30) — deny event + `reasonCode` |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Hook anchor** | Engine only vs engine + HTTP? | **Engine only** — `PlayerInventoryOperations` (NEO-49 single-anchor precedent; NEO-55 deferred hooks here). | **User:** engine only. |
|
||||
| **Deny event shape** | Per-reason events vs single deny + `reasonCode`? | **`inventory_transfer_denied` + `reasonCode`** (NEO-30 `ability_cast_denied`; epic `inventory_full` maps to a reason code). | **User:** single deny + `reasonCode`. |
|
||||
| **`item_created` scope** | Add success only vs add + remove? | **Successful add only** (`TryAddStack` → `Applied`). | **User:** add success only. |
|
||||
| **Runtime behavior** | Comments-only vs dev `ILogger`? | **Comments-only** — `TODO(E9.M1)`; no production logging. | **User:** comments-only. |
|
||||
| **Manual QA doc** | `docs/manual-qa/NEO-56.md`? | **Yes** — NEO-49 verification pattern. | **User:** yes. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Document and place **comment-only hook sites** on the server-authoritative inventory mutation path for future E9.M1 catalog events **`item_created`** (successful add) and **`inventory_transfer_denied`** (transfer failures with stable `reasonCode`). Align with Epic 3 Slice 1 telemetry vocabulary ([epic_03 — Slice 1](../decomposition/epics/epic_03_crafting_economy.md)).
|
||||
|
||||
**In scope (from Linear + [E3M3-07](E3M3-prototype-backlog.md#e3m3-07--item_created--transfer-failure-telemetry-hook-sites)):**
|
||||
|
||||
- Hook placement in **`PlayerInventoryOperations`** (`TryAddStack` / `TryRemoveStack`) with **`TODO(E9.M1)`** comments.
|
||||
- **`server/README.md`** inventory subsection + [E3_M3 module](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) hook note.
|
||||
- **`docs/manual-qa/NEO-56.md`** — verify comments + no behavior/API change.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Telemetry ingest, dashboards (**E9.M1**).
|
||||
- `ILogger` / metrics / dev-only log lines.
|
||||
- Duplicate hook comments in **`PlayerInventoryApi`** (HTTP calls engine; NEO-55 plan deferred hooks to NEO-56).
|
||||
- **`item_created`** on successful **remove** (kickoff decision).
|
||||
- **`StoreMissing`** hooks (not a transfer failure — HTTP maps to **404**).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Hook sites documented in code (`PlayerInventoryOperations`) with **`TODO(E9.M1)`**.
|
||||
- [x] **`item_created`** on successful add; **`inventory_transfer_denied`** + `reasonCode` on rule denies (including early validation denies).
|
||||
- [x] `server/README.md` documents hook placement.
|
||||
- [x] Matches Epic 3 Slice 1 vocabulary (`item_created`, `inventory_full` / transfer failures via `reasonCode`).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Authoritative surface:** `PlayerInventoryOperations` — all inventory add/remove rules and outcomes. Callers (`PlayerInventoryApi` POST, future gather/craft) funnel here; **no** duplicate markers at HTTP.
|
||||
|
||||
2. **Hook site A — `item_created`:** In **`TryAddStack`**, immediately before returning **`PlayerInventoryMutationKind.Applied`** (after successful `TryMutateSnapshot` with no `denyReason`):
|
||||
- Comment block names future E9.M1 event **`item_created`**.
|
||||
- **`TODO(E9.M1): catalog emit`** (NEO-49 / NEO-30 style).
|
||||
- Planned payload fields: `playerId`, `itemId`, `quantity`, optional `mutationKind`/`source` when Slice 2+ callers exist.
|
||||
- **Not** on **`TryRemoveStack`** success (kickoff).
|
||||
|
||||
3. **Hook site B — `inventory_transfer_denied`:** On every **`Denied`** return path in **`TryAddStack`** and **`TryRemoveStack`**:
|
||||
- Early **`Deny(...)`** (e.g. `invalid_quantity`, `invalid_item` before store write).
|
||||
- Post-mutation deny when `denyReason` is set (`inventory_full`, `insufficient_quantity`).
|
||||
- Single comment pattern: catalog event **`inventory_transfer_denied`** + **`reasonCode`** from [`PlayerInventoryReasonCodes`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryReasonCodes.cs) (`inventory_full`, `invalid_item`, `insufficient_quantity`, `invalid_quantity`).
|
||||
- Epic Slice 1 **`inventory_full`** is the flagship transfer-failure code; others share the same deny event shape (NEO-30 precedent).
|
||||
|
||||
4. **No hook on `StoreMissing`:** Outcome is missing player/store — not an inventory transfer rule deny.
|
||||
|
||||
5. **Cross-references (minimal):**
|
||||
- One-line pointer in `PlayerInventoryMutationOutcome.cs` or `PlayerInventoryReasonCodes.cs` xml summary → hook sites in `PlayerInventoryOperations`.
|
||||
- **`server/README.md`:** extend **Player inventory (NEO-54 store, NEO-55 HTTP)** with **NEO-56 telemetry hooks** subsection (anchor file + event names).
|
||||
- **`E3_M3_ItemizationAndInventorySchema.md`:** **NEO-56 landed** line under Related implementation slices.
|
||||
- **`documentation_and_implementation_alignment.md`:** E3.M3 row cites NEO-56 hook sites (still no E9.M1 ingest).
|
||||
|
||||
6. **Regression:** Run `dotnet test` on server solution; existing `PlayerInventoryOperationsTests` / `PlayerInventoryApiTests` unchanged (comments-only).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/manual-qa/NEO-56.md` | Manual QA: verify hook comment blocks + no API/behavior change. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs` | **NEO-56:** `item_created` on add `Applied`; `inventory_transfer_denied` on all `Denied` paths in add/remove. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryReasonCodes.cs` | Cross-reference deny `reasonCode` values → transfer-denied hook anchor (no behavior change). |
|
||||
| `server/README.md` | Document inventory telemetry hook placement (NEO-56). |
|
||||
| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | Implementation snapshot: NEO-56 telemetry hooks. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row: NEO-56 hook sites. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| **None required** | Comments-only; existing `PlayerInventoryOperationsTests` and `PlayerInventoryApiTests` cover add/remove/deny paths. |
|
||||
|
||||
**Manual verification:** `docs/manual-qa/NEO-56.md` — confirm **`item_created`** and **`inventory_transfer_denied`** comments, **`TODO(E9.M1)`**, payload field notes, no new log statements; `dotnet test` regression.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Future gather/craft callers** | Hooks stay in engine; callers pass `source`/`mutationKind` in E9.M1 payload when catalog exists — document in hook comments as optional fields. | `adopted` |
|
||||
| **HTTP request correlation** | Defer `requestId` to E9.M1 / future sink; do not add API-layer hooks (kickoff: engine only). | `adopted` |
|
||||
| **Partial stack placement on add** | NEO-54 all-or-nothing add — single `item_created` emit for full requested quantity on `Applied`. | `adopted` |
|
||||
|
||||
None blocking.
|
||||
|
||||
## Decisions (post-review)
|
||||
|
||||
| Topic | Decision |
|
||||
|--------|----------|
|
||||
| **PR scope** | Branch includes chore `3d43b00` (kickoff `AskQuestion` + recommendation rule hardening). Intentional tooling hygiene on the story branch; call out in PR description if reviewers want story-only diff vs `main`. |
|
||||
| **Post-mutation deny hooks** | Expanded to multi-line NEO-49-style blocks with payload fields (`mutationKind=add` / `remove`) per code review. |
|
||||
| **Dependency register** | E3.M3 footnote extended with NEO-56 to match alignment table. |
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# NEO-57 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-57 |
|
||||
| **Title** | E3.M1: Prototype ResourceNodeDef + yield tables + schemas + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-57/e3m1-prototype-resourcenodedef-yield-tables-schemas-ci |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-01** |
|
||||
| **Branch** | `NEO-57-e3m1-prototype-resourcenodedef-yield-schemas-ci` |
|
||||
| **Precursor** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — frozen six-item catalog (`scrap_metal_bulk`); [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — prototype `resource_node` interact XP anchor (unchanged this story) |
|
||||
| **Pattern** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — row JSON Schema + catalog envelope + `scripts/validate_content.py` Slice gate |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Catalog layout** | One file vs two files / schemas? | **Two files + two row schemas** — `prototype_resource_nodes.json` + `prototype_resource_yields.json`; mirrors backlog paths and E3M1-02 loader split. | **User:** two files + two row schemas. |
|
||||
| **`maxGathers` in content** | Include on `ResourceNodeDef` now? | **Yes** — depletion contract is documented in module freeze; avoids schema churn before E3M1-04. | **User:** yes now. |
|
||||
| **Yield quantities** | Same vs per-node quantities? | **Differentiated** (e.g. 1 / 2 / 3 / 5) — proves per-node yield tables without new item ids. | **User:** differentiated. |
|
||||
| **Decomposition docs** | How to land E3M1 backlog + freeze table? | **Merge `chore/e3m1-slice2-decomposition`** into story branch first. | **User:** merge chore branch. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **four** frozen `ResourceNodeDef` rows and **fixed** per-node yield rows before any server catalog load (E3M1-02).
|
||||
|
||||
**In scope (from Linear + [E3M1-01](E3M1-prototype-backlog.md#e3m1-01--prototype-resourcenodedef--yield-tables--schemas--ci)):**
|
||||
|
||||
- JSON Schemas: `resource-node-def.schema.json`, `resource-yield-row.schema.json` (single-row contracts).
|
||||
- `content/resource-nodes/prototype_resource_nodes.json` — four frozen `nodeDefId` values aligned to interactable ids ([freeze table](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md#prototype-slice-2-freeze-e3m1-01)).
|
||||
- `content/resource-nodes/prototype_resource_yields.json` — one yield row per node; **`itemId`** only **`scrap_metal_bulk`**; differentiated **`quantity`** per node.
|
||||
- `scripts/validate_content.py` — schema validation, duplicate `nodeDefId`, exact four-node allowlist, **`gatherLens`** coverage (one per lens), cross-check yield **`itemId`** against loaded item catalog, exactly one yield row per known `nodeDefId`.
|
||||
- Designer note already in E3.M1 module doc + `content/README.md` Slice 2 paragraph (from merged decomposition).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server loader, depletion store, inventory grants, HTTP, `PrototypeInteractableRegistry` new anchors (E3M1-05+), Bruno.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates resource-node JSON against schema.
|
||||
- [x] Exactly four prototype node ids; duplicate `nodeDefId` fails CI.
|
||||
- [x] Every yield references a valid frozen item id (`scrap_metal_bulk`).
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Row schema — `ResourceNodeDef` (`resource-node-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `nodeDefId`, `displayName`, `gatherLens`, `maxGathers`. `nodeDefId` pattern `^[a-z][a-z0-9_]*$`. `gatherLens` enum: `consumer_salvage` \| `subsurface` \| `bio` \| `urban_bulk` (matches [gathering.md](../game-design/gathering.md#node-lenses-design-facing)). `maxGathers` integer ≥ 1.
|
||||
|
||||
2. **Row schema — yield row (`resource-yield-row.schema.json`):** Required: `nodeDefId`, `itemId`, `quantity`. `quantity` integer ≥ 1. `itemId` uses same id pattern as items.
|
||||
|
||||
3. **Catalog envelopes (mirror items):**
|
||||
- `prototype_resource_nodes.json`: `{ "schemaVersion": 1, "nodes": [ … ] }` — four rows from freeze table; placeholder `displayName` strings OK.
|
||||
- `prototype_resource_yields.json`: `{ "schemaVersion": 1, "yields": [ … ] }` — four rows, one per `nodeDefId`.
|
||||
|
||||
4. **Frozen content values (prototype):**
|
||||
|
||||
| `nodeDefId` | `gatherLens` | `maxGathers` | yield `quantity` |
|
||||
|-------------|--------------|--------------|------------------|
|
||||
| `prototype_resource_node_alpha` | `consumer_salvage` | `10` | `1` |
|
||||
| `prototype_subsurface_vein_beta` | `subsurface` | `10` | `2` |
|
||||
| `prototype_bio_mat_gamma` | `bio` | `10` | `3` |
|
||||
| `prototype_urban_bulk_delta` | `urban_bulk` | `10` | `5` |
|
||||
|
||||
All yields: `itemId` **`scrap_metal_bulk`**. `maxGathers` is uniform **10** for prototype (depletion behavior lands in E3M1-04; value is content-only until then).
|
||||
|
||||
5. **`validate_content.py`:**
|
||||
- Discover `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` (or fixed filenames above).
|
||||
- Validate each node row / yield row against respective schemas.
|
||||
- Track `seen_node_ids`; reject duplicates across node files.
|
||||
- **`_prototype_slice2_resource_node_gate`:** ids must equal `PROTOTYPE_SLICE2_NODE_IDS`; `gatherLens` set must equal four lens enums exactly once each.
|
||||
- Yield pass: reject duplicate `nodeDefId` in yields; every yield `nodeDefId` must exist in loaded nodes; every yield `itemId` must exist in item catalog (`PROTOTYPE_SLICE1_ITEM_IDS` / loaded item ids); prototype gate requires **only** `scrap_metal_bulk` as yield item id in Slice 2.
|
||||
- Require **exactly one** yield row per frozen `nodeDefId`.
|
||||
- Run resource-node validation **after** item catalogs succeed (reuse item id set from `_validate_item_catalogs`).
|
||||
- Extend success summary line with resource-node file counts.
|
||||
|
||||
6. **Docs (minimal delta beyond merged decomposition):**
|
||||
- `content/README.md` — add `resource-nodes/` row to table + Slice 2 freeze pointer (if not already complete post-merge).
|
||||
- `CT_M1_ContentValidationPipeline.md` — PR gate paragraph for resource-node catalogs.
|
||||
- `documentation_and_implementation_alignment.md` — E3.M1 row note NEO-57 content landed when implementation completes.
|
||||
|
||||
7. **No server/C#/client/Bruno changes** in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/resource-node-def.schema.json` | JSON Schema for a single `ResourceNodeDef` row. |
|
||||
| `content/schemas/resource-yield-row.schema.json` | JSON Schema for a single fixed-yield row. |
|
||||
| `content/resource-nodes/prototype_resource_nodes.json` | Four-node prototype catalog (`schemaVersion` + `nodes`). |
|
||||
| `content/resource-nodes/prototype_resource_yields.json` | Four yield rows (`schemaVersion` + `yields`). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Load/validate resource-node catalogs; duplicate `nodeDefId`; Slice 2 four-id + four-lens gates; yield↔item and yield↔node cross-checks; summary line. |
|
||||
| `content/README.md` | Document `content/resource-nodes/`, schema paths, Slice 2 freeze (if merge left gaps). |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for resource-node + yield validation. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 / NEO-57 status after catalog lands. |
|
||||
|
||||
**Already on branch (merge `chore/e3m1-slice2-decomposition`):** `docs/plans/E3M1-prototype-backlog.md`, `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` (freeze box), `docs/decomposition/modules/module_dependency_register.md` (E3.M1 note), `content/README.md` Slice 2 pointer.
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| **CI / PR gate** | `.github/workflows/pr-gate.yml` runs `python scripts/validate_content.py` — extended script is the regression signal. |
|
||||
| **Manual negative cases** | From repo root: (1) duplicate `nodeDefId` in nodes file → non-zero exit; (2) remove one frozen id → gate error; (3) invalid `gatherLens` → schema error; (4) yield references `not-a-prototype-item` → cross-check error; (5) second yield row for same `nodeDefId` → duplicate error; (6) wrong yield item id (not `scrap_metal_bulk`) → Slice 2 item gate error. |
|
||||
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports resource-node catalogs + existing catalogs OK. |
|
||||
|
||||
No dedicated pytest module (same as NEO-50 / NEO-33). No C# tests until E3M1-02 server loader.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Interactable registry anchors** for beta/gamma/delta | **Defer to E3M1-05** — content ids frozen now; world X/Z added when interactables list grows. | **deferred** |
|
||||
| **C# vs Python drift** | None until E3M1-02; duplicate `PROTOTYPE_SLICE2_*` constants in loader + script with “keep in sync” comment (NEO-50 pattern). | **adopted** |
|
||||
| **Catalog growth beyond four nodes** | New Linear issue to relax `PROTOTYPE_SLICE2_NODE_IDS` and lens gate. | **adopted** |
|
||||
| **Per-lens skill XP** | Stay **`salvage`** + **`activity`** until content pass; not in JSON this story. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Two-file layout** with separate row schemas for nodes and yields.
|
||||
- **`maxGathers`** on node defs in content now (`10` for all prototype rows).
|
||||
- **Differentiated yield quantities** 1 / 2 / 3 / 5 by lens order in freeze table.
|
||||
- **Decomposition docs** merged from `chore/e3m1-slice2-decomposition` on this branch before implementation commits.
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# NEO-58 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-58 |
|
||||
| **Title** | E3.M1: Server resource-node catalog load (fail-fast) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-58/e3m1-server-resource-node-catalog-load-fail-fast |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-02** |
|
||||
| **Branch** | `NEO-58-e3m1-server-resource-node-catalog-load-fail-fast` (rebased on `main` after NEO-57 merge PR #92) |
|
||||
| **Precursor** | [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) — `content/resource-nodes/` + CI gates (**merged** to `main` via PR #92) |
|
||||
| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket ([NEO-59](https://linear.app/neon-sprawl/issue/NEO-59)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Branch base** | Stack on NEO-57 vs branch from `main`? | **Branch from NEO-57** — Linear `blockedBy` NEO-57; loader requires content + schemas from E3M1-01. | **User:** branch from NEO-57 (stacked PR). |
|
||||
| **DI scope (E3M1-02 vs E3M1-03)** | Register `IResourceNodeDefinitionRegistry` now? | **`ResourceNodeCatalog` singleton only** — E3M1-03 ([NEO-59](https://linear.app/neon-sprawl/issue/NEO-59)) owns injectable registry; mirrors NEO-51 / NEO-52 split for items. | **User:** catalog singleton only. |
|
||||
| **Item cross-check** | How to validate yield `itemId` at startup? | **Pass ids from `ItemDefinitionCatalog`** in the DI factory (items load first; no second disk read). | **User:** cross-check from `ItemDefinitionCatalog`. |
|
||||
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (NEO-51 / NEO-34 precedent). | **Adopted** — no separate question. |
|
||||
| **Host boot tests** | Loader unit tests only vs also `WebApplicationFactory`? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` (happy path, failure modes, `Host_ShouldResolveCatalogFromDi`, `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`). | **Adopted** — NEO-51 precedent. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** On host startup, load `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` with the same validation rules as CI ([NEO-57](NEO-57-implementation-plan.md)), build an in-memory **`ResourceNodeCatalog`**, cross-check yield **`itemId`** values against the already-loaded item catalog, and **refuse to listen** when any file is missing, malformed, or fails the prototype Slice 2 gate.
|
||||
|
||||
**In scope (from Linear + [E3M1-02](E3M1-prototype-backlog.md#e3m1-02--server-resource-node-catalog-load-fail-fast)):**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Gathering/`.
|
||||
- Configurable resource-nodes directory and row schema paths (`ContentPathsOptions` extension).
|
||||
- CI-parity validation: `schemaVersion`, row schemas, duplicate `nodeDefId`, frozen four ids, four `gatherLens`, one yield per node, yield `nodeDefId` exists in nodes, yield `itemId` in item catalog + Slice 2 `scrap_metal_bulk`-only gate.
|
||||
- DI registration of **`ResourceNodeCatalog`** singleton (factory depends on `ItemDefinitionCatalog`); eager resolve in `Program.cs` after item catalog.
|
||||
- Unit + host startup tests (AAA).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- **`IResourceNodeDefinitionRegistry`** / `ResourceNodeDefinitionRegistry` (**NEO-59**, E3M1-03).
|
||||
- Per-instance depletion, HTTP (`GET …/resource-node-definitions` is **NEO-60**), interact wiring, Bruno, client.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Server refuses boot when resource-node catalog invalid (missing dir, no `*_resource_nodes.json` / `*_resource_yields.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `nodeDefId`, Slice 2 gate failure, unknown yield `itemId`, orphan yield `nodeDefId`).
|
||||
- [x] `ResourceNodeCatalog` resolves node def and yield row by `nodeDefId` (e.g. `prototype_resource_node_alpha` → lens + `maxGathers` + yield quantity).
|
||||
- [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 **node count**, **yield count**, **catalog directory**, and **file counts** (Information).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`Game/Gathering/` row DTOs** — `ResourceNodeDefRow` (`NodeDefId`, `DisplayName`, `GatherLens`, `MaxGathers`); `ResourceYieldRow` (`NodeDefId`, `ItemId`, `Quantity`).
|
||||
|
||||
2. **`ResourceNodeCatalogLoader.Load(resourceNodesDirectory, nodeSchemaPath, yieldSchemaPath, itemIds, logger)`** — two-pass load mirroring `validate_content.py`:
|
||||
- Enumerate `*_resource_nodes.json` then `*_resource_yields.json` (top-level only), ordered by path.
|
||||
- **Nodes pass:** per file `schemaVersion == 1`, top-level `nodes` array; per row validate `resource-node-def.schema.json`; track `nodeDefId`, `gatherLens`; reject duplicate `nodeDefId` across files.
|
||||
- Run **`PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2NodeGateError`** (mirror `PROTOTYPE_SLICE2_NODE_IDS`, `PROTOTYPE_SLICE2_GATHER_LENSES`).
|
||||
- **Yields pass:** per file `schemaVersion == 1`, top-level `yields` array; per row validate `resource-yield-row.schema.json`; reject duplicate yield `nodeDefId`; require yield `nodeDefId` in loaded nodes; require `itemId` ∈ `itemIds`; run Slice 2 yield item gate (`scrap_metal_bulk` only); require exactly one yield per frozen `nodeDefId`.
|
||||
- Throw **`InvalidOperationException`** with aggregated messages prefixed `Resource node catalog validation failed:`.
|
||||
|
||||
3. **`ResourceNodeCatalog`** — immutable snapshot: `ResourceNodesDirectory`, `NodesById`, `YieldsByNodeDefId`, counts; `TryGetNode(nodeDefId, out ResourceNodeDefRow?)`, `TryGetYield(nodeDefId, out ResourceYieldRow?)`.
|
||||
|
||||
4. **`ResourceNodeCatalogPathResolution`** — `TryDiscoverResourceNodesDirectory`, `ResolveResourceNodesDirectory`, `ResolveResourceNodeDefSchemaPath`, `ResolveResourceYieldRowSchemaPath` (parent `content/schemas/` layout when unset).
|
||||
|
||||
5. **`PrototypeSlice2ResourceNodeCatalogRules`** — frozen four `nodeDefId`s, four `gatherLens`, yield item allowlist; sync comments to `scripts/validate_content.py` `PROTOTYPE_SLICE2_*`.
|
||||
|
||||
6. **`ResourceNodeCatalogServiceCollectionExtensions.AddResourceNodeCatalog`** — bind `ContentPathsOptions`; register **`ResourceNodeCatalog`** singleton factory that resolves paths, gets `ItemDefinitionCatalog.ById.Keys` as `FrozenSet<string>`, calls loader.
|
||||
|
||||
7. **`Program.cs`** — `AddResourceNodeCatalog(configuration)` after `AddItemDefinitionCatalog`; after `Build()`, `GetRequiredService<ResourceNodeCatalog>()` alongside existing catalog eager resolves.
|
||||
|
||||
8. **Tests** — temp-dir fixtures copying repo schemas; valid four-node + four-yield JSON matching [prototype_resource_nodes.json](../../content/resource-nodes/prototype_resource_nodes.json); six-item item id set stub for cross-check; negative cases (duplicate id, missing yield, bad lens set, unknown `itemId`, invalid directory); `InMemoryWebApplicationFactory` / `WebApplicationFactory` host tests mirroring items.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs` | Load-time node row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs` | Load-time yield row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs` | In-memory catalog + `TryGetNode` / `TryGetYield`. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs` | Disk I/O, schema validation, Slice 2 gates, item cross-check. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs` | Frozen four ids / four lenses / yield item gate (sync comment to Python). |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs` | `AddResourceNodeCatalog` DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs` | Repo `content/resource-nodes` + schema discovery for tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs` | AAA loader + host startup tests. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ResourceNodesDirectory`, `ResourceNodeDefSchemaPath`, `ResourceYieldRowSchemaPath` under `Content` section. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `ResourceNodeCatalog` at startup (after item catalog). |
|
||||
| `server/README.md` | Document resource-node catalog load, config keys, fail-fast behavior, Slice 2 parity with CI. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 / NEO-58 server loader status when implementation completes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs` | **Add** — AAA: `Load` happy path with repo-shaped JSON; failures for missing dir/schema, bad `schemaVersion`, schema violations, duplicate `nodeDefId`, incomplete Slice 2 ids, lens set mismatch, orphan yield `nodeDefId`, unknown `itemId`, duplicate yield `nodeDefId`, missing yield for node, empty `*_resource_nodes.json` / `*_resource_yields.json` directories; `Host_ShouldResolveCatalogFromDi`; `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | **Change** if needed — ensure resource-node content is discoverable in test host (same ancestor walk as items). |
|
||||
| **CI / PR gate** | Existing `scripts/validate_content.py` remains content regression; server tests guard C#/Python drift for Slice 2 constants. |
|
||||
| **Bruno smoke (optional)** | `bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bru` — `GET /health` after boot; no resource-node HTTP API this story (pre-commit hook when `Program.cs` changes). |
|
||||
|
||||
No client tests (out of scope).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Stacked PR on NEO-57** | NEO-57 merged (PR #92); NEO-58 rebased onto `main`. | **adopted** |
|
||||
| **C# vs Python drift** | Duplicate `PROTOTYPE_SLICE2_*` in `PrototypeSlice2ResourceNodeCatalogRules` with “keep in sync” comment (NEO-50 / NEO-57 pattern). | **adopted** |
|
||||
| **Test factories without NEO-57 on main** | NEO-58 branch includes NEO-57 content via stack; after NEO-57 merges, rebase drops dependency. | **adopted** |
|
||||
| **Registry consumers** | Gather engine / HTTP use `IResourceNodeDefinitionRegistry` in NEO-59+; this story only exposes `ResourceNodeCatalog` for boot + direct test lookup. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Branch from NEO-57** for initial stacked PR; rebased onto `main` after NEO-57 merged (PR #92).
|
||||
- **`ResourceNodeCatalog` singleton only** — defer `IResourceNodeDefinitionRegistry` to NEO-59.
|
||||
- **Yield `itemId` cross-check** uses `ItemDefinitionCatalog` keys passed into loader at DI registration time.
|
||||
- **In-process validation** + **loader and host boot tests** per NEO-51 precedent.
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# NEO-59 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-59 |
|
||||
| **Title** | E3.M1: Resource node definition registry + DI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-59/e3m1-resource-node-definition-registry-di |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-03** |
|
||||
| **Branch** | `NEO-59-e3m1-resource-node-definition-registry-di` |
|
||||
| **Precursor** | [NEO-58](https://linear.app/neon-sprawl/issue/NEO-58) — fail-fast `ResourceNodeCatalog` load (**merged** to `main` via PR #93) |
|
||||
| **Pattern** | [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) — thin registry adapter over startup catalog + DI; strict split after NEO-58 loader |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Enumeration API** | Include `GetDefinitionsInIdOrder()` now? | **Yes** — mirror `IItemDefinitionRegistry` / `ISkillDefinitionRegistry`; [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) `GET /game/world/resource-node-definitions` will need ordered defs without reaching into `ResourceNodeCatalog`. | **User:** include enumeration. |
|
||||
| **Yield access** | How should yield rows be exposed? | **Separate `TryGetYield(nodeDefId)`** alongside `TryGetDefinition` — mirrors `ResourceNodeCatalog.TryGetNode` / `TryGetYield`; backlog E3M1-03 tests call for yield metadata + capacity (`maxGathers`) fields. | **User:** separate `TryGetYield`. |
|
||||
| **Lookup naming** | `TryGetDefinition` vs `TryGetNode`? | **`TryGetDefinition(string? nodeDefId, …)`** — same naming as item/skill registries; catalog keeps `TryGetNode` for direct catalog access. | **Adopted** — NEO-52 / NEO-58 precedent. |
|
||||
| **Program.cs eager resolve** | Eager-resolve `IResourceNodeDefinitionRegistry` at boot? | **Omit** — `ResourceNodeCatalog` is already eager-resolved in `Program.cs` (NEO-58); registry is a thin adapter (NEO-52 default). | **Adopted** |
|
||||
| **Runtime validation** | Re-validate nodes/yields in registry? | **No** — catalog load is fail-fast (NEO-58); registry delegates to loaded rows only. | **Adopted** |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Provide **`IResourceNodeDefinitionRegistry`** backed by the startup-loaded **`ResourceNodeCatalog`**: resolve node defs and yield rows by stable **`nodeDefId`**, enumerate node defs in id order, expose prototype metadata (`displayName`, `gatherLens`, `maxGathers`, yield `itemId` / `quantity`). Register in DI so **NEO-60+** (HTTP), **NEO-61** (depletion), **NEO-62** (gather engine), and interact wiring depend on the interface instead of the catalog type.
|
||||
|
||||
**In scope (from Linear + [E3M1-03](E3M1-prototype-backlog.md#e3m1-03--resource-node-definition-registry--di)):**
|
||||
|
||||
- `ResourceNodeDefinitionRegistry` thin adapter over `ResourceNodeCatalog`.
|
||||
- DI registration alongside existing catalog singleton.
|
||||
- Unit tests (AAA): known prototype id lookup + node metadata + yield metadata; unknown `nodeDefId` returns false without throwing; enumeration order; host resolves registry from DI.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- HTTP ([NEO-60](https://linear.app/neon-sprawl/issue/NEO-60)).
|
||||
- Persistence of depletion state ([NEO-61](https://linear.app/neon-sprawl/issue/NEO-61)).
|
||||
- Gather engine / inventory mutation ([NEO-62](https://linear.app/neon-sprawl/issue/NEO-62)).
|
||||
- Changing loader, Slice 2 gate, or catalog load semantics (NEO-58).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] DI resolves `IResourceNodeDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
|
||||
- [x] Unit tests (AAA): lookup for known prototype `nodeDefId` (e.g. `prototype_resource_node_alpha`) with expected node metadata (`gatherLens`, `maxGathers`) and yield metadata (`itemId`, `quantity`).
|
||||
- [x] Unit tests (AAA): unknown `nodeDefId` returns false / absent without throwing (both node and yield lookups).
|
||||
- [x] `GetDefinitionsInIdOrder` returns all loaded node defs ordered by `nodeDefId` (ordinal).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`IResourceNodeDefinitionRegistry`** — mirror [`IItemDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Items/IItemDefinitionRegistry.cs):
|
||||
- `TryGetDefinition(string? nodeDefId, [NotNullWhen(true)] out ResourceNodeDefRow? definition)` — null and unknown ids return false without throwing.
|
||||
- `TryGetYield(string? nodeDefId, [NotNullWhen(true)] out ResourceYieldRow? yield)` — same contract for yield rows.
|
||||
- `GetDefinitionsInIdOrder()` — all node defs ordered by `ResourceNodeDefRow.NodeDefId` (ordinal).
|
||||
- XML remarks: gather engine / depletion / interact callers (NEO-61+) should use this interface; HTTP read model (NEO-60) should not reach into `ResourceNodeCatalog`.
|
||||
|
||||
2. **`ResourceNodeDefinitionRegistry`** — primary-constructor adapter over `ResourceNodeCatalog` (same pattern as [`ItemDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Items/ItemDefinitionRegistry.cs)).
|
||||
|
||||
3. **DI** — extend [`AddResourceNodeCatalog`](../../server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs) to register `IResourceNodeDefinitionRegistry` → `ResourceNodeDefinitionRegistry` after catalog registration. No change to `Program.cs` eager-resolve (catalog only).
|
||||
|
||||
4. **Comments** — update `ResourceNodeCatalog` summary to point at `IResourceNodeDefinitionRegistry`; update [server README](../../server/README.md) resource-node catalog section (currently says “lands in NEO-59”).
|
||||
|
||||
5. **Tests** — new `ResourceNodeDefinitionRegistryTests.cs` mirroring [`ItemDefinitionRegistryTests`](../../server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs): in-memory catalog helper, loader-backed prototype fixture via `ResourceNodeCatalogTestPaths`, host DI test.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/IResourceNodeDefinitionRegistry.cs` | Public contract: node lookup, yield lookup, enumerate, remarks for NEO-60/61/62 callers. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionRegistry.cs` | Singleton adapter over `ResourceNodeCatalog`. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionRegistryTests.cs` | AAA unit + host DI tests (mirror `ItemDefinitionRegistryTests`). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs` | Register `IResourceNodeDefinitionRegistry` → `ResourceNodeDefinitionRegistry` after catalog singleton. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs` | Tighten summary comment to reference `IResourceNodeDefinitionRegistry` (replace “Prefer registry in NEO-59” placeholder). |
|
||||
| `server/README.md` | Resource-node catalog section: document `IResourceNodeDefinitionRegistry` as preferred lookup surface. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-59 registry when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` for `prototype_resource_node_alpha` returns true with `GatherLens`, `MaxGathers`, `DisplayName`; `TryGetYield` for same id returns true with `ItemId` (`scrap_metal_bulk`) and `Quantity`; null and unknown id return false without throw for both methods; `GetDefinitionsInIdOrder` count (4) and ordinal order for multi-node fixture; loader-backed prototype fixture matches catalog. **Host:** `InMemoryWebApplicationFactory` resolves `IResourceNodeDefinitionRegistry` and finds `prototype_resource_node_alpha` with expected yield. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Catalog vs registry for game code** | New callers inject `IResourceNodeDefinitionRegistry`; leave `ResourceNodeCatalog` eager-resolve in `Program.cs` for fail-fast only. | **adopted** |
|
||||
| **Duplicate API on catalog** | Keep `ResourceNodeCatalog.TryGetNode` / `TryGetYield` for loader/tests; registry is the game-facing surface (NEO-52 precedent). | **adopted** |
|
||||
| **NEO-60 HTTP projection** | API layer joins node + yield via registry’s two TryGet methods when building DTOs; no combined read model in NEO-59. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`GetDefinitionsInIdOrder()` included** — enumeration for NEO-60 HTTP without catalog coupling.
|
||||
- **Separate `TryGetYield`** — yield metadata available to gather engine / tests without a combined DTO.
|
||||
- **`TryGetDefinition` naming** — consistent with item/skill registries; parameter is `nodeDefId`.
|
||||
- **No `Program.cs` registry eager-resolve** — catalog fail-fast only (NEO-52 pattern).
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
# NEO-60 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-60 |
|
||||
| **Title** | E3.M1: GET world resource-node-definitions + Bruno |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-60/e3m1-get-world-resource-node-definitions-bruno |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-04** |
|
||||
| **Branch** | `NEO-60-e3m1-get-world-resource-node-definitions-bruno` |
|
||||
| **Precursor** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — `GET /game/world/item-definitions` |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Yield projection** | Nested `yield` object vs flat `itemId`/`quantity` on the row? | **Nested `yield: { itemId, quantity }`** — mirrors catalog split (node def + yield table) and matches NEO-59 registry shape; room for multi-yield later. | **User:** nested yield object. |
|
||||
| **Row id JSON name** | `id` vs `nodeDefId`? | **`id`** — same HTTP convention as item/skill definition endpoints (NEO-53 / NEO-36). | **User:** `id`. |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-60.md`? | **Yes** — user-visible HTTP surface; mirror NEO-53. | **User:** yes. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose a stable, read-only JSON endpoint that returns all loaded prototype resource-node definitions (with yield summary) for dev tooling, Bruno, manual QA, and future client/codegen, backed by **`IResourceNodeDefinitionRegistry`** without duplicating catalog truth outside the server.
|
||||
|
||||
**In scope (from Linear + [E3M1-04](E3M1-prototype-backlog.md#e3m1-04--get-world-resource-node-definitions)):**
|
||||
|
||||
- **`GET /game/world/resource-node-definitions`** with versioned envelope (`schemaVersion` **1**, **`nodes`** array).
|
||||
- Each row: **`id`**, **`displayName`**, **`gatherLens`**, **`maxGathers`**, nested **`yield`** with **`itemId`** and **`quantity`** (camelCase JSON).
|
||||
- Nodes ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
|
||||
- Bruno folder `bruno/neon-sprawl-server/resource-node-definitions/`.
|
||||
- `docs/manual-qa/NEO-60.md`.
|
||||
- `server/README.md` section.
|
||||
- API integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Per-instance remaining capacity / depletion state ([NEO-61](https://linear.app/neon-sprawl/issue/NEO-61)).
|
||||
- Gather engine, inventory mutation, interact wiring ([NEO-62+](E3M1-prototype-backlog.md)).
|
||||
- Client HUD.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] GET returns all **four** prototype defs with **`schemaVersion`** **1** and yield summary fields needed for QA.
|
||||
- [x] Bruno happy path documented and runnable against dev server.
|
||||
- [x] Automated API tests (AAA) assert four ids in order and spot-check node + yield metadata.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`GET /game/world/resource-node-definitions`** — parameterless read; inject **`IResourceNodeDefinitionRegistry`** only (not `ResourceNodeCatalog`).
|
||||
|
||||
2. **Response shape:** Top-level **`schemaVersion`** (`ResourceNodeDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`nodes`** array. Each row maps from **`ResourceNodeDefRow`** + joined **`ResourceYieldRow`** via **`TryGetYield`**: **`id`** ← `NodeDefId`, **`displayName`**, **`gatherLens`**, **`maxGathers`**, **`yield`** ← `{ itemId, quantity }`. Prototype catalog guarantees one yield per node at load time; if **`TryGetYield`** fails for an enumerated def, treat as programmer error (should not occur post-NEO-58 validation).
|
||||
|
||||
3. **Ordering:** Build list from **`registry.GetDefinitionsInIdOrder()`**. Tests assert exact id sequence (ordinal):
|
||||
|
||||
`prototype_bio_mat_gamma`, `prototype_resource_node_alpha`, `prototype_subsurface_vein_beta`, `prototype_urban_bulk_delta`.
|
||||
|
||||
4. **Implementation:** New **`ResourceNodeDefinitionsWorldApi`** + **`ResourceNodeDefinitionsListDtos.cs`** in `Game/Gathering/` (mirror [`ItemDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Items/ItemDefinitionsWorldApi.cs)). Wire **`app.MapResourceNodeDefinitionsWorldApi()`** from **`Program.cs`** next to **`MapItemDefinitionsWorldApi()`**.
|
||||
|
||||
5. **Bruno:** `bruno/neon-sprawl-server/resource-node-definitions/` with **`Get resource node definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`nodes.length === 4`**, ascending id order, frozen four ids in registry order, spot-check **`prototype_resource_node_alpha`** (`gatherLens` `consumer_salvage`, `maxGathers` 10, `yield.itemId` `scrap_metal_bulk`, `yield.quantity` 1) and **`prototype_urban_bulk_delta`** (`yield.quantity` 5).
|
||||
|
||||
6. **Docs:** `docs/manual-qa/NEO-60.md` (mirror NEO-53). Update **`server/README.md`** resource-node section with GET + curl. Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row when landed. Flip E3M1-04 acceptance checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON (node + nested yield). |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionsListDtos.cs` | Versioned response + row/yield DTOs for JSON serialization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, four ids in order, spot-check rows + yields. |
|
||||
| `bruno/neon-sprawl-server/resource-node-definitions/Get resource node definitions.bru` | Manual / Bruno verification against dev server. |
|
||||
| `bruno/neon-sprawl-server/resource-node-definitions/folder.bru` | Bruno folder metadata (match `item-definitions`). |
|
||||
| `docs/manual-qa/NEO-60.md` | Checklist: start server, curl GET, confirm four ids + spot-check yield. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapResourceNodeDefinitionsWorldApi()` alongside other game world APIs. |
|
||||
| `server/README.md` | Document `GET /game/world/resource-node-definitions`, curl example, links to plan/manual QA/Bruno. |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — HTTP read model bullet (NEO-60). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-60 HTTP projection when landed. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-04 acceptance checkboxes when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionsWorldApiTests.cs` | **Integration:** `GET /game/world/resource-node-definitions` via **`InMemoryWebApplicationFactory`**; **`ReadFromJsonAsync`**; assert **`schemaVersion` 1**; **`nodes`** count **4**; ordered **`id`** list equals frozen four (ordinal); spot-check **`prototype_resource_node_alpha`** (`displayName` “Prototype Salvage Heap”, `gatherLens` `consumer_salvage`, `maxGathers` 10, `yield.itemId` `scrap_metal_bulk`, `yield.quantity` 1) and **`prototype_urban_bulk_delta`** (`gatherLens` `urban_bulk`, `yield.quantity` 5). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
Bruno scripts complement automated tests for manual dev-server verification.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Linear blockedBy NEO-59** | Branch from **`main`** where NEO-59 is merged (confirmed at kickoff). | **adopted** |
|
||||
| **Missing yield at map time** | Rely on NEO-58 load parity (every node has exactly one yield row); no silent omission in HTTP mapper. | **adopted** |
|
||||
| **Instance depletion in response** | Omit remaining gathers — instance state is NEO-61; this endpoint is definition catalog only. | **adopted** |
|
||||
|
||||
None blocking.
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Nested `yield` object** — `{ itemId, quantity }` per row; joined from registry’s separate TryGet methods.
|
||||
- **Row `id` JSON name** — consistent with item/skill world definition endpoints.
|
||||
- **`nodes` top-level array** — parallel to `items` / `skills` naming on sibling routes.
|
||||
- **Manual QA doc included** — `docs/manual-qa/NEO-60.md`.
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# NEO-61 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-61 |
|
||||
| **Title** | E3.M1: World node instance depletion store |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-61/e3m1-world-node-instance-depletion-store |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-05** |
|
||||
| **Branch** | `NEO-61-e3m1-world-node-instance-depletion-store` |
|
||||
| **Precursor** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done** on `main`); [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) — definition HTTP (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `IPlayerInventoryStore` + `PlayerInventoryOperations` persistence policy |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Architecture** | Split store + operations vs monolithic store? | **Split** — `IResourceNodeInstanceStore` (persistence) + `ResourceNodeInstanceOperations` (registry-backed lazy init, atomic decrement, reason codes); mirrors NEO-54. | **User:** split. |
|
||||
| **Initialization** | Lazy init vs startup seed all four nodes? | **Lazy init** — first read/decrement creates a row at `maxGathers` from registry; sparse Postgres rows. | **User:** lazy init. |
|
||||
| **Depleted rows** | Keep `remainingGathers = 0` vs delete row? | **Keep row at 0** — explicit depleted state for `node_depleted`; survives restarts. | **User:** keep zero. |
|
||||
| **Interactable registry** | Expand `PrototypeInteractableRegistry` to four nodes now? | **Defer to NEO-63** — store-only scope; tests use catalog `nodeDefId`s as `interactableId` without world anchors. | **User:** defer. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Server-authoritative **remaining gathers** per world node instance (`interactableId`), with structured deny **`node_depleted`** when capacity is exhausted — persistence + rules only, ready for the gather engine (NEO-62) and interact wiring (NEO-63).
|
||||
|
||||
**In scope (from Linear + [E3M1-05](E3M1-prototype-backlog.md#e3m1-05--world-node-instance-depletion-store)):**
|
||||
|
||||
- **`IResourceNodeInstanceStore`**, **`InMemoryResourceNodeInstanceStore`**, **`PostgresResourceNodeInstanceStore`**, **`PostgresResourceNodeInstanceBootstrap`**, **`ResourceNodeInstanceServiceCollectionExtensions`**.
|
||||
- **`ResourceNodeInstanceOperations`** — lazy init from **`IResourceNodeDefinitionRegistry`**, atomic successful-gather decrement, stable **`reasonCode`** strings.
|
||||
- Domain types: **`ResourceNodeInstanceSnapshot`**, **`ResourceNodeInstanceMutationOutcome`**, **`ResourceNodeInstanceReasonCodes`**.
|
||||
- Migration **`V006__resource_node_instance.sql`** (world-wide rows keyed by **`interactable_id`**; not per-player).
|
||||
- Unit + Postgres **parity** integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Item grants, skill XP, **`GatherResult`** engine ([NEO-62](https://linear.app/neon-sprawl/issue/NEO-62)).
|
||||
- **`InteractionApi`** wiring and **`node_depleted`** on interact ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)).
|
||||
- HTTP read model for instance state (definition catalog only is NEO-60).
|
||||
- Bruno, client HUD, regen / respawn ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
|
||||
- Expanding **`PrototypeInteractableRegistry`** beyond **`prototype_resource_node_alpha`** (NEO-63).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative.
|
||||
- [x] Postgres + in-memory parity tests.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Keying (prototype):** Store rows keyed by lowercase **`interactableId`**. Prototype wiring assumes **`interactableId == nodeDefId`** ([E3.M1 freeze](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md#prototype-slice-2-freeze-e3m1-01)); operations resolve **`maxGathers`** via **`IResourceNodeDefinitionRegistry.TryGetDefinition(interactableId, …)`**.
|
||||
|
||||
2. **Lazy initialization:** When **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement`** runs and the store has no row for the id:
|
||||
- If registry miss → deny **`unknown_node`**, no write.
|
||||
- Else **`TryEnsureInitialized(interactableId, definition.MaxGathers)`** on the store (create-if-absent only; idempotent when row already exists).
|
||||
|
||||
3. **Atomic decrement (store):** **`TryDecrementRemainingGathers(interactableId, out previous, out remaining)`**:
|
||||
- Requires an existing row with **`remaining > 0`**.
|
||||
- Sets **`remaining = previous - 1`** in one compare-and-swap (in-memory per-id lock) or single **`UPDATE … WHERE remaining_gathers > 0 RETURNING …`** (Postgres).
|
||||
- Returns **`false`** when row missing or already **`0`** (operations maps to **`node_depleted`** after lazy init path).
|
||||
|
||||
4. **Operations entry point:** **`TryCommitSuccessfulGatherDecrement(interactableId, registry, store, out outcome)`**:
|
||||
- Normalize id (trim + lowercase).
|
||||
- Lazy-init as above.
|
||||
- Call store decrement; on success → **`Applied`** with snapshot **`{ interactableId, remainingGathers }`**.
|
||||
- On decrement fail with row at **`0`** → **`Denied`** + **`node_depleted`**.
|
||||
- On registry miss → **`Denied`** + **`unknown_node`**.
|
||||
|
||||
5. **Depleted persistence:** When decrement reaches **`0`**, **keep the row** (`remaining_gathers = 0`). Subsequent decrements deny **`node_depleted`** without negative values.
|
||||
|
||||
6. **Persistence policy (NEO-54 / NEO-38 mirror):**
|
||||
- **`AddResourceNodeInstanceStore`** chooses **`PostgresResourceNodeInstanceStore`** when **`ConnectionStrings__NeonSprawl`** is set; else **`InMemoryResourceNodeInstanceStore`** (empty map at startup — no pre-seed).
|
||||
- Postgres: **`resource_node_instance`** table — **`interactable_id TEXT PRIMARY KEY`**, **`remaining_gathers INTEGER NOT NULL CHECK (remaining_gathers >= 0)`**, **`updated_at TIMESTAMPTZ`**.
|
||||
- Bootstrap: **`PostgresResourceNodeInstanceBootstrap.EnsureSchema`** loads embedded **`V006__resource_node_instance.sql`** from **`db/migrations/`**.
|
||||
- Chain registration from **`ResourceNodeCatalogServiceCollectionExtensions.AddResourceNodeCatalog`** (after registry) to keep **`Program.cs`** thin.
|
||||
|
||||
7. **Reason codes (snake_case constants):**
|
||||
- **`node_depleted`** — row exists and **`remainingGathers <= 0`** (or decrement rejected at zero).
|
||||
- **`unknown_node`** — **`interactableId`** not in **`IResourceNodeDefinitionRegistry`**.
|
||||
- Document in code XML + brief **`server/README.md`** depletion subsection (persistence only; interact deny deferred to NEO-63).
|
||||
|
||||
8. **Tests (primary node: `prototype_resource_node_alpha`, `maxGathers` 10):**
|
||||
- Operations: first decrement after lazy init → **`remainingGathers == 9`**; loop 10 successful decrements then next → **`node_depleted`**; assert never negative.
|
||||
- Spot-check **`prototype_urban_bulk_delta`** lazy init at **10**.
|
||||
- Unknown id → **`unknown_node`**, no row created.
|
||||
- In-memory store: get/ensure/decrement concurrency hygiene.
|
||||
- Postgres parity: decrement across two factory instances (mirror **`PlayerInventoryPersistenceIntegrationTests`**).
|
||||
|
||||
9. **Docs (on land):** Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices**, [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row, and E3M1-05 checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceSnapshot.cs` | Instance state: `interactableId`, `remainingGathers`. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs` | Persistence: get, ensure-initialized, atomic decrement. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs` | Thread-safe in-memory map; lazy rows only. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs` | Postgres CRUD + conditional decrement. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceBootstrap.cs` | One-time DDL ensure from `V006__resource_node_instance.sql`. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceServiceCollectionExtensions.cs` | Postgres vs in-memory registration from configuration. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceReasonCodes.cs` | Stable `node_depleted`, `unknown_node` constants. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceMutationOutcome.cs` | Applied/denied result types (`reasonCode`, snapshot). |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceOperations.cs` | Lazy init, registry lookup, decrement orchestration. |
|
||||
| `server/db/migrations/V006__resource_node_instance.sql` | Postgres table for per-instance remaining gathers. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs` | AAA: lazy init, decrement loop to depletion, unknown_node, never negative. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs` | Store get/ensure/decrement, id normalization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs` | Postgres: decrement, new factory, snapshot parity. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs` | Chain **`AddResourceNodeInstanceStore`** after registry registration. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Force in-memory instance store; strip Postgres instance registration in tests. |
|
||||
| `server/README.md` | Depletion store subsection: lazy init, reason codes, migration note (interact wiring in NEO-63). |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — instance depletion store bullet (NEO-61). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-61 depletion store when landed. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-05 acceptance checkboxes when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs` | **Integration-style (DI factory):** `TryCommitSuccessfulGatherDecrement` on **`prototype_resource_node_alpha`** — first call **`Applied`**, **`remainingGathers == 9`**; ten more successful decrements; eleventh → **`Denied`**, **`node_depleted`**; **`unknown_node`** for bogus id with no store row. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs` | **Unit:** `TryEnsureInitialized` idempotent; decrement from 1→0; second decrement fails; normalization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs` | **Postgres:** decrement via first host, read/decrement via fresh **`PostgresWebApplicationFactory`**; depleted row persists at 0; **`RequirePostgresFact`**. |
|
||||
|
||||
No Bruno or HTTP tests — out of scope; manual verification of interact deny waits for NEO-63.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Linear blockedBy NEO-59** | Branch from **`main`** where NEO-59/60 are merged (confirmed at kickoff). | **adopted** |
|
||||
| **Only alpha in interactable registry** | Defer registry expansion to NEO-63; store tests use catalog ids directly. | **adopted** |
|
||||
| **World-wide vs per-player depletion** | **World-wide** — one capacity pool per `interactableId` (module doc: per-instance node capacity, not per-player). | **adopted** |
|
||||
| **Regen after depletion** | Out of scope Slice 2; depleted rows stay at 0 until a future ecology story. | **deferred** |
|
||||
|
||||
None blocking.
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Split store + operations** — persistence vs registry-backed rules and reason codes (NEO-54 mirror).
|
||||
- **Lazy init** — rows created on first operations access at catalog **`maxGathers`** (prototype: **10** for all four nodes).
|
||||
- **Keep depleted rows** — `remainingGathers = 0` persisted; subsequent commits return **`node_depleted`**.
|
||||
- **Defer interactable registry expansion** — NEO-63 adds beta/gamma/delta world anchors.
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# NEO-62 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-62 |
|
||||
| **Title** | E3.M1: GatherResult engine (yields + inventory + skill XP) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-62/e3m1-gatherresult-engine-yields-inventory-skill-xp |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-06** |
|
||||
| **Branch** | `NEO-62-e3m1-gatherresult-engine-yields-inventory-skill-xp` |
|
||||
| **Precursors** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done**); [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — `IResourceNodeInstanceStore` + depletion ops (**Done**); [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) / [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — skill XP grant path (**Done**) |
|
||||
| **Pattern** | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — static `*Operations` orchestrator over injectable stores; [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — subsystem reason codes surfaced on gather deny |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Side-effect order** | Inventory / XP / depletion commit sequence? | **Inventory add → XP grant → depletion decrement last** — depletion only after successful grant; avoids consuming capacity on `inventory_full` (NEO-54 all-or-nothing). | **User:** inventory → XP → depletion last. |
|
||||
| **XP failure after inventory** | Roll back items or partial success? | **Deny gather + compensating `TryRemoveStack`** — gather success must include items and XP per AC; NEO-41 silent XP no-op stays on interact until NEO-63. | **User:** rollback + deny. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Single server operation resolving **`GatherResult`**: validate node + capacity, grant configured yield via **`PlayerInventoryOperations`**, award **`salvage`** XP via **`SkillProgressionGrantOperations`**, commit depletion via **`ResourceNodeInstanceOperations`** — engine-only, ready for interact wiring (NEO-63).
|
||||
|
||||
**In scope (from Linear + [E3M1-06](E3M1-prototype-backlog.md#e3m1-06--gatherresult-engine-yields--inventory--skill-xp)):**
|
||||
|
||||
- **`GatherOperations`** / **`GatherResult`** types in `server/NeonSprawl.Server/Game/Gathering/`.
|
||||
- Stable deny **`reasonCode`** passthrough: `node_depleted`, `inventory_full`, `unknown_node`, plus gather-specific `progression_store_missing` when XP rollback path runs.
|
||||
- Unit / integration tests (AAA) via **`InMemoryWebApplicationFactory`** with real catalogs and stores.
|
||||
- Document **`GatherResult`** shape in module doc (server-internal until promoted to wire).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- **`InteractionApi`** wiring and duplicate NEO-41 XP removal ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)).
|
||||
- Client HUD, Bruno, HTTP gather route.
|
||||
- Regen / respawn timers ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
|
||||
- Telemetry hook sites ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)).
|
||||
- Cross-store transactions (prototype relies on ordered commits + compensating remove on XP failure).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
|
||||
- [x] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change.
|
||||
- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**).
|
||||
- [x] Depletion store decrements atomically with successful grant (decrement **last**, only when inventory + XP both succeed).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Entry point:** **`GatherOperations.TryGather(...)`** — static orchestrator accepting `playerId`, `interactableId`, and dependencies:
|
||||
- **`IResourceNodeDefinitionRegistry`** (def + yield),
|
||||
- **`IItemDefinitionRegistry`** + **`IPlayerInventoryStore`**,
|
||||
- **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, **`ISkillLevelCurve`**, **`PerkUnlockEngine`**,
|
||||
- **`IResourceNodeInstanceStore`**.
|
||||
|
||||
2. **Normalize key:** **`ResourceNodeInstanceIds.Normalize(interactableId)`** (trim + lowercase); empty → **`unknown_node`**.
|
||||
|
||||
3. **Definition + yield resolve:** **`TryGetDefinition`** and **`TryGetYield`** on normalized id. Either miss → **`Denied`** + **`GatherReasonCodes.UnknownNode`** (no writes).
|
||||
|
||||
4. **Capacity pre-check (read-only, no decrement):**
|
||||
- **`store.TryEnsureInitialized(key, definition.MaxGathers)`** (lazy init mirror of NEO-61).
|
||||
- **`store.TryGetRemainingGathers(key, out snapshot)`** — if **`remainingGathers <= 0`** → **`Denied`** + **`GatherReasonCodes.NodeDepleted`** with snapshot (no inventory / XP / decrement).
|
||||
|
||||
5. **Inventory grant (first mutation):** **`PlayerInventoryOperations.TryAddStack(playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore)`**.
|
||||
- **`Denied`** + **`inventory_full`** (passthrough) → return immediately; depletion unchanged.
|
||||
- **`StoreMissing`** → **`GatherReasonCodes.InventoryStoreMissing`** (gather surface; unlikely in tests).
|
||||
- **`Applied`** → continue.
|
||||
|
||||
6. **Skill XP (second mutation):** **`SkillProgressionGrantOperations.TryApplyGrant`** with **`GatherSkillXpConstants.SalvageSkillId`**, **`ActivityXpPerResourceNodeGather`**, **`ActivitySourceKind`**.
|
||||
- **`Granted`** → continue.
|
||||
- **`ProgressionStoreMissing`** or **`Denied`** → **compensating rollback:** **`PlayerInventoryOperations.TryRemoveStack`** same **`itemId`** / **`quantity`**; return **`Denied`** + **`GatherReasonCodes.ProgressionStoreMissing`** (or passthrough skill deny **`reasonCode`** from response when **`Denied`**). Depletion unchanged.
|
||||
|
||||
7. **Depletion commit (last mutation):** **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(key, nodeRegistry, store)`**.
|
||||
- Expect **`Applied`** given step 4 pre-check; on unexpected **`Denied`**, attempt compensating inventory remove + surface **`node_depleted`** (defensive; document as prototype race risk — no XP rollback in this edge case).
|
||||
|
||||
8. **Success envelope — `GatherResult`:**
|
||||
- **`Success = true`**, **`ReasonCode = null`**
|
||||
- **`GrantsApplied`**: single entry `{ itemId, quantity }` from yield row (prototype: one yield per node).
|
||||
- **`RemainingGathers`**: from depletion outcome snapshot.
|
||||
- **`XpGrantSummary`**: optional compact summary from grant response (e.g. **`skillId`**, **`amount`**, **`sourceKind`**) for tests / future wire.
|
||||
|
||||
9. **Reason codes (`GatherReasonCodes`):** Re-export stable subsystem strings where applicable:
|
||||
- **`unknown_node`**, **`node_depleted`**, **`inventory_full`** — same literals as NEO-61 / NEO-54.
|
||||
- **`progression_store_missing`**, **`inventory_store_missing`** — gather-layer codes for store-missing paths.
|
||||
|
||||
10. **Yield quantities (content, fixed):** Alpha **1**, beta **2**, gamma **3**, delta **5** `scrap_metal_bulk` per [`prototype_resource_yields.json`](../../content/resource-nodes/prototype_resource_yields.json). Tests spot-check alpha (1) and optionally delta (5).
|
||||
|
||||
11. **Docs (on land):** Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices** + **`GatherResult`** contract note; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row; E3M1-06 checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md); brief **`server/README.md`** gather-engine subsection (engine-only; interact in NEO-63).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs` | Stable gather deny codes (passthrough + store-missing). |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs` | One applied yield row in success envelope. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs` | Compact XP summary on successful gather. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherResult.cs` | Success/deny envelope: grants, remainingGathers, reasonCode, xp summary. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Orchestrator: pre-check → inventory → XP → depletion; rollback on XP failure. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | AAA: success path, inventory_full, node_depleted, unknown_node, XP rollback, quantity per yield. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/README.md` | Gather engine subsection: `GatherOperations`, commit order, reason codes; note interact wiring in NEO-63. |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — gather engine bullet (NEO-62); **`GatherResult`** field table alignment. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-62 gather engine when landed. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-06 acceptance checkboxes when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | **Integration-style (DI factory):** **`TryGather`** on **`prototype_resource_node_alpha`** — **`Success`**, bag quantity +1 **`scrap_metal_bulk`**, **`salvage`** XP +10, **`RemainingGathers == 9`**. **Full bag:** pre-fill 24 bag slots (`survey_drone_kit` ×1 each) → **`Denied`**, **`inventory_full`**, depletion row unchanged (still lazy-uninitialized or full capacity). **Depletion loop:** exhaust alpha (10 gathers) → next **`node_depleted`**, no inventory/XP change. **`unknown_node`** for bogus id. **XP failure:** custom test factory or stub progression store that returns **`ProgressionStoreMissing`** after inventory add → gather denied, inventory restored to pre-gather snapshot. **Optional:** delta node grants quantity **5**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
No Bruno or HTTP tests — out of scope; manual gather flow waits for NEO-63.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **NEO-61 on `main`** | Branch from **`main`** where NEO-59/60/61 are merged (confirmed at kickoff). | **adopted** |
|
||||
| **Duplicate XP via `InteractionApi`** | Leave NEO-41 interact hook until **NEO-63** removes it; engine-only scope for NEO-62. | **adopted** |
|
||||
| **Depletion fails after inventory+XP** | Extremely unlikely after pre-check; defensive inventory rollback, no XP undo — document; no cross-store transaction in prototype. | **deferred** |
|
||||
| **Per-lens yield quantities in tests** | Spot-check alpha (1) + delta (5); full four-node matrix optional. | **adopted** |
|
||||
|
||||
None blocking.
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Commit order:** inventory add → XP grant → depletion decrement **last**.
|
||||
- **XP failure:** deny gather + compensating **`TryRemoveStack`**; depletion not committed.
|
||||
- **Reason codes:** passthrough subsystem literals on gather deny surface; gather-specific codes for store-missing only.
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
# NEO-63 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-63 |
|
||||
| **Title** | E3.M1: Interact path integration + registry expansion + Bruno |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-63/e3m1-interact-path-integration-registry-expansion-bruno |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-07** |
|
||||
| **Branch** | `NEO-63-e3m1-interact-path-integration-registry-expansion-bruno` |
|
||||
| **Precursors** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — `GatherOperations` + `GatherResult` (**Done** on story branch); [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — inline XP on interact (superseded) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **New node anchors** | X/Z for beta, gamma, delta? | **Beta (18, −6)**, **gamma (12, 0)**, **delta (18, 0)** — spread from alpha **(12, −6)** and terminal **(0, 0)**, same radius **3**. | **Adopted** (no blocking questions at kickoff; user said **begin**). |
|
||||
| **Gather deny on interact** | `Allowed: false` + `reasonCode` vs NEO-41 silent success? | **`Allowed: false`** + passthrough gather **`reasonCode`** per E3M1-07 backlog. | **Adopted**. |
|
||||
| **InteractionResponse payload** | Extend with gather fields? | **Keep placeholder** — verify via GET inventory / skill-progression (module doc). | **Adopted**. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Wire **`POST …/interact`** **`resource_node`** path to **`GatherOperations`**; register **four** prototype nodes; Bruno + manual QA for gather → inventory and depletion deny.
|
||||
|
||||
**In scope:** `InteractionApi`, `PrototypeInteractableRegistry` expansion, integration tests, Bruno, README, manual QA.
|
||||
|
||||
**Out of scope:** Godot HUD, gather HTTP route, telemetry hooks (NEO-64).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] In-range interact on fresh node increases inventory quantity and **`salvage`** XP.
|
||||
- [x] Depleted node returns **`node_depleted`** without inventory or XP change.
|
||||
- [x] Terminal interact unchanged (no gather side effects).
|
||||
- [x] Bruno documents happy path + depletion deny for at least one node.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`InteractionApi`:** On **`resource_node`** kind after range check, call **`GatherOperations.TryGather`**. Success → **`Allowed: true`**; deny → **`Allowed: false`** + **`ReasonCode`** from **`GatherResult`**. Remove NEO-41 inline **`SkillProgressionGrantOperations`** block.
|
||||
|
||||
2. **Registry:** Add beta/gamma/delta ids + anchors (see kickoff table). **`GET …/interactables`** returns **5** rows (terminal + four nodes).
|
||||
|
||||
3. **Tests:** New **`InteractionResourceNodeGatherIntegrationTests`** — inventory + XP success, depletion deny, terminal control, stub-registry deny. Update **`InteractablesWorldApiTests`** for four nodes. Trim NEO-41 XP-only defensive test (migrated to integration deny test). Fix **`SalvageActivityDeniedRegistryWebApplicationFactory`** for gather DI (items + resource nodes + inventory + instance stores).
|
||||
|
||||
4. **Bruno:** Update alpha gather request; add gather → GET inventory; add depletion deny (pre-request 10 gathers).
|
||||
|
||||
5. **Docs:** README interaction reason codes + gather subsection; E3.M1 module freeze table anchors; alignment + backlog checkboxes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs` | HTTP integration: inventory, XP, depletion, terminal, deny registry. |
|
||||
| `bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru` | Happy path: interact + assert scrap in bag. |
|
||||
| `bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru` | Pre-loop 10 gathers on **`prototype_urban_bulk_delta`** (isolated from alpha requests earlier in collection); 11th **`node_depleted`**. |
|
||||
| `docs/manual-qa/NEO-63.md` | curl gather → inventory + depletion deny. |
|
||||
| `docs/plans/NEO-63-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Delegate **`resource_node`** to **`GatherOperations`**; map denies. |
|
||||
| `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs` | Three new node anchors + registry entries. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs` | Assert **5** interactables + new anchor coords. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs` | Remove obsolete NEO-41 silent-deny test; keep terminal + XP smoke. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Full in-memory gather dependencies for deny test host. |
|
||||
| `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru` | NEO-63 docs (inventory + XP via gather engine). |
|
||||
| `server/README.md` | Gather via interact; reason codes; four anchors. |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | Anchor coords; NEO-63 slice bullet. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — NEO-63 landed. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E3.M1 note — E3M1-07 complete. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-07 checkboxes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `InteractionResourceNodeGatherIntegrationTests.cs` | Success: +1 scrap, +10 salvage XP. Depletion: 10 gathers then **`node_depleted`**. Terminal: no side effects. Stub registry: deny + no grants. |
|
||||
| `InteractablesWorldApiTests.cs` | **5** descriptors; beta/gamma/delta anchors. |
|
||||
| `InteractionResourceNodeGatherXpTests.cs` | Terminal + alpha XP smoke (regression). |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Branch stacks NEO-62** | PR base includes gather engine commits until NEO-62 merges. | **adopted** |
|
||||
| **Client world props** | Godot **`interactable_world_builder`** picks up new ids from GET; no scene edit required for server QA. | **deferred** |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# NEO-64 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-64 |
|
||||
| **Title** | E3.M1: resource_gathered / gather_node_depleted telemetry hook sites |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-64/e3m1-resource-gathered-gather-node-depleted-telemetry-hook-sites |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-08** |
|
||||
| **Branch** | `NEO-64-e3m1-resource-gathered-gather-node-depleted-telemetry-hooks` |
|
||||
| **Blocked by** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — `GatherOperations` + `GatherResult` (**Done** on `main`); [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — interact gather wiring (**Done** on `main`). |
|
||||
| **Pattern** | [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) / [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) — comment-only engine anchors; [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30) — event name + `TODO(E9.M1)` |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Runtime behavior** | Comments-only vs dev `ILogger`? | **Comments-only** — `TODO(E9.M1)`; no production logging (NEO-49/56 precedent). | **Adopted** (repo precedent; not asked). |
|
||||
| **Hook anchor** | Engine only vs engine + `InteractionApi`? | **Engine only** in **`GatherOperations.TryGather`** — HTTP calls engine; no duplicate API-layer hooks (NEO-56 precedent). | **Adopted** (repo precedent; not asked). |
|
||||
| **`gather_node_depleted` timing** | Success transition vs also pre-gather deny? | **Success transition only** — hook when a successful gather drives **`remainingGathers`** to **0** after depletion commit; not on pre-gather **`node_depleted`** deny. | **User:** success transition only. |
|
||||
| **Manual QA doc** | `docs/manual-qa/NEO-64.md`? | **Yes** — NEO-49/56 verification pattern (comments + no behavior change). | **Adopted** (repo convention). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Document and place **comment-only hook sites** on the server-authoritative gather path for future E9.M1 catalog events **`resource_gathered`** and **`gather_node_depleted`**. Align with Epic 3 Slice 2 telemetry vocabulary ([epic_03 — Slice 2](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-2)).
|
||||
|
||||
**In scope (from Linear + [E3M1-08](E3M1-prototype-backlog.md#e3m1-08--resource_gathered--gather_node_depleted-telemetry-hook-sites)):**
|
||||
|
||||
- Hook placement in **`GatherOperations.TryGather`** with **`TODO(E9.M1)`** comments.
|
||||
- **`server/README.md`** gather subsection + [E3_M1 module](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) hook note.
|
||||
- **`docs/manual-qa/NEO-64.md`** — verify comments + no behavior/API change.
|
||||
- E3M1 backlog + alignment register updates when implementation completes.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Telemetry ingest, dashboards (**E9.M1**).
|
||||
- `ILogger` / metrics / dev-only log lines.
|
||||
- Duplicate hook comments in **`InteractionApi`** (NEO-56 engine-only precedent).
|
||||
- **`gather_node_depleted`** on pre-gather depletion deny or interact deny passthrough (kickoff decision).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Hook sites documented in code and `server/README.md`.
|
||||
- [x] Matches Epic 3 Slice 2 telemetry vocabulary (`resource_gathered`, `gather_node_depleted`).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Authoritative surface:** **`GatherOperations.TryGather`** — all gather side effects (inventory → XP → depletion) funnel here; **`InteractionApi`** delegates **`resource_node`** interact without duplicating hooks.
|
||||
|
||||
2. **Hook site A — `resource_gathered`:** Immediately before the successful **`GatherResult`** return (after depletion commit succeeds):
|
||||
- Comment block names future E9.M1 event **`resource_gathered`**.
|
||||
- **`TODO(E9.M1): catalog emit`** (NEO-49 / NEO-56 style).
|
||||
- Planned payload fields: `playerId`, `interactableId` (node instance key), `itemId`, `quantity` (from **`GatherGrantApplied`**), `skillId` + `xpAmount` + `sourceKind` (from **`GatherXpGrantSummary`**), `remainingGathers` after commit.
|
||||
|
||||
3. **Hook site B — `gather_node_depleted`:** On the same success path, inside `if (remainingAfterGather == 0)` after depletion commit (NEO-40 `level_up` conditional pattern):
|
||||
- Comment block names future E9.M1 event **`gather_node_depleted`**.
|
||||
- **`TODO(E9.M1): catalog emit`** — emit **once** when capacity transitions to zero via successful gather (not on pre-gather **`node_depleted`** deny).
|
||||
- Planned payload fields: `interactableId`, optional `playerId` (last gatherer), `maxGathers` from definition if needed at E9.M1 wiring time.
|
||||
|
||||
4. **Cross-references (minimal):**
|
||||
- Update **`GatherResult.cs`** xml summary → hook sites in **`TryGather`**.
|
||||
- **`GatherOperations`** class summary → NEO-64 telemetry hook anchor.
|
||||
- **`server/README.md`:** new **NEO-64 telemetry hooks** subsection under gather engine (after NEO-62 block).
|
||||
- **`E3_M1_ResourceNodeAndGatherLoop.md`:** NEO-64 landed line when complete.
|
||||
- **`documentation_and_implementation_alignment.md`** + **`module_dependency_register.md`:** E3.M1 row — E3M1-08 complete; module **Ready** when all Slice 2 backlog lands.
|
||||
- **`E3M1-prototype-backlog.md`:** E3M1-08 checkboxes + landed note.
|
||||
|
||||
5. **NEO-56 interaction:** Successful gather already triggers **`item_created`** inside **`PlayerInventoryOperations.TryAddStack`**; **`resource_gathered`** is the gather-funnel catalog event at the orchestration layer (analogous to cast accept vs ability-specific events).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/manual-qa/NEO-64.md` | Manual QA: verify comment blocks + no behavior/API change. |
|
||||
| `docs/plans/NEO-64-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | **NEO-64:** comment-only **`resource_gathered`** + **`gather_node_depleted`** hook sites on success path. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherResult.cs` | Cross-reference hook anchor in type summary (no behavior change). |
|
||||
| `server/README.md` | Document gather telemetry hook placement (NEO-64). |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | Implementation snapshot: NEO-64 telemetry hooks landed. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row: NEO-64 hook sites. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E3.M1 note — E3M1-08 complete; module status **Ready**. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-08 checkboxes + landed note. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing **`GatherOperationsTests`** + **`InteractionResourceNodeGatherIntegrationTests`** unchanged). Manual verification: **`docs/manual-qa/NEO-64.md`**. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Overlap with NEO-56 `item_created`** | Keep both — **`item_created`** at inventory layer; **`resource_gathered`** at gather orchestration (E9.M1 can correlate or sample). | **adopted** |
|
||||
| **Pre-gather depletion deny telemetry** | No **`gather_node_depleted`** hook on deny; interact **`node_depleted`** **`reasonCode`** suffices until E9.M1 defines a separate deny funnel event. | **adopted** (kickoff) |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# NEO-65 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-65 |
|
||||
| **Title** | E3.M2: Prototype RecipeDef catalog + schemas + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-65/e3m2-prototype-recipedef-catalog-schemas-ci |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-01** |
|
||||
| **Branch** | `NEO-65-e3m2-prototype-recipedef-catalog-schemas-ci` |
|
||||
| **Upstream** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — frozen six-item catalog; prototype skill trio includes **`refine`**. |
|
||||
| **Pattern** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) / [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) — row JSON Schema + catalog envelope + `scripts/validate_content.py` Slice gate |
|
||||
| **Blocks** | [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) — server recipe catalog load |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No blocking clarifications needed.** Linear AC, [E3M2-prototype-backlog — E3M2-01](E3M2-prototype-backlog.md#e3m2-01--prototype-recipedef-catalog--schemas--ci), [E3.M2 kickoff decisions](E3M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults), and the [Slice 3 freeze table](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md#prototype-slice-3-freeze-e3m2-01) fully specify recipe count, ids, I/O quantities, `recipeKind`, and `requiredSkillId`. Schema shape follows NEO-50 / NEO-57 precedent:
|
||||
|
||||
| Topic | Agent recommendation | Status |
|
||||
|--------|----------------------|--------|
|
||||
| **Catalog envelope** | `{ "schemaVersion": 1, "recipes": [ … ] }` in `content/recipes/prototype_recipes.json` (mirror items / resource-nodes). | **Adopted** (NEO-50 precedent) |
|
||||
| **`displayName` on `RecipeDef`** | **Required** — mirror `ItemDef`; supports E3M2-04 GET without schema churn. | **Adopted** (NEO-50 / NEO-57 precedent) |
|
||||
| **Optional forward-compat stubs** | Optional `requiredSkillLevel` (int ≥ 0) and `stationTag` (string) on `recipe-def.schema.json`; **omitted** on all eight prototype rows (Slice 3 has no gates / stations). | **Adopted** (NEO-50 stub pattern) |
|
||||
| **`requiredSkillLevel` in prototype rows** | **Omitted** — decomposition default; no level gates in Slice 3. | **Adopted** (backlog kickoff table) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **eight** frozen `RecipeDef` rows before server load (NEO-66+).
|
||||
|
||||
**In scope (from Linear + [E3M2-01](E3M2-prototype-backlog.md#e3m2-01--prototype-recipedef-catalog--schemas--ci)):**
|
||||
|
||||
- `content/schemas/recipe-def.schema.json` and `content/schemas/recipe-io-row.schema.json`.
|
||||
- `content/recipes/prototype_recipes.json` — eight frozen ids (freeze table below).
|
||||
- `scripts/validate_content.py` — schema validation, duplicate `id`, exact eight-recipe allowlist, `recipeKind` coverage (**`process`** + **`make`**), cross-check every input/output **`itemId`** against item catalogs, every **`requiredSkillId`** against skill catalogs (prototype gate: all **`refine`**).
|
||||
- Designer note in **E3.M2** module doc + `content/README.md` Slice 3 paragraph.
|
||||
- **CT.M1** PR gate paragraph for recipe catalogs.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server loader, craft engine, HTTP (NEO-66+).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates recipe JSON against schema.
|
||||
- [x] Exactly eight prototype recipe ids; duplicate `id` fails CI.
|
||||
- [x] Every input/output references a valid frozen item id; every recipe references **`refine`**.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Row schema — I/O row (`recipe-io-row.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `itemId`, `quantity`. `itemId` pattern `^[a-z][a-z0-9_]*$`. `quantity` integer ≥ 1.
|
||||
|
||||
2. **Row schema — `RecipeDef` (`recipe-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `id`, `displayName`, `recipeKind`, `requiredSkillId`, `inputs`, `outputs`. `id` pattern `^[a-z][a-z0-9_]*$`. `recipeKind` enum: `process` \| `make`. `requiredSkillId` string (same id pattern). `inputs` / `outputs`: arrays with `minItems: 1`; each element validates against `recipe-io-row.schema.json` (via `$ref`). Optional stubs (unused in prototype rows): `requiredSkillLevel` (int ≥ 0), `stationTag` (string).
|
||||
|
||||
3. **Catalog file:** `content/recipes/prototype_recipes.json` with `schemaVersion: 1` and eight rows from the freeze table; placeholder `displayName` strings acceptable.
|
||||
|
||||
4. **Frozen content values (prototype Slice 3):**
|
||||
|
||||
| `RecipeDef.id` | `recipeKind` | inputs → outputs |
|
||||
|----------------|--------------|------------------|
|
||||
| `refine_scrap_standard` | `process` | 5× `scrap_metal_bulk` → 1× `refined_plate_stock` |
|
||||
| `refine_scrap_efficient` | `process` | 10× `scrap_metal_bulk` → 2× `refined_plate_stock` |
|
||||
| `make_field_stim_mk0` | `make` | 2× `refined_plate_stock` + 1× `scrap_metal_bulk` → 1× `field_stim_mk0` |
|
||||
| `make_field_stim_batch` | `make` | 5× `refined_plate_stock` + 3× `scrap_metal_bulk` → 3× `field_stim_mk0` |
|
||||
| `make_prototype_armor` | `make` | 8× `refined_plate_stock` + 5× `scrap_metal_bulk` → 1× `prototype_armor_shell` |
|
||||
| `make_armor_quick` | `make` | 4× `refined_plate_stock` + 3× `scrap_metal_bulk` → 1× `prototype_armor_shell` |
|
||||
| `make_survey_drone_kit` | `make` | 3× `refined_plate_stock` + 2× `scrap_metal_bulk` → 1× `survey_drone_kit` |
|
||||
| `make_contract_token` | `make` | 2× `refined_plate_stock` + 1× `scrap_metal_bulk` → 1× `contract_handoff_token` |
|
||||
|
||||
All rows: `requiredSkillId: "refine"`. No `requiredSkillLevel` or `stationTag` on prototype rows.
|
||||
|
||||
5. **`validate_content.py`:**
|
||||
- Add `RECIPE_SCHEMA`, `RECIPE_IO_ROW_SCHEMA`, `RECIPES_DIR`; discover `content/recipes/*_recipes.json`.
|
||||
- `_validate_recipe_catalogs`: envelope `schemaVersion: 1`, top-level `recipes` array; validate each row against `recipe-def.schema.json`; track `seen_ids`; reject duplicates across files.
|
||||
- Post-schema cross-checks per row: every I/O `itemId` ∈ loaded item ids; `requiredSkillId` ∈ loaded skill ids.
|
||||
- **`_prototype_slice3_recipe_gate`:** ids must equal `PROTOTYPE_SLICE3_RECIPE_IDS`; `recipeKind` set must include **`process`** and **`make`**; every row `requiredSkillId` must be **`refine`** (prototype Slice 3 contract).
|
||||
- Run recipe validation **after** skill + item catalogs succeed (reuse `seen_ids` / `item_seen_ids`).
|
||||
- Require recipe schema files in `main()` startup checks; extend success summary with recipe file + id counts.
|
||||
- Keep `PROTOTYPE_SLICE3_RECIPE_IDS` in sync with [E3.M2 freeze table](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md#prototype-slice-3-freeze-e3m2-01) and future NEO-66 server loader.
|
||||
|
||||
6. **Docs:**
|
||||
- `content/README.md` — ensure `recipes/` table row + Slice 3 freeze paragraph (may already exist on branch from decomposition prep).
|
||||
- `E3_M2_RefinementAndRecipeExecution.md` — ensure freeze table + CI rules line (may already exist on branch).
|
||||
- `CT_M1_ContentValidationPipeline.md` — recipe catalog PR gate paragraph.
|
||||
- `documentation_and_implementation_alignment.md` — E3.M2 row → **In Progress** / NEO-65.
|
||||
- `E3M2-prototype-backlog.md` — E3M2-01 checkboxes when implementation completes.
|
||||
|
||||
7. **No server/C#/client changes** in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/recipe-io-row.schema.json` | JSON Schema for a single recipe input/output row. |
|
||||
| `content/schemas/recipe-def.schema.json` | JSON Schema for a single `RecipeDef` row. |
|
||||
| `content/recipes/prototype_recipes.json` | Prototype eight-recipe catalog (`schemaVersion` + `recipes` array). |
|
||||
| `docs/plans/NEO-65-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Load/validate recipe catalogs; duplicate `id`; Slice 3 eight-id + `recipeKind` + `refine` gates; I/O item + skill cross-checks; success summary. |
|
||||
| `content/README.md` | Document `content/recipes/`, schema paths, Slice 3 freeze (active catalog). |
|
||||
| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | Confirm freeze table + CI enforcement note; link to plan when complete. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for recipe catalog validation. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 / NEO-65 status after catalog lands. |
|
||||
| `docs/plans/E3M2-prototype-backlog.md` | E3M2-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 recipe `id` → non-zero exit; (2) remove one frozen id → gate error; (3) unknown `itemId` in inputs → cross-check error; (4) `requiredSkillId: "salvage"` → Slice 3 gate error; (5) break schema (empty `outputs`) → schema error. |
|
||||
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports recipe catalog + existing catalogs OK. |
|
||||
|
||||
No dedicated pytest module in-repo today (same as NEO-50 / NEO-57).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Catalog growth** | Slice 3 gate requires **exactly** the eight frozen ids; expanding the roster needs a follow-up issue to change `PROTOTYPE_SLICE3_RECIPE_IDS`. | **adopted** |
|
||||
| **C# vs Python drift** | None until NEO-66; document “keep in sync” constant names in both places when server load lands. | **adopted** |
|
||||
| **Branch carries E3M2 decomposition prep** | Commit decomposition doc deltas (freeze table, backlog, README) with implementation or as first doc batch on this branch. | **done** — prep landed in prior commits; catalog + CI in this story. |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# NEO-66 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-66 |
|
||||
| **Title** | E3.M2: Server recipe catalog load (fail-fast) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-66/e3m2-server-recipe-catalog-load-fail-fast |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-02** |
|
||||
| **Branch** | `NEO-66-e3m2-server-recipe-catalog-load-fail-fast` |
|
||||
| **Precursor** | [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) — frozen eight-recipe catalog + CI gates (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket ([NEO-67](https://linear.app/neon-sprawl/issue/NEO-67)) |
|
||||
| **Blocks** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) — recipe definition registry + DI |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **NEO-66 vs NEO-67 (DI scope)** | Ship `IRecipeDefinitionRegistry` on this story, or only load + `RecipeDefinitionCatalog`? | **Strict split** — NEO-66: loader + `RecipeDefinitionCatalog` + eager fail-fast boot; [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) adds injectable `IRecipeDefinitionRegistry` (E3M2-03). Rationale: E3M2 backlog separates E3M2-02/03; mirrors NEO-51/NEO-52 and NEO-58/NEO-59. | **User:** `RecipeDefinitionCatalog` singleton only (kickoff). |
|
||||
| **Cross-check dependencies** | How to validate I/O `itemId` and `requiredSkillId` at startup? | **Pass ids from `ItemDefinitionCatalog` and `SkillDefinitionCatalog`** in the DI factory (items + skills load first; no second disk read). Rationale: NEO-58 / NEO-46 load-time precedent. | **Adopted** — NEO-58 precedent; no separate question. |
|
||||
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + Slice 3 recipe gate). | **Adopted** — NEO-51 / NEO-34 precedent. |
|
||||
| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` / `ResourceNodeCatalogLoaderTests`. | **Adopted** — NEO-51 precedent. |
|
||||
| **Lookup API on NEO-66** | How does “catalog resolves `RecipeDef` and I/O rows by recipe `id`” land without NEO-67? | **`RecipeDefinitionCatalog.ById`** (read-only map) + `TryGetRecipe(string id, out RecipeDefRow row)`; inputs/outputs embedded on `RecipeDefRow`. | **Adopted** |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** On host startup, load all `content/recipes/*_recipes.json` catalogs (validated in CI per [NEO-65](NEO-65-implementation-plan.md)), build an in-memory **`RecipeDefinitionCatalog`**, cross-check every I/O **`itemId`** against the already-loaded item catalog and every **`requiredSkillId`** against the skill catalog, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype Slice 3 gate.
|
||||
|
||||
**In scope (from Linear + [E3M2-02](E3M2-prototype-backlog.md#e3m2-02--server-recipe-catalog-load-fail-fast)):**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Crafting/`.
|
||||
- Configurable recipes directory and schema paths (`ContentPathsOptions` extension).
|
||||
- CI-parity validation: `schemaVersion`, row schemas (`recipe-def` + `$ref` to `recipe-io-row`), duplicate `id`, cross-check item + skill ids, Slice 3 eight-id + `recipeKind` + `refine` gate.
|
||||
- DI registration of **`RecipeDefinitionCatalog`** singleton (factory depends on `ItemDefinitionCatalog` + `SkillDefinitionCatalog`); eager resolve in `Program.cs` after item + skill catalogs.
|
||||
- Unit + host startup tests (AAA).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Per-player craft state, HTTP, inventory mutation.
|
||||
- **`IRecipeDefinitionRegistry`** / `RecipeDefinitionRegistry` (**NEO-67**, E3M2-03).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Server refuses boot when recipe catalog invalid (missing dir, no `*_recipes.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, unknown `itemId` / `requiredSkillId`, Slice 3 gate failure).
|
||||
- [x] `RecipeDefinitionCatalog` resolves prototype rows by recipe `id` (e.g. `refine_scrap_standard`, `make_field_stim_mk0`) including embedded input/output rows.
|
||||
- [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 **recipe count**, **catalog directory**, and **file count** (Information).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`Game/Crafting/` row DTOs** — `RecipeIoRow` (`ItemId`, `Quantity`); `RecipeDefRow` (`Id`, `DisplayName`, `RecipeKind`, `RequiredSkillId`, `Inputs`, `Outputs`, optional `RequiredSkillLevel`, `StationTag`).
|
||||
|
||||
2. **`RecipeDefinitionCatalogLoader.Load(recipesDirectory, recipeDefSchemaPath, recipeIoRowSchemaPath, knownItemIds, knownSkillIds, logger)`** — single-pass load mirroring `validate_content.py` `_validate_recipe_catalogs` + `_prototype_slice3_recipe_gate`:
|
||||
- Enumerate `*_recipes.json` (top-level only), ordered by path.
|
||||
- Build **JsonSchema.Net** evaluator with both schemas registered by `$id` (same `$ref` URLs as Python: `https://neon-sprawl.local/schemas/recipe-def.json` + `recipe-io-row.json`).
|
||||
- Per file: parse JSON; require `schemaVersion == 1` and top-level `recipes` array.
|
||||
- Per row: validate against composite recipe-def schema; on clean rows track `id`, `recipeKind`, `requiredSkillId`; build `RecipeDefRow` with parsed I/O arrays; reject duplicate `id` across files.
|
||||
- Post-schema cross-checks per row: every I/O `itemId` ∈ `knownItemIds`; `requiredSkillId` ∈ `knownSkillIds`.
|
||||
- Run **`PrototypeSlice3RecipeCatalogRules.TryGetSlice3GateError`** (mirror `PROTOTYPE_SLICE3_RECIPE_IDS`, `PROTOTYPE_SLICE3_REFINE_SKILL_ID`, `process` + `make` coverage in `validate_content.py`).
|
||||
- Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Recipe catalog validation failed:`.
|
||||
|
||||
3. **`RecipeDefinitionCatalog`** — immutable snapshot: `RecipesDirectory`, `ById`, `DistinctRecipeCount`, `CatalogJsonFileCount`; `TryGetRecipe(id, out RecipeDefRow?)` for lookups (I/O rows on the row DTO).
|
||||
|
||||
4. **`RecipeCatalogPathResolution`** — `TryDiscoverRecipesDirectory`, `ResolveRecipesDirectory`, `ResolveRecipeDefSchemaPath`, `ResolveRecipeIoRowSchemaPath` (parent `content/schemas/` layout when unset).
|
||||
|
||||
5. **`PrototypeSlice3RecipeCatalogRules`** — frozen eight recipe ids + refine skill id + `recipeKind` coverage gate; sync comments to `scripts/validate_content.py` `PROTOTYPE_SLICE3_RECIPE_IDS` / `PROTOTYPE_SLICE3_REFINE_SKILL_ID` / `_prototype_slice3_recipe_gate`.
|
||||
|
||||
6. **`RecipeCatalogServiceCollectionExtensions.AddRecipeDefinitionCatalog`** — bind `ContentPathsOptions`; register **`RecipeDefinitionCatalog`** singleton factory that resolves paths, gets `ItemDefinitionCatalog.ById.Keys` and `SkillDefinitionCatalog.ById.Keys` as hash sets, calls loader.
|
||||
|
||||
7. **`Program.cs`** — `AddRecipeDefinitionCatalog(configuration)` after `AddItemDefinitionCatalog` and `AddSkillDefinitionCatalog`; after `Build()`, `GetRequiredService<RecipeDefinitionCatalog>()` alongside existing catalog eager resolves.
|
||||
|
||||
8. **Tests** — temp-dir fixtures copying repo `recipe-def.schema.json` + `recipe-io-row.schema.json`; valid eight-recipe JSON matching [prototype_recipes.json](../../content/recipes/prototype_recipes.json); stub item + skill id sets for cross-check; negative cases (duplicate id, unknown item/skill, wrong Slice 3 roster, missing `process`/`make`, bad `requiredSkillId`); `InMemoryWebApplicationFactory` / `WebApplicationFactory` host tests mirroring items.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeIoRow.cs` | Load-time recipe input/output row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefRow.cs` | Load-time `RecipeDef` row DTO with embedded I/O lists. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs` | In-memory catalog + `TryGetRecipe`. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs` | Disk I/O, schema validation ($ref), Slice 3 gate, item + skill cross-check. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/PrototypeSlice3RecipeCatalogRules.cs` | Frozen eight ids + refine skill + recipeKind gate (sync comment to Python). |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs` | `AddRecipeDefinitionCatalog` DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeCatalogTestPaths.cs` | Repo `content/recipes` + schema discovery for tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. |
|
||||
| `docs/plans/NEO-66-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `RecipesDirectory`, `RecipeDefSchemaPath`, `RecipeIoRowSchemaPath` under `Content` section. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `RecipeDefinitionCatalog` at startup (after item + skill catalogs). |
|
||||
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:RecipesDirectory` / schema path keys for override documentation (if other catalogs document keys here). |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:RecipesDirectory` to discovered repo path so existing tests keep booting. |
|
||||
| `server/README.md` | New **Recipe catalog (`content/recipes`, NEO-66)** section (config keys, discovery, fail-fast, Slice 3 parity with CI). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-66 server load when implementation completes. |
|
||||
| `docs/plans/E3M2-prototype-backlog.md` | E3M2-02 checkboxes when implementation completes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs` | **Unit:** valid eight-recipe prototype catalog loads; `recipes` not array; schema violation (incl. bad I/O row); duplicate `id` across files; wrong/missing Slice 3 ids; missing `process` or `make`; `requiredSkillId` ≠ `refine`; unknown `itemId` in inputs/outputs; unknown `requiredSkillId`; `schemaVersion` ≠ 1; missing dir/schema; no `*_recipes.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `RecipeDefinitionCatalog` count 8; `WebApplicationFactory` with empty recipes dir fails startup with actionable message. |
|
||||
|
||||
**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA loader/host tests above plus Bruno `recipe-catalog/Health after recipe catalog load.bru` (same pattern as NEO-51).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Schema `$ref` in C#** | Register both recipe schemas by `$id` in JsonSchema.Net before evaluating rows (mirror Python `_recipe_def_validator`). | **adopted** |
|
||||
| **Constants drift vs Python** | Comment on `PrototypeSlice3RecipeCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_SLICE3_RECIPE_IDS` / `_prototype_slice3_recipe_gate`. | **adopted** |
|
||||
| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:RecipesDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** |
|
||||
| **Registry consumers** | Craft engine / HTTP use `IRecipeDefinitionRegistry` in NEO-67+; this story only exposes `RecipeDefinitionCatalog` for boot + direct test lookup. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`RecipeDefinitionCatalog` singleton only** — defer `IRecipeDefinitionRegistry` to NEO-67 (user confirmed kickoff).
|
||||
- **Item + skill cross-check** uses `ItemDefinitionCatalog` and `SkillDefinitionCatalog` keys passed into loader at DI registration time.
|
||||
- **In-process validation** + **loader and host boot tests** per NEO-51 / NEO-58 precedent.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# NEO-67 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-67 |
|
||||
| **Title** | E3.M2: Recipe definition registry + DI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-67/e3m2-recipe-definition-registry-di |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-03** |
|
||||
| **Branch** | `NEO-67-recipe-definition-registry-di` |
|
||||
| **Precursor** | [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) — fail-fast `RecipeDefinitionCatalog` load (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) / [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — thin registry adapter over startup catalog + DI; strict split after NEO-66 loader |
|
||||
| **Blocks** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — GET world recipe-definitions; [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — CraftResult engine |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Enumeration API** | Include `GetDefinitionsInIdOrder()` now? | **Yes** — mirror `IItemDefinitionRegistry` / `ISkillDefinitionRegistry`; [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) `GET /game/world/recipe-definitions` will need ordered defs without reaching into `RecipeDefinitionCatalog`. | **User:** include enumeration. |
|
||||
| **Lookup naming** | `TryGetDefinition` vs `TryGetRecipe`? | **`TryGetDefinition(string? recipeId, …)`** — same naming as item/skill registries; catalog keeps `TryGetRecipe` for direct catalog access. | **User:** `TryGetDefinition`. |
|
||||
| **NEO-66 vs NEO-67 split** | Combine registry with catalog load? | **Strict split** (decided on NEO-66 kickoff) — NEO-66 ships loader + catalog; this story adds injectable registry only. | **Adopted** — NEO-66 plan. |
|
||||
| **Program.cs eager resolve** | Eager-resolve `IRecipeDefinitionRegistry` at boot? | **Omit** — `RecipeDefinitionCatalog` is already eager-resolved in `Program.cs` (NEO-66); registry is a thin adapter (NEO-52 default). | **Adopted** |
|
||||
| **I/O + `recipeKind` exposure** | Separate TryGet methods for inputs/outputs? | **No** — embedded on `RecipeDefRow` returned by `TryGetDefinition`; backlog tests cover metadata + `recipeKind` via one lookup (unlike node+yield on NEO-59). | **Adopted** |
|
||||
| **Runtime validation** | Re-validate recipes in registry? | **No** — catalog load is fail-fast (NEO-66); registry delegates to loaded rows only. | **Adopted** |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Provide **`IRecipeDefinitionRegistry`** backed by the startup-loaded **`RecipeDefinitionCatalog`**: resolve by stable **`recipeId`**, enumerate definitions in id order, expose prototype metadata (`displayName`, `recipeKind`, `requiredSkillId`, embedded `inputs` / `outputs`). Register in DI so **NEO-68+** (HTTP), **NEO-69** (craft engine), and future callers depend on the interface instead of the catalog type.
|
||||
|
||||
**In scope (from Linear + [E3M2-03](E3M2-prototype-backlog.md#e3m2-03--recipe-definition-registry--di)):**
|
||||
|
||||
- `RecipeDefinitionRegistry` thin adapter over `RecipeDefinitionCatalog`.
|
||||
- DI registration alongside existing catalog singleton.
|
||||
- Unit tests (AAA): known prototype id lookup + `recipeKind` + I/O metadata; unknown `recipeId` returns false without throwing; enumeration order; host resolves registry from DI.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- HTTP ([NEO-68](https://linear.app/neon-sprawl/issue/NEO-68)).
|
||||
- Persistence, per-player craft state, inventory mutation ([NEO-69+](E3M2-prototype-backlog.md)).
|
||||
- Changing loader, Slice 3 gate, or catalog load semantics (NEO-66).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] DI resolves `IRecipeDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
|
||||
- [x] Unit tests (AAA): lookup for known prototype `recipeId` (e.g. `refine_scrap_standard`, `make_field_stim_mk0`) with expected `recipeKind`, `requiredSkillId`, and input/output rows.
|
||||
- [x] Unit tests (AAA): unknown `recipeId` returns false / absent without throwing.
|
||||
- [x] `GetDefinitionsInIdOrder` returns all eight loaded rows ordered by `id` (ordinal).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`IRecipeDefinitionRegistry`** — mirror [`IItemDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Items/IItemDefinitionRegistry.cs):
|
||||
- `TryGetDefinition(string? recipeId, [NotNullWhen(true)] out RecipeDefRow? definition)` — null and unknown ids return false without throwing.
|
||||
- `GetDefinitionsInIdOrder()` — all rows ordered by `RecipeDefRow.Id` (ordinal).
|
||||
- XML remarks: craft engine / inventory callers (NEO-69+) should use this interface; HTTP read model (NEO-68) should not reach into `RecipeDefinitionCatalog`.
|
||||
|
||||
2. **`RecipeDefinitionRegistry`** — primary-constructor adapter over `RecipeDefinitionCatalog` (same pattern as [`ItemDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Items/ItemDefinitionRegistry.cs)).
|
||||
|
||||
3. **DI** — extend [`AddRecipeDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs) to register `IRecipeDefinitionRegistry` → `RecipeDefinitionRegistry` after catalog registration. No change to `Program.cs` eager-resolve (catalog only).
|
||||
|
||||
4. **Comments** — update `RecipeDefinitionCatalog` summary to point at `IRecipeDefinitionRegistry`; update [server README](../../server/README.md) recipe-catalog section (currently says “until NEO-67”).
|
||||
|
||||
5. **Tests** — new `RecipeDefinitionRegistryTests.cs` mirroring [`ItemDefinitionRegistryTests`](../../server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs): in-memory catalog helper, loader-backed prototype fixture via `RecipeCatalogTestPaths` + item/skill id stubs, host DI test.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/IRecipeDefinitionRegistry.cs` | Public contract: lookup, enumerate, remarks for NEO-68/69 callers. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionRegistry.cs` | Singleton adapter over `RecipeDefinitionCatalog`. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionRegistryTests.cs` | AAA unit + host DI tests (mirror `ItemDefinitionRegistryTests`). |
|
||||
| `docs/plans/NEO-67-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs` | Register `IRecipeDefinitionRegistry` → `RecipeDefinitionRegistry` after catalog singleton. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs` | Tighten summary comment to reference `IRecipeDefinitionRegistry` (replace “NEO-67 adds…” placeholder). |
|
||||
| `server/README.md` | Recipe catalog section: document `IRecipeDefinitionRegistry` as preferred lookup surface. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-67 registry when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` for `refine_scrap_standard` returns true with `RecipeKind` `process`, `RequiredSkillId` `refine`, expected input/output `ItemId`/`Quantity`; `make_field_stim_mk0` returns `make` with multi-input rows; null and unknown id return false without throw; `GetDefinitionsInIdOrder` count (8) and ordinal order for prototype fixture; loader-backed prototype fixture matches catalog. **Host:** `InMemoryWebApplicationFactory` resolves `IRecipeDefinitionRegistry` and finds `refine_scrap_standard`. |
|
||||
|
||||
**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA unit/host tests (same as NEO-52 / NEO-59).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Catalog vs registry for game code** | New callers inject `IRecipeDefinitionRegistry`; leave `RecipeDefinitionCatalog` eager-resolve in `Program.cs` for fail-fast only. | **adopted** |
|
||||
| **Duplicate API on catalog** | Keep `RecipeDefinitionCatalog.TryGetRecipe` for loader/tests; registry is the game-facing surface (NEO-52 precedent). | **adopted** |
|
||||
| **NEO-68 HTTP projection** | API layer maps `RecipeDefRow` fields to DTOs via registry enumeration + lookup; no combined read model in NEO-67. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`GetDefinitionsInIdOrder()` included** — enumeration for NEO-68 HTTP without catalog coupling (user confirmed).
|
||||
- **`TryGetDefinition` naming** — consistent with item/skill registries; parameter is `recipeId` (user confirmed).
|
||||
- **No `Program.cs` registry eager-resolve** — catalog fail-fast only (NEO-52 pattern).
|
||||
- **I/O and `recipeKind` on `RecipeDefRow`** — single lookup surface; no separate TryGet methods.
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
# NEO-68 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-68 |
|
||||
| **Title** | E3.M2: GET world recipe-definitions + Bruno |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-68/e3m2-get-world-recipe-definitions-bruno |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-04** |
|
||||
| **Branch** | `NEO-68-get-world-recipe-definitions-bruno` |
|
||||
| **Precursor** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) — `IRecipeDefinitionRegistry` + DI (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — `GET /game/world/item-definitions` |
|
||||
| **Client counterpart** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) — E3S5-03 craft UI + recipe list (blocked by this issue; server-only in this story) |
|
||||
| **Blocks** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) (Linear relations) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **JSON row fields** | Expose required six + I/O only vs optional nullables? | **Required six + `inputs`/`outputs` only** — prototype rows omit `requiredSkillLevel` / `stationTag`; mirror NEO-53 “loaded fields only”. | **User:** required six + I/O only. |
|
||||
| **Response envelope** | Array property name? | **`recipes`** (with `schemaVersion` **1**) — parallel to NEO-36 `skills` / NEO-53 `items`; matches content vocabulary. | **Adopted** — no objection; follows precedent. |
|
||||
| **Route path** | `/game/world/…` vs `/game/content/…`? | **`GET /game/world/recipe-definitions`** — E3M2 backlog + parallel to item/skill defs. | **Adopted** — backlog. |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-68.md`? | **Yes** — player-visible HTTP; NEO-53 pattern. | **User:** yes. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose a stable, read-only JSON endpoint that returns all eight loaded prototype recipe definitions for Bruno, manual QA, client preview ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)), and tooling — backed by **`IRecipeDefinitionRegistry`** without duplicating catalog truth.
|
||||
|
||||
**In scope (from Linear + [E3M2-04](E3M2-prototype-backlog.md#e3m2-04--get-world-recipe-definitions)):**
|
||||
|
||||
- **`GET /game/world/recipe-definitions`** with versioned envelope (`schemaVersion` **1**, **`recipes`** array).
|
||||
- Row fields: **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, **`inputs`**, **`outputs`** (I/O rows: **`itemId`**, **`quantity`**; camelCase JSON).
|
||||
- Recipes ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
|
||||
- Bruno folder `bruno/neon-sprawl-server/recipe-definitions/`.
|
||||
- `docs/manual-qa/NEO-68.md`.
|
||||
- `server/README.md` section.
|
||||
- API integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Per-player craft history or cooldown state.
|
||||
- Optional schema fields (`requiredSkillLevel`, `stationTag`) until content ships them.
|
||||
- Craft execution ([NEO-69](https://linear.app/neon-sprawl/issue/NEO-69)).
|
||||
- Godot client ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] GET returns all **eight** prototype recipes with **`schemaVersion`** **1** and input/output summary fields needed for QA.
|
||||
- [x] Bruno happy path documented and runnable against dev server.
|
||||
- [x] Automated API tests (AAA) assert eight ids, id order, and spot-check metadata + I/O rows.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`GET /game/world/recipe-definitions`** — parameterless read; inject **`IRecipeDefinitionRegistry`** only (not `RecipeDefinitionCatalog`).
|
||||
|
||||
2. **Response shape:** Top-level **`schemaVersion`** (`RecipeDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`recipes`** array. Each row maps from **`RecipeDefRow`**: **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, **`inputs`**, **`outputs`**. Do **not** emit optional null fields on this contract.
|
||||
|
||||
3. **I/O projection:** Map **`RecipeIoRow`** → JSON with **`itemId`**, **`quantity`** (same keys as content schema).
|
||||
|
||||
4. **Ordering:** Build list from **`registry.GetDefinitionsInIdOrder()`** (already ordinal by id). Tests assert exact id sequence (frozen eight from [E3.M2 snapshot](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md#prototype-slice-3-freeze-e3m2-01)):
|
||||
|
||||
`make_armor_quick`, `make_contract_token`, `make_field_stim_batch`, `make_field_stim_mk0`, `make_prototype_armor`, `make_survey_drone_kit`, `refine_scrap_efficient`, `refine_scrap_standard`.
|
||||
|
||||
5. **Implementation:** New **`RecipeDefinitionsWorldApi`** + **`RecipeDefinitionsListDtos.cs`** in `Game/Crafting/` (mirror [`ItemDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Items/ItemDefinitionsWorldApi.cs)). Wire **`app.MapRecipeDefinitionsWorldApi()`** from **`Program.cs`** next to **`MapItemDefinitionsWorldApi()`**.
|
||||
|
||||
6. **Bruno:** `bruno/neon-sprawl-server/recipe-definitions/` with **`Get recipe definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`recipes.length === 8`**, ascending id order, **exact frozen-eight registry sequence** (mirror NEO-53 item-definitions Bruno), spot-check **`refine_scrap_standard`** (`recipeKind` `process`, inputs/outputs) and **`make_field_stim_mk0`** (`recipeKind` `make`, multi-input).
|
||||
|
||||
7. **Docs:** `docs/manual-qa/NEO-68.md` (mirror NEO-53). Update **`server/README.md`** recipe section with GET + curl. Update [E3_M2](E3_M2_RefinementAndRecipeExecution.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M2 row when landed.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsListDtos.cs` | Versioned response + row + I/O DTOs for JSON serialization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, eight ids in order, spot-check rows + I/O. |
|
||||
| `bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru` | Manual / Bruno verification against dev server. |
|
||||
| `bruno/neon-sprawl-server/recipe-definitions/folder.bru` | Bruno folder metadata (match `item-definitions`). |
|
||||
| `docs/manual-qa/NEO-68.md` | Checklist: start server, curl GET, confirm eight ids + spot-check I/O. |
|
||||
| `docs/plans/NEO-68-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapRecipeDefinitionsWorldApi()` alongside other game world definition APIs. |
|
||||
| `server/README.md` | Document `GET /game/world/recipe-definitions`, curl example, links to plan/manual QA/Bruno. |
|
||||
| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | **Related implementation slices** — HTTP read model bullet (NEO-68). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-68 HTTP projection when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs` | **Integration:** `GET /game/world/recipe-definitions` via **`InMemoryWebApplicationFactory`**; **`ReadFromJsonAsync`**; assert **`schemaVersion` 1**; **`recipes`** count **8**; ordered **`id`** list equals **`FrozenEightInIdOrder`** (shared constant referenced by manual QA / Bruno); spot-check **`refine_scrap_standard`** and **`make_field_stim_mk0`**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
Bruno scripts complement automated tests for manual dev-server verification.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Linear blockedBy NEO-67** | Branch from **`main`** where NEO-67 is merged (confirmed at kickoff). | **adopted** |
|
||||
| **Optional fields later** | When content adds `requiredSkillLevel` / `stationTag`, bump **`schemaVersion`** or extend v1 with documented null semantics in a follow-up issue. | **deferred** |
|
||||
| **Client not in this story** | Cross-link **NEO-74** in plan; Bruno + manual QA are not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Required six + I/O only** — no optional null fields on v1 contract (user confirmed).
|
||||
- **`recipes` array key** — consistent with entity naming in sibling world-definition endpoints.
|
||||
- **`docs/manual-qa/NEO-68.md` included** (user confirmed).
|
||||
- **Registry-only injection** — HTTP layer depends on **`IRecipeDefinitionRegistry`**, not **`RecipeDefinitionCatalog`**.
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
# NEO-69 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-69 |
|
||||
| **Title** | E3.M2: CraftResult engine (inputs, outputs, refine XP) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-69/e3m2-craftresult-engine-inputs-outputs-refine-xp |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-05** |
|
||||
| **Branch** | `NEO-69-craftresult-engine-inputs-outputs-refine-xp` |
|
||||
| **Precursors** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) — `IRecipeDefinitionRegistry` + DI (**Done** on `main`); [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) — `RefineActivitySkillXpGrant` (**Done**) |
|
||||
| **Pattern** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — static `*Operations` orchestrator + subsystem reason codes; [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) — engine-only scope (HTTP in follow-up) |
|
||||
| **Client counterpart** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) — E3S5-03 craft UI + recipe list (blocked by [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) craft HTTP; server-only in this story) |
|
||||
| **Blocks** | [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — craft POST + Bruno spine; [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) — telemetry hook sites |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Output-full deny strategy** | Pre-flight output capacity vs consume inputs then rollback? | **Pre-flight** — simulate all scaled output adds on a cloned snapshot before any input removal; deny **`inventory_full`** with zero mutations (cleaner for multi-input recipes than compensating rollback). | **User:** pre-flight. |
|
||||
| **Quantity in engine API** | Include `quantity` in `TryCraft` in NEO-69? | **Yes** — E3M2-05 lists **`invalid_quantity`**; [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) POST will pass `recipeId` + optional quantity default **1**; engine owns validation now. | **Adopted** — backlog + NEO-70 wiring. |
|
||||
| **Craft vs inventory reason codes** | Surface `insufficient_quantity` or craft-layer `insufficient_materials`? | **Craft-layer `insufficient_materials`** — E3M2 backlog stable deny vocabulary; map from inventory checks at orchestration boundary (mirror gather passthrough for `inventory_full` only). | **Adopted** — backlog. |
|
||||
| **XP failure after commit** | Roll back inventory on XP deny? | **Yes** — compensating remove of outputs + re-add inputs (mirror NEO-62 gather rollback on XP failure). | **Adopted** — NEO-62 precedent. |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-69.md`? | **No** — engine-only like NEO-62; automated tests + [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) manual QA cover player-visible verification. | **Adopted** — NEO-62 pattern. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Single server operation resolving **`CraftResult`**: validate recipe + inventory, atomically remove inputs and add outputs via **`PlayerInventoryOperations`**, award **`refine`** XP via **`RefineActivitySkillXpGrant`** on success — engine-only, ready for craft HTTP (NEO-70).
|
||||
|
||||
**In scope (from Linear + [E3M2-05](E3M2-prototype-backlog.md#e3m2-05--craftresult-engine-consume-inputs-grant-outputs-refine-xp)):**
|
||||
|
||||
- **`CraftOperations`** / **`CraftResult`** types in `server/NeonSprawl.Server/Game/Crafting/`.
|
||||
- Stable deny reason codes: **`unknown_recipe`**, **`insufficient_materials`**, **`inventory_full`**, **`invalid_quantity`** (+ store-missing craft-layer codes as needed).
|
||||
- Pre-flight output capacity before input removal (kickoff decision).
|
||||
- Compensating inventory rollback when XP grant fails after successful input/output mutations (NEO-62 pattern).
|
||||
- Unit tests (AAA) via **`InMemoryWebApplicationFactory`** with real catalogs and stores; **`RefineActivitySkillXpGrant`** invoked on success path.
|
||||
- Document **`CraftResult`** shape in module doc (server-internal until promoted to wire in NEO-70).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Craft HTTP, Bruno, `docs/manual-qa/` ([NEO-70](https://linear.app/neon-sprawl/issue/NEO-70)).
|
||||
- Client HUD ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)).
|
||||
- Skill level gates, station checks, craft failure RNG.
|
||||
- Telemetry hook sites ([NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Successful craft removes configured inputs and adds outputs deterministically.
|
||||
- [x] Insufficient materials returns stable **`reasonCode`** with **no** inventory change.
|
||||
- [x] Full bag on output returns stable **`reasonCode`**; inputs are **not** consumed.
|
||||
- [x] **`refine`** skill XP increases by **10** on success (NEO-42).
|
||||
- [x] **`make_prototype_armor`** respects equipment slot rules from E3.M3 when bag is full but equip slot empty.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Entry point:** **`CraftOperations.TryCraft(...)`** — static orchestrator accepting `playerId`, `recipeId`, `quantity`, and dependencies:
|
||||
- **`IRecipeDefinitionRegistry`**
|
||||
- **`IItemDefinitionRegistry`** + **`IPlayerInventoryStore`**
|
||||
- **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, **`ISkillLevelCurve`**, **`PerkUnlockEngine`**
|
||||
|
||||
2. **Validate quantity:** `quantity <= 0` → **`Denied`** + **`CraftReasonCodes.InvalidQuantity`** (no writes).
|
||||
|
||||
3. **Resolve recipe:** **`registry.TryGetDefinition(recipeId.Trim(), out def)`** — miss → **`unknown_recipe`**.
|
||||
|
||||
4. **Pre-flight inputs (read-only):** For each input row, required amount = **`row.Quantity * quantity`**. Sum held quantity in the correct container (bag vs equipment via catalog **`inventorySlotKind`**) from current snapshot. Any shortfall → **`insufficient_materials`** without mutations.
|
||||
|
||||
5. **Pre-flight outputs (read-only, kickoff decision):** Clone current snapshot; **simulate removing all scaled inputs** on the clone, then simulate each scaled output add. If any output cannot be fully placed → **`inventory_full`** without mutations. Implementation: **`TrySimulateRemoveStack`** + **`TrySimulateAddStack`** on `PlayerInventoryOperations`.
|
||||
|
||||
6. **Commit mutations (only after steps 4–5 pass):**
|
||||
- **Remove inputs:** foreach input row, **`TryRemoveStack`** scaled quantity. (Should succeed given pre-flight; defensive deny **`insufficient_materials`** if not.)
|
||||
- **Add outputs:** foreach output row, **`TryAddStack`** scaled quantity. (Should succeed given pre-flight; defensive deny + compensating re-add inputs if not.)
|
||||
- **XP:** **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**. On failure (store missing / skill deny): compensating rollback — remove granted outputs, re-add removed inputs; return **`Denied`** + **`progression_store_missing`** or passthrough skill **`reasonCode`**.
|
||||
|
||||
7. **Success envelope — `CraftResult`:**
|
||||
- **`Success = true`**, **`ReasonCode = null`**
|
||||
- **`InputsConsumed`**: list of `{ itemId, quantity }` applied (scaled).
|
||||
- **`OutputsGranted`**: list of `{ itemId, quantity }` applied (scaled).
|
||||
- **`XpGrantSummary`**: compact summary (`refine`, **10**, `activity`) for tests / future wire.
|
||||
|
||||
8. **Reason codes (`CraftReasonCodes`):**
|
||||
- **`unknown_recipe`**, **`insufficient_materials`**, **`invalid_quantity`** — craft-layer.
|
||||
- **`inventory_full`** — passthrough literal (same string as NEO-54).
|
||||
- **`progression_store_missing`**, **`inventory_store_missing`** — craft-layer store-missing (mirror gather).
|
||||
|
||||
9. **Equipment slot AC (`make_prototype_armor`):** No special-case in craft engine — **`prototype_armor_shell`** routes to equipment via catalog metadata in pre-flight + **`TryAddStack`**. Test: fill all **24** bag slots, leave equipment empty, craft succeeds and places armor in **`equipmentSlots[0]`**.
|
||||
|
||||
10. **Primary demo recipe for happy path tests:** **`refine_scrap_standard`** (5× **`scrap_metal_bulk`** → 1× **`refined_plate_stock`**) and/or **`make_field_stim_mk0`**.
|
||||
|
||||
11. **Docs (on land):** Update [E3_M2](E3_M2_RefinementAndRecipeExecution.md) **Implementation snapshot** + **`CraftResult`** contract note; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M2 row; E3M2-05 checkboxes in [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md); brief **`server/README.md`** craft-engine subsection (engine-only; HTTP in NEO-70). Cross-link **NEO-74** / full-stack note per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs` | Stable craft deny codes (craft-layer + passthrough). |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs` | One consumed input or granted output row in success envelope. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs` | Compact refine XP summary on successful craft. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftResult.cs` | Success/deny envelope: inputs consumed, outputs granted, reasonCode, xp summary. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Orchestrator: validate → pre-flight → remove inputs → add outputs → XP; rollback on XP failure. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs` | AAA: success, insufficient_materials, inventory_full (bag), make_prototype_armor + full bag, unknown_recipe, invalid_quantity, XP grant + rollback path. |
|
||||
| `docs/plans/NEO-69-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs` | Add snapshot-based **`CanPlaceStack`** (or simulate helper) for craft output pre-flight without mutation. |
|
||||
| `server/README.md` | Document craft engine (`CraftOperations`, reason codes, refine XP hook); note HTTP deferred to NEO-70. |
|
||||
| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | **Implementation snapshot** — craft engine + **`CraftResult`** field table. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-69 craft engine when landed. |
|
||||
| `docs/plans/E3M2-prototype-backlog.md` | E3M2-05 acceptance checkboxes + landed note when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs` | **Success:** seed **`scrap_metal_bulk`**, craft **`refine_scrap_standard`**, assert inputs removed, **`refined_plate_stock`** granted, **`refine`** XP +10, envelope fields. **Insufficient materials:** empty inventory → **`insufficient_materials`**, snapshot unchanged. **Inventory full:** fill bag so output cannot fit → **`inventory_full`**, inputs unchanged. **Equipment routing:** full bag + empty equip slot → **`make_prototype_armor`** succeeds, armor in equipment slot. **Unknown recipe** → **`unknown_recipe`**. **Invalid quantity** (0 or negative) → **`invalid_quantity`**. **XP path:** assert **`RefineActivitySkillXpGrant`** effect via progression store (mirror **`GatherOperationsTests`** / **`RefineActivitySkillXpGrantTests`**). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs` | **Optional:** one test for new **`CanPlaceStack`** helper if added as public surface (bag full vs equipment route). |
|
||||
|
||||
No Bruno or manual QA in this story — [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) owns player-visible HTTP verification.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Pre-flight simulate vs rollback** | User chose **pre-flight** at kickoff. | **adopted** |
|
||||
| **Multi-output pre-flight order** | Simulate adds in recipe **`outputs`** order on one cloned snapshot (order should not matter for prototype fixed outputs; document if later recipes introduce interactions). | **deferred** |
|
||||
| **NEO-71 telemetry hooks** | Do not add `item_crafted` / `craft_failed` comments in NEO-69 — NEO-71 owns hook placement. | **adopted** |
|
||||
| **Client not in this story** | Cross-link **NEO-74**; Bruno-only verification is not prototype-complete. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Pre-flight output capacity** before any input removal (user confirmed).
|
||||
- **`quantity` parameter** on **`TryCraft`** with **`invalid_quantity`** deny in this story.
|
||||
- **Craft-layer `insufficient_materials`** at orchestration boundary (not raw **`insufficient_quantity`** on craft deny).
|
||||
- **XP failure rollback** — remove outputs, restore inputs (NEO-62 gather precedent).
|
||||
- **No `docs/manual-qa/NEO-69.md`** — engine-only; NEO-70 manual QA covers HTTP.
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
# NEO-70 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-70 |
|
||||
| **Title** | E3.M2: Craft HTTP + Bruno gather→refine→make spine |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-70/e3m2-craft-http-bruno-gatherrefinemake-spine |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-06** |
|
||||
| **Branch** | `NEO-70-craft-http-bruno-gather-refine-make-spine` |
|
||||
| **Precursor** | [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — `CraftOperations.TryCraft` + `CraftResult` (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) — `POST /game/players/{id}/inventory` (known-player gate, schemaVersion, 200 + structured deny) |
|
||||
| **Client counterpart** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) — E3S5-03 craft UI + recipe list (blocked by this issue; server-only in this story) |
|
||||
| **Blocks** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI HTTP dependency |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **POST response envelope** | Promote `CraftResult` 1:1 vs include inventory echo? | **CraftResult fields only** — `schemaVersion` **1**, `success`, `reasonCode`, `inputsConsumed`, `outputsGranted`, `xpGrantSummary`; separate GET inventory / skill-progression for Bruno spine (E3_M2 module doc wire table). | **User:** CraftResult fields only. |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-70.md`? | **Yes** — E3M2-06 backlog calls for manual QA when craft HTTP lands (NEO-68 pattern). | **User:** **No** — server-only story, no Godot; Bruno + README sufficient (NEO-55 pattern). |
|
||||
|
||||
No other blocking decisions — request shape (`recipeId` + optional `quantity` default **1**), route (`POST /game/players/{id}/craft`), known-player gate, and HTTP status mapping follow [E3M2 kickoff defaults](E3M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults) and NEO-55/NEO-38 precedent.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Versioned **`POST /game/players/{id}/craft`** wired to **`CraftOperations.TryCraft`**; Bruno documents the full prototype loop **gather → refine → make** through combat-relevant **`field_stim_mk0`** without inventory POST shortcuts.
|
||||
|
||||
**In scope (from Linear + [E3M2-06](E3M2-prototype-backlog.md#e3m2-06--craft-http--bruno-gatherrefinemake-spine)):**
|
||||
|
||||
- **`POST /game/players/{id}/craft`** + wire DTOs + API integration tests (AAA).
|
||||
- Known-player gate via **`IPositionStateStore`** (NEO-37 / NEO-55 pattern).
|
||||
- Bruno **`bruno/neon-sprawl-server/craft/`** — full spine + deny paths for major craft **`reasonCode`** values.
|
||||
- **`server/README.md`** craft HTTP subsection; module doc + alignment table updates.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Godot craft UI — **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**.
|
||||
- Station interact / world bench placement.
|
||||
- **`docs/manual-qa/NEO-70.md`** (kickoff decision — Bruno + README only).
|
||||
- Telemetry hook sites ([NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] POST success returns structured **`CraftResult`** wire JSON matching engine outcome.
|
||||
- [x] Bruno spine: gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** (or efficient path) → **`make_field_stim_mk0`** without manual inventory POST shortcuts.
|
||||
- [x] At least one deny path documented per major **`reasonCode`** (see Tests / Bruno below).
|
||||
- [x] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`POST /game/players/{id}/craft`** — trim **`id`**; **404** when empty/unknown position (NEO-55 gate).
|
||||
|
||||
2. **Request (`PlayerCraftRequest`, `schemaVersion` **1**):**
|
||||
- **`recipeId`** (required, non-empty after trim).
|
||||
- **`quantity`** (optional, default **1** when omitted or null) — passed to **`CraftOperations.TryCraft`**.
|
||||
|
||||
3. **Response (`CraftResponse`, `schemaVersion` **1**)** — promote NEO-69 envelope (kickoff decision):
|
||||
- **`success`**: `true` on commit, `false` on rule deny.
|
||||
- **`reasonCode`**: `null` on success; **`CraftReasonCodes`** string on deny.
|
||||
- **`inputsConsumed`**: `{ itemId, quantity }[]` (empty on deny).
|
||||
- **`outputsGranted`**: `{ itemId, quantity }[]` (empty on deny).
|
||||
- **`xpGrantSummary`**: `{ skillId, amount, sourceKind }` on success; omitted or `null` on deny.
|
||||
|
||||
4. **HTTP status mapping (NEO-55 / NEO-38 precedent):**
|
||||
- **400** — null body, **`schemaVersion`** mismatch, empty/missing **`recipeId`**.
|
||||
- **404** — unknown player (position gate) or **`CraftReasonCodes.InventoryStoreMissing`** / **`ProgressionStoreMissing`** when store missing (mirror inventory POST store-missing → 404).
|
||||
- **200** + JSON — engine deny with **`success: false`** + **`reasonCode`** + empty I/O lists.
|
||||
- **200** + JSON — success with full envelope.
|
||||
|
||||
5. **Implementation:** New **`PlayerCraftApi.cs`** + **`CraftWireDtos.cs`** in `Game/Crafting/` (map request → **`CraftOperations.TryCraft`**, map **`CraftResult`** → response). Wire **`app.MapPlayerCraftApi()`** from **`Program.cs`** after **`MapPlayerInventoryApi()`**.
|
||||
|
||||
6. **Bruno (`bruno/neon-sprawl-server/craft/`):**
|
||||
- **`Post craft gather refine make spine.bru`** — pre-request script (axios): move + interact on **four** prototype resource nodes to accumulate **≥11** **`scrap_metal_bulk`** without inventory POST (delta **5** + gamma **3** + beta **2** + alpha **1**); **`POST craft`** **`refine_scrap_efficient`** (10→2 **`refined_plate_stock`**); **`POST craft`** **`make_field_stim_mk0`**; assert **`success` true**; optional follow-up GET inventory + GET skill-progression asserts **`field_stim_mk0`** qty **≥1** and **`refine`** XP increased.
|
||||
- **`Post craft deny unknown recipe.bru`** — **`recipeId`** `unknown_recipe_id` → **`success` false**, **`unknown_recipe`**.
|
||||
- **`Post craft deny insufficient materials.bru`** — empty player (no gather seed) → **`insufficient_materials`**.
|
||||
- **`Post craft deny inventory full.bru`** — pre-request fills bag via inventory helper or repeated adds, then craft with output that cannot fit → **`inventory_full`** (may use **`make_prototype_armor`** or a make recipe after filling bag slots).
|
||||
- **`Post craft deny invalid quantity.bru`** — **`quantity: 0`** → **`invalid_quantity`**.
|
||||
- **`folder.bru`** — match sibling folders.
|
||||
|
||||
7. **Docs (on land):** Expand **`server/README.md`** craft section with POST route, request/response JSON, curl examples, reason codes. Update [E3_M2](E3_M2_RefinementAndRecipeExecution.md) **Implementation snapshot** — mark **`CraftResult`** promoted to wire; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M2 row; E3M2-06 checkboxes in [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md). Cross-link **NEO-74** per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftWireDtos.cs` | Versioned POST request/response + I/O row + XP summary JSON DTOs (`schemaVersion` 1). |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs` | `MapPlayerCraftApi` — POST route, position gate, engine dispatch, JSON mapping. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs` | AAA HTTP integration: success, denies, 400/404, schema mismatch. |
|
||||
| `bruno/neon-sprawl-server/craft/folder.bru` | Bruno folder metadata. |
|
||||
| `bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru` | Full gather→refine→make spine (no inventory POST). |
|
||||
| `bruno/neon-sprawl-server/craft/Post craft deny unknown recipe.bru` | Deny **`unknown_recipe`**. |
|
||||
| `bruno/neon-sprawl-server/craft/Post craft deny insufficient materials.bru` | Deny **`insufficient_materials`**. |
|
||||
| `bruno/neon-sprawl-server/craft/Post craft deny inventory full.bru` | Deny **`inventory_full`**. |
|
||||
| `bruno/neon-sprawl-server/craft/Post craft deny invalid quantity.bru` | Deny **`invalid_quantity`**. |
|
||||
| `docs/plans/NEO-70-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register **`MapPlayerCraftApi()`** alongside other player mutation APIs. |
|
||||
| `server/README.md` | Document craft POST route, request/response shapes, curl examples, reason codes; remove “HTTP deferred to NEO-70” note. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftResult.cs` | Update XML remark — wire promotion landed in NEO-70. |
|
||||
| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | **Implementation snapshot** — craft HTTP + wire **`CraftResult`** contract. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-70 craft POST when landed. |
|
||||
| `docs/plans/E3M2-prototype-backlog.md` | E3M2-06 acceptance checkboxes + landed note when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs` | **404** unknown player. **400** bad **`schemaVersion`**, empty **`recipeId`**. **Success:** seed inventory via store helper (test-only), POST **`refine_scrap_standard`**, assert **`success` true**, **`inputsConsumed`** / **`outputsGranted`**, **`xpGrantSummary`** (`refine`, **10**, `activity`), **`reasonCode` null**. **Deny:** **`unknown_recipe`**, **`insufficient_materials`**, **`invalid_quantity`**, **`inventory_full`** — each **200**, **`success` false**, stable **`reasonCode`**, empty I/O lists; inventory unchanged where applicable. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
Bruno scripts cover end-to-end gather→craft spine and manual dev-server verification. Store-missing codes (**`inventory_store_missing`**, **`progression_store_missing`**) covered by API tests with test doubles if practical; otherwise documented as 404 paths in README (not Bruno — requires broken store).
|
||||
|
||||
Major **`reasonCode`** coverage matrix:
|
||||
|
||||
| `reasonCode` | Automated API test | Bruno |
|
||||
|--------------|-------------------|-------|
|
||||
| `unknown_recipe` | yes | yes |
|
||||
| `insufficient_materials` | yes | yes |
|
||||
| `invalid_quantity` | yes | yes |
|
||||
| `inventory_full` | yes | yes |
|
||||
| `inventory_store_missing` | API test or README note | no |
|
||||
| `progression_store_missing` | defer to engine tests (NEO-69) | no |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Bruno scrap totals for spine** | Use **four** distinct prototype nodes (11 scrap) + **`refine_scrap_efficient`** so **`make_field_stim_mk0`** has 2× **`refined_plate_stock`** + 1 scrap without inventory POST. | **adopted** |
|
||||
| **Node depletion** | Spine Bruno must hit **different** `nodeDefId` rows (one interact each); document node order in Bruno `docs` block. | **adopted** |
|
||||
| **No manual QA doc** | User chose Bruno + README only; Epic 3 Slice 3 “player completes loop” verified via Bruno spine + API tests. | **adopted** |
|
||||
| **Client not in this story** | Cross-link **NEO-74**; Bruno-only verification is not prototype-complete per full-stack rule. | **adopted** |
|
||||
| **NEO-71 telemetry** | No hook comments in NEO-70 — NEO-71 owns engine hook placement. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **CraftResult-only wire response** — no inventory echo (user confirmed).
|
||||
- **No `docs/manual-qa/NEO-70.md`** — Bruno + README only (user confirmed; no Godot in scope).
|
||||
- **HTTP deny = 200 + `success: false`** for rule denies; **400** for contract errors; **404** for unknown player / store missing (NEO-55 precedent).
|
||||
- **Optional `quantity` defaults to 1** when omitted (E3M2 backlog default).
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# NEO-71 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-71 |
|
||||
| **Title** | E3.M2: item_crafted / craft_failed telemetry hook sites |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-71/e3m2-item-crafted-craft-failed-telemetry-hook-sites |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-07** |
|
||||
| **Branch** | `NEO-71-item-crafted-craft-failed-telemetry-hooks` |
|
||||
| **Blocked by** | [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — `CraftOperations.TryCraft` + `CraftResult` (**Done** on `main`); [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — craft HTTP + Bruno spine (**Done** on `main`; may parallel). |
|
||||
| **Pattern** | [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) — gather telemetry hooks in engine; [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) — centralized **`Deny`** hook for structured denies |
|
||||
| **Client counterpart** | None — comment-only server engine story; craft UI is [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) (Slice 5). |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-71.md`? User asked whether anything is testable in the UI. | **No** — no Godot/UI surface in this story (comment-only server hooks; craft UI is NEO-74). Verify via code review + `dotnet test` + optional Bruno craft regression (NEO-70 waiver for E3.M2 server-only). | **User:** No UI to test; **no manual QA doc** (NEO-70 pattern). |
|
||||
| **Runtime behavior** | Comments-only vs dev `ILogger`? | **Comments-only** — `TODO(E9.M1)`; no production logging (NEO-49/56/64 precedent). | **Adopted** (repo precedent). |
|
||||
| **Hook anchor** | Engine only vs engine + `PlayerCraftApi`? | **Engine only** in **`CraftOperations.TryCraft`** — HTTP delegates to engine; no duplicate API-layer hooks (NEO-56/64 precedent; Linear out-of-scope). | **Adopted** (repo precedent). |
|
||||
| **`craft_failed` placement** | Central **`Deny`** vs inline at each early return? | **Central `Deny`** — one hook site covers all **`CraftReasonCodes`** (NEO-56 **`PlayerInventoryOperations.Deny`** pattern). | **Adopted** (repo precedent). |
|
||||
| **`craft_failed` scope** | All structured denies vs pre-commit rule denies only? | **All** **`Deny`** outcomes including store-missing and post-mutation rollback paths — stable **`reasonCode`** on every craft failure (NEO-56 all-deny precedent). | **Adopted** (repo precedent). |
|
||||
| **`time-to-first-craft`** | Separate hook in prototype? | **No separate hook** — derived metric from **`item_crafted`** + session context at E9.M1 (E3M2-07 backlog note). | **Adopted** (backlog default). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Document and place **comment-only hook sites** on the server-authoritative craft path for future E9.M1 catalog events **`item_crafted`** and **`craft_failed`**. Align with Epic 3 Slice 3 telemetry vocabulary ([epic_03 — Slice 3](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-3)).
|
||||
|
||||
**In scope (from Linear + [E3M2-07](E3M2-prototype-backlog.md#e3m2-07--item_crafted--craft_failed-telemetry-hook-sites)):**
|
||||
|
||||
- Hook placement in **`CraftOperations.TryCraft`** (success + structured deny via **`Deny`**).
|
||||
- **`TODO(E9.M1)`** comments; no production logging.
|
||||
- **`server/README.md`** craft telemetry subsection; [E3_M2 module](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) hook note.
|
||||
- E3M2 backlog + alignment register updates when implementation completes.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Telemetry ingest, dashboards (**E9.M1**).
|
||||
- `ILogger` / metrics / dev-only log lines.
|
||||
- Duplicate hook comments in **`PlayerCraftApi`** (engine-only precedent).
|
||||
- Godot craft UI — **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**.
|
||||
- **`docs/manual-qa/NEO-71.md`** (kickoff decision — no player-visible or UI verification).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Hook sites documented in code and `server/README.md`.
|
||||
- [x] Matches Epic 3 Slice 3 telemetry vocabulary (`item_crafted`, `craft_failed` with reason; **`time-to-first-craft`** noted as derived, no separate hook).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Authoritative surface:** **`CraftOperations.TryCraft`** — all craft side effects (recipe resolve → inventory pre-flight → input removal → output grant → refine XP) funnel here; **`PlayerCraftApi`** delegates without duplicating hooks.
|
||||
|
||||
2. **Hook site A — `item_crafted`:** Immediately before the successful **`CraftResult`** return (after refine XP grant succeeds):
|
||||
- Comment block names future E9.M1 event **`item_crafted`**.
|
||||
- **`TODO(E9.M1): catalog emit`** (NEO-64 / NEO-56 style).
|
||||
- Planned payload fields: `playerId`, `recipeId`, `quantity` (batch), `inputsConsumed`, `outputsGranted`, `xpGrantSummary` (`skillId`, `amount`, `sourceKind`).
|
||||
|
||||
3. **Hook site B — `craft_failed`:** In private **`Deny(string reasonCode)`** (centralized, NEO-56 pattern):
|
||||
- Comment block names future E9.M1 event **`craft_failed`**.
|
||||
- **`TODO(E9.M1): catalog emit`** — on every structured craft deny with stable **`reasonCode`** from **`CraftReasonCodes`**.
|
||||
- Planned payload fields: `playerId`, `recipeId`, `quantity`, `reasonCode`; note **`playerId` / `recipeId` / `quantity`** are in enclosing **`TryCraft`** scope (same pattern as NEO-56 **`Deny`** noting fields at call sites).
|
||||
|
||||
4. **Cross-references (minimal):**
|
||||
- Update **`CraftOperations`** class summary → NEO-71 telemetry hook anchor.
|
||||
- **`CraftResult.cs`** summary already points to **`TryCraft`** — confirm wording after hooks land.
|
||||
- **`server/README.md`:** new **NEO-71 craft telemetry hooks** subsection under craft engine (replace “Telemetry hook sites are **NEO-71**” deferral with landed doc).
|
||||
- **`E3_M2_RefinementAndRecipeExecution.md`:** NEO-71 landed line; remove “Telemetry hooks deferred to NEO-71”.
|
||||
- **`documentation_and_implementation_alignment.md`** + **`module_dependency_register.md`:** E3.M2 row — E3M2-07 complete; module **Ready** when all Slice 3 backlog lands.
|
||||
- **`E3M2-prototype-backlog.md`:** E3M2-07 checkboxes + landed note.
|
||||
|
||||
5. **NEO-56 interaction:** Successful craft output grants already trigger **`item_created`** inside **`PlayerInventoryOperations.TryAddStack`**; **`item_crafted`** is the craft-funnel catalog event at the orchestration layer (analogous to **`resource_gathered`** + **`item_created`** on gather).
|
||||
|
||||
6. **`time-to-first-craft`:** Document in README that E9.M1 derives this from first **`item_crafted`** per player/session — no prototype hook.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-71-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | **NEO-71:** comment-only **`item_crafted`** hook on success path + **`craft_failed`** hook in **`Deny`**. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftResult.cs` | Confirm type summary cross-references NEO-71 hook anchor (wording tweak if needed). |
|
||||
| `server/README.md` | Document craft telemetry hook placement (NEO-71); remove deferral note. |
|
||||
| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | Implementation snapshot: NEO-71 telemetry hooks landed; Slice 3 telemetry vocabulary. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row: NEO-71 hook sites. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E3.M2 note — E3M2-07 complete; module status **Ready**. |
|
||||
| `docs/plans/E3M2-prototype-backlog.md` | E3M2-07 checkboxes + landed note; Slice 3 server spine complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing **`CraftOperationsTests`** + **`PlayerCraftApiTests`** unchanged). Optional Bruno: `bruno/neon-sprawl-server/craft/` spine against running dev server (NEO-70; behavior unchanged). |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Overlap with NEO-56 `item_created`** | Keep both — **`item_created`** at inventory layer; **`item_crafted`** at craft orchestration (E9.M1 can correlate). | **adopted** |
|
||||
| **No UI / manual QA doc** | User confirmed no Godot verification; README + code review + existing test suite sufficient (NEO-70 E3.M2 server-only pattern). | **adopted** (kickoff) |
|
||||
| **Slice 3 module closure** | Landing NEO-71 completes E3.M2 prototype **server** backlog; client loop remains NEO-72–NEO-75 (Slice 5). | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **No `docs/manual-qa/NEO-71.md`** — no UI surface; server comment-only (user confirmed via UI question).
|
||||
- **Engine-only hooks** in **`CraftOperations`**; no **`PlayerCraftApi`** duplicates.
|
||||
- **Central `Deny` for `craft_failed`** — all **`CraftReasonCodes`** paths.
|
||||
- **Comments + `TODO(E9.M1)` only** — no ingest or logging.
|
||||
- **`time-to-first-craft`** — derived at E9.M1; no separate prototype hook.
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
# NEO-72 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-72 |
|
||||
| **Title** | E3.M3: Client inventory snapshot HUD (E3S5-01) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-72/e3m3-client-inventory-snapshot-hud-e3s5-01 |
|
||||
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 **Slice 5** (client 1 of 4) · backlog **E3S5-01** |
|
||||
| **Branch** | `NEO-72-client-inventory-snapshot-hud` |
|
||||
| **Server deps** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) — `GET /game/players/{id}/inventory` (**Done**); [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — `GET /game/world/item-definitions` (**Done**) |
|
||||
| **Pattern** | [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) / [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — thin HTTP client nodes, `main.gd` wiring, debug HUD labels, GdUnit mock transport |
|
||||
| **Downstream** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) gather feedback calls inventory refresh; [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI reuses inventory + defs clients |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Display names** | Resolve **`displayName`** via **`GET /game/world/item-definitions`** or raw **`itemId`** only? | **Include `item_definitions_client`** + cached lookup — NEO-53 is landed; readable labels help gather/craft QA in NEO-73/74. | **User:** include defs + **`displayName`**. |
|
||||
| **Failed GET UX** | How to surface inventory GET errors? | **`InventoryLabel` error line + `push_warning`** — visible in-game (NEO-28 cast deny HUD) and dev console (cooldown/interaction HTTP clients). | **User:** HUD + **`push_warning`**. |
|
||||
| **Refresh key** | Manual refresh binding? | **Bind `I` → `inventory_refresh`** — E3S5 backlog QA note; public **`request_sync_from_server()`** entrypoint for NEO-73. | **User:** **`I`** key. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Godot client for **`GET /game/players/{id}/inventory`** plus a readable bag/equipment debug HUD; boot hydrate and a manual refresh entrypoint for later Slice 5 stories.
|
||||
|
||||
**In scope (from Linear + [E3S5-01](E3S5-client-prototype-backlog.md#e3s5-01--client-inventory-snapshot-hud-e3m3)):**
|
||||
|
||||
- **`inventory_client.gd`:** GET snapshot, parse v1 envelope, emit signal with parsed body; public **`request_sync_from_server()`**; structured **`push_warning`** on transport/HTTP/JSON failures.
|
||||
- **`item_definitions_client.gd`:** GET world item defs once (or on boot), cache **`id → displayName`** (and optional **`inventorySlotKind`** for row prefix); emit **`definitions_ready`**.
|
||||
- **`InventoryLabel`** under **`UICanvas`:** lists **non-empty bag slots** + **equipment slot 0** (show **`— empty`** when quantity 0); resolve labels via defs cache when available, fall back to raw **`itemId`**.
|
||||
- Boot hydrate after scene ready (chain: item defs → inventory GET, mirroring hotbar → cooldown boot order in NEO-32).
|
||||
- **`I`** input → **`inventory_refresh`** in **`project.godot`**; handled in **`main.gd`** **`_unhandled_key_input`** (same pattern as E/R interact forwarding).
|
||||
- GdUnit: mock HTTP — happy-path parse + error paths for both clients.
|
||||
- **`client/README.md`** inventory HUD subsection.
|
||||
- **`docs/manual-qa/NEO-72.md`**.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Drag-drop, stack split, equip UX polish.
|
||||
- POST inventory mutations from UI (gather/craft stories own refresh triggers).
|
||||
- Server route or DTO changes.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Boot shows current server inventory for **`dev-local-1`** (empty bag + empty equipment stub on fresh dev player).
|
||||
- [x] HUD lists all **non-empty** **`bagSlots`** and **equipment slot 0** from GET body (with **`displayName`** when defs loaded).
|
||||
- [x] Failed GET surfaces visible error on **`InventoryLabel`** and **`push_warning`** in Output.
|
||||
- [x] **`I`** triggers manual inventory refresh; **`request_sync_from_server()`** callable for NEO-73 wiring.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`item_definitions_client.gd`**
|
||||
- Injectable HTTP transport; **`base_url`** / shared authority config from **`main.gd`** (same as **`CooldownSnapshotClient`**).
|
||||
- **`request_sync_from_server()`** → **`GET …/game/world/item-definitions`**.
|
||||
- On 200 + v1 JSON: build **`Dictionary`** map **`itemId → { displayName, inventorySlotKind }`**; emit **`definitions_ready(map)`**.
|
||||
- Errors: **`push_warning("ItemDefinitionsClient: …")`**; emit nothing (inventory HUD falls back to raw ids).
|
||||
|
||||
2. **`inventory_client.gd`**
|
||||
- **`request_sync_from_server()`** → **`GET …/game/players/{dev_player_id}/inventory`**.
|
||||
- On 200: validate **`schemaVersion` 1**, emit **`inventory_received(snapshot: Dictionary)`** with full parsed body.
|
||||
- On failure (transport, non-2xx, non-JSON): **`push_warning("InventoryClient: …")`** + emit **`inventory_sync_failed(reason: String)`** so **`main.gd`** can paint the HUD error line without parsing a snapshot.
|
||||
- **`_busy`** guard like **`CooldownSnapshotClient`** (ignore overlapping GETs).
|
||||
|
||||
3. **Boot sequence in `main.gd`**
|
||||
- Add **`_setup_inventory_sync()`** from **`_ready()`** after authority is wired.
|
||||
- Instantiate **`ItemDefinitionsClient`** + **`InventoryClient`** as child nodes; copy **`base_url`** / **`dev_player_id`** from **`PositionAuthorityClient`**.
|
||||
- Connect **`definitions_ready`** → cache map on **`main`** (or hold ref to defs client with **`display_name_for(item_id)`** helper method).
|
||||
- On boot: **`item_definitions_client.request_sync_from_server()`** then **`inventory_client.request_sync_from_server()`** (defs may still be in flight when first inventory paints — re-render label when defs arrive).
|
||||
- Connect **`inventory_received`** / **`inventory_sync_failed`** → **`_render_inventory_label()`**.
|
||||
|
||||
4. **HUD formatting (`_render_inventory_label`)**
|
||||
- Header: **`Inventory:`** + optional **`(refresh: I)`** hint for QA.
|
||||
- **Bag:** iterate **`bagSlots`**; emit lines only where **`quantity > 0`**: **`slot {slotIndex}: {displayName or itemId} x{quantity}`**.
|
||||
- **Equipment:** always show **`slot 0`**: item line if occupied, else **`slot 0: — empty`**.
|
||||
- On **`inventory_sync_failed`**: replace body with **`Inventory: error — {reason}`** (keep header); also **`push_warning`** already fired from client.
|
||||
|
||||
5. **Scene + input**
|
||||
- **`main.tscn`:** add **`InventoryClient`**, **`ItemDefinitionsClient`** nodes; **`InventoryLabel`** below **`CooldownSlotsLabel`** (offset ~460px top, autowrap, same font theme as sibling HUD labels).
|
||||
- **`project.godot`:** **`inventory_refresh`** → **I** (physical key).
|
||||
- **`main.gd` `_unhandled_key_input`:** if **`inventory_refresh`** pressed → **`inventory_client.request_sync_from_server()`**.
|
||||
|
||||
6. **Public refresh contract for NEO-73**
|
||||
- Expose **`InventoryClient.request_sync_from_server()`** (no wrapper required in **`main`** unless we add **`refresh_inventory_from_server()`** alias — prefer direct client call from **`main`** helpers NEO-73 can reuse).
|
||||
|
||||
7. **Docs on land**
|
||||
- Update [E3_M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M3 row when implementation completes.
|
||||
- **`E3S5-client-prototype-backlog.md`:** check E3S5-01 boxes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-72-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/item_definitions_client.gd` | GET world item defs; cache id → display metadata; **`definitions_ready`** signal. |
|
||||
| `client/scripts/item_definitions_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/scripts/inventory_client.gd` | GET player inventory snapshot; **`inventory_received`** / **`inventory_sync_failed`** signals. |
|
||||
| `client/scripts/inventory_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/item_definitions_client_test.gd` | GdUnit: parse v1 items list; lookup helper; HTTP error **`push_warning`**. |
|
||||
| `client/test/inventory_client_test.gd` | GdUnit: parse bag/equipment slots; 404/500 failure emits **`inventory_sync_failed`**. |
|
||||
| `docs/manual-qa/NEO-72.md` | Boot empty inventory, Bruno seed + refresh, failed GET (server stopped), **`I`** refresh. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scenes/main.tscn` | Add **`InventoryClient`**, **`ItemDefinitionsClient`**, **`InventoryLabel`** nodes. |
|
||||
| `client/scripts/main.gd` | Boot wiring, HUD render, **`I`** refresh input, defs cache + inventory signal handlers. |
|
||||
| `client/project.godot` | Register **`inventory_refresh`** input action (**I**). |
|
||||
| `client/README.md` | Inventory HUD subsection: bindings, label format, boot/refresh behavior, server deps. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `client/test/item_definitions_client_test.gd` | Mock transport 200 with six-item v1 JSON → map contains **`scrap_metal_bulk`** **`displayName`**; non-200 → no **`definitions_ready`**, warning path. AAA layout. |
|
||||
| `client/test/inventory_client_test.gd` | Mock 200 with empty 24+1 slot grid → **`inventory_received`** payload has **`bagSlots.size()` 24**; 404 → **`inventory_sync_failed`** + warning; malformed JSON → failure signal. AAA layout. |
|
||||
|
||||
No new **C#** tests (client-only; server contracts covered by NEO-55/NEO-53). No new Bruno (server already exercised).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Defs/inventory race on boot** | First inventory paint may use raw ids; re-render on **`definitions_ready`** — acceptable for prototype. | **adopted** |
|
||||
| **Empty bag UX** | Show **`Bag: (empty)`** when no occupied bag slots; equipment stub always listed. | **adopted** |
|
||||
| **HUD clutter at 24 slots** | Non-empty-only rows keep label readable until NEO-75 optional collapse. | **adopted** |
|
||||
| **Slice 5 module docs** | Update alignment register when NEO-72 lands, not at kickoff. | **deferred** (implementation) |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Include `item_definitions_client`** with **`displayName`** resolution (user confirmed).
|
||||
- **Error UX:** **`InventoryLabel`** error line + **`push_warning`** (user confirmed).
|
||||
- **Manual refresh:** **`I` → `inventory_refresh`** (user confirmed).
|
||||
- **Equipment row:** always show slot **0** (empty or occupied) per Linear AC “equipment stub slot”.
|
||||
- **No POST inventory** from UI in this story.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Clients:** `item_definitions_client.gd` (cached **`displayName`**), `inventory_client.gd` (**`inventory_received`** / **`inventory_sync_failed`**).
|
||||
- **HUD:** `UICanvas/InventoryLabel`; boot hydrate; **`I` → `inventory_refresh`**; error line + **`push_warning`** on failed GET.
|
||||
- **Tests:** `client/test/item_definitions_client_test.gd`, `client/test/inventory_client_test.gd`.
|
||||
- **Docs:** `client/README.md` inventory section; [`NEO-72` manual QA](../manual-qa/NEO-72.md); E3.M3 module + alignment register updated.
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# NEO-73 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-73 |
|
||||
| **Title** | E3.M1: Client gather feedback on interact (E3S5-02) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-73/e3m1-client-gather-feedback-on-interact-e3s5-02 |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 **Slice 5** (client 2 of 4) · backlog **E3S5-02** |
|
||||
| **Branch** | `NEO-73-e3m1-client-gather-feedback-on-interact` |
|
||||
| **Server deps** | [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — gather via **`POST …/interact`** (**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) — **`GET …/skill-progression`** (**Done**) |
|
||||
| **Pattern** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28) / [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — 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 loop; [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) reuses **`SkillProgressionClient`** for **refine** row |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Four-node R wiring** | How should **R** pick the resource node id? | **Nearest in-range `resource_node`** from cached interactables catalog — no new keys; walk to each anchor, press **R**. | **User:** nearest in-range from catalog. |
|
||||
| **Gather success copy** | What does **`GatherFeedbackLabel`** show? | **Inventory delta after refresh** — `+N {displayName}` for **`scrap_metal_bulk`** (compare pre/post snapshot qty). | **User:** inventory delta. |
|
||||
| **Salvage XP HUD** | Where does salvage xp/level live? | **`SkillProgressionLabel`** — persistent salvage row; boot hydrate + refresh after successful gather. | **User:** dedicated skill progression label. |
|
||||
|
||||
No other blocking decisions — **`node_depleted`** deny copy follows **`CastFeedbackLabel`** pattern (`Gather: denied — node_depleted`); terminal **E** interact unchanged.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** After successful **`resource_node`** interact, the player **sees** inventory change and **salvage** XP movement on HUD — not Output-panel-only proof.
|
||||
|
||||
**In scope (from Linear + [E3S5-02](E3S5-client-prototype-backlog.md#e3s5-02--client-gather-feedback-on-interact-e3m1)):**
|
||||
|
||||
- **`skill_progression_client.gd`:** GET snapshot; parse v1 envelope; emit **`progression_received`** / **`progression_sync_failed`**; public **`request_sync_from_server()`**; **`skill_row(skill_id)`** helper for **`salvage`**.
|
||||
- **`SkillProgressionLabel`** under **`UICanvas`:** shows **`salvage: level N, xp M`** (and optional **`refine`** stub line omitted until NEO-74).
|
||||
- **`GatherFeedbackLabel`:** success delta line + deny lines for gather interacts.
|
||||
- Extend **`interaction_request_client.gd`:** emit **`interaction_result_received(interactable_id, allowed, reason_code)`** on parsed 200 (keep Output **`print`** for dev).
|
||||
- **`main.gd` gather path:**
|
||||
- Cache interactables catalog descriptors from **`catalog_ready`**.
|
||||
- **R** → resolve **nearest in-range** descriptor with **`kind == resource_node`** (horizontal X/Z, inclusive **`<= interactionRadius`** — same math as **`interaction_radius_indicators.gd`**); fall back to **`prototype_resource_node_alpha`** when none in range (server returns **`out_of_range`**).
|
||||
- On **`interaction_result_received`** when id is a known resource node:
|
||||
- **`allowed=true`:** snapshot pre-gather **`scrap_metal_bulk`** qty → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`** → on both (or inventory-first then skill) compute delta → paint **`GatherFeedbackLabel`**.
|
||||
- **`allowed=false`:** paint **`GatherFeedbackLabel`** with **`Gather: denied — {reasonCode}`** (including **`node_depleted`**); no refresh.
|
||||
- Boot: chain **`skill_progression_client.request_sync_from_server()`** alongside inventory hydrate (after authority config wired).
|
||||
- GdUnit: mock interact 200 → **`main`** handler (or client signal) triggers inventory + skill refresh; skill client parse tests.
|
||||
- **`client/README.md`** gather feedback subsection.
|
||||
- **`docs/manual-qa/NEO-73.md`**.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- New gather HTTP route; node respawn visuals; gather VFX.
|
||||
- **`refine`** skill row polish (NEO-74); resource-node-defs preview client (NEO-60 optional).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] In-range interact on fresh node updates **inventory HUD** without manual **I** refresh.
|
||||
- [x] Depleted node shows **`node_depleted`** (or agreed copy) on **`GatherFeedbackLabel`**.
|
||||
- [x] **Salvage** skill level/XP visible on **`SkillProgressionLabel`** and changes after successful gather.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Clients:** `skill_progression_client.gd`; `interaction_request_client.gd` emits **`interaction_result_received`** + **`post_interact_id`**.
|
||||
- **Picker:** `prototype_interactable_picker.gd` — nearest in-range **`resource_node`** on **R** (alpha fallback).
|
||||
- **HUD:** `GatherFeedbackLabel` (delta / deny); `SkillProgressionLabel` (salvage row); inventory auto-refresh on gather success.
|
||||
- **Tests:** `skill_progression_client_test.gd`, `prototype_interactable_picker_test.gd`, `gather_feedback_refresh_test.gd`; extended `interaction_request_client_test.gd`.
|
||||
- **Docs:** `client/README.md` gather section; [`NEO-73` manual QA](../manual-qa/NEO-73.md).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`skill_progression_client.gd`**
|
||||
- Mirror **`inventory_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/skill-progression`**.
|
||||
- **`parse_progression_json(text)`** static: require **`schemaVersion` 1**, **`skills`** array.
|
||||
- **`skill_row(skill_id: String) -> Dictionary`:** find row by **`id`**; empty dict if missing.
|
||||
- Signals: **`progression_received(snapshot)`**, **`progression_sync_failed(reason)`**.
|
||||
|
||||
2. **`interaction_request_client.gd`**
|
||||
- Add **`signal interaction_result_received(interactable_id: String, allowed: bool, reason_code: String)`**.
|
||||
- On 200 + parsed dict: emit signal; **`reason_code`** empty string when allowed.
|
||||
- Add **`post_interact_id(interactable_id: String)`** public entry (used by **`main`** for nearest-node resolution); keep **`post_interact_resource()`** as thin wrapper or deprecate in favor of **`main`**-resolved id only.
|
||||
|
||||
3. **Nearest resource node helper (`main.gd` or small `prototype_interactable_picker.gd`)**
|
||||
- Input: cached descriptor **`Array`**, player **`Vector3`** world position.
|
||||
- Filter **`kind == "resource_node"`**; compute horizontal distance to **`anchor`**; pick minimum **`<= interactionRadius`**.
|
||||
- Tie-break: ascending **`interactableId`** (stable with server ordering).
|
||||
- Store descriptors in **`_interactables_catalog: Array`** on **`catalog_ready`**.
|
||||
|
||||
4. **Gather feedback orchestration (`main.gd`)**
|
||||
- **`_gather_pre_scrap_qty: int`** — set from **`_total_bag_qty("scrap_metal_bulk")`** immediately before POST when posting a resource node id.
|
||||
- Connect **`interaction_result_received`** → **`_on_interaction_result_for_gather`**.
|
||||
- Success path: refresh inventory + skill progression; on **`inventory_received`**, if pending gather feedback flag set, compute **`delta = new_qty - _gather_pre_scrap_qty`**, format **`Gather: +{delta} {displayName}`** on **`GatherFeedbackLabel`**; clear flag.
|
||||
- Deny path: **`Gather: denied — {reasonCode}`** on label ( **`node_depleted`**, **`inventory_full`**, **`out_of_range`**, etc.).
|
||||
- Terminal **E** interact: optional **`Interaction: …`** line or leave **`GatherFeedbackLabel`** unchanged (prefer unchanged unless we want generic interact deny — keep gather label gather-specific only).
|
||||
|
||||
5. **`SkillProgressionLabel` rendering**
|
||||
- Header **`Skills:`**; row **`salvage: level {level}, xp {xp}`** from cached snapshot.
|
||||
- Error line on **`progression_sync_failed`** (mirror inventory error UX).
|
||||
- Re-render on **`progression_received`** and after gather refresh.
|
||||
|
||||
6. **Scene layout (`main.tscn`)**
|
||||
- Add **`SkillProgressionClient`**, **`GatherFeedbackLabel`**, **`SkillProgressionLabel`** nodes.
|
||||
- **`GatherFeedbackLabel`:** below **`CastFeedbackLabel`** (~offset_top 378) or between cast and cooldown — short single-line feedback.
|
||||
- **`SkillProgressionLabel`:** below **`InventoryLabel`** (~offset_top 628) or stack under inventory block; shift layout if needed for readability.
|
||||
- **`SkillProgressionClient`:** sibling HTTP client node like **`InventoryClient`**.
|
||||
|
||||
7. **Tests**
|
||||
- **`skill_progression_client_test.gd`:** mock 200 with trio skills → **`skill_row("salvage")`** has xp/level; 404 → failure signal.
|
||||
- **`interaction_request_client_test.gd`:** extend — mock 200 deny → signal **`allowed=false`**, **`reason_code=node_depleted`**.
|
||||
- **`gather_feedback_refresh_test.gd`** (or extend **`interaction_request_client_test`**): stub **`main`** gather handler / test double that verifies inventory + skill **`request_sync_from_server`** called on allowed resource node signal (AAA).
|
||||
|
||||
8. **Docs on land**
|
||||
- Update [E3_M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M1 row when implementation completes.
|
||||
- **`E3S5-client-prototype-backlog.md`:** check E3S5-02 boxes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-73-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/skill_progression_client.gd` | GET player skill progression; **`progression_received`** / **`progression_sync_failed`**; **`skill_row`**. |
|
||||
| `client/scripts/skill_progression_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/skill_progression_client_test.gd` | GdUnit: parse v1 skills; salvage row lookup; HTTP error path. AAA layout. |
|
||||
| `client/test/gather_feedback_refresh_test.gd` | GdUnit: interact allow signal → inventory + skill refresh invoked (mock/spy). AAA layout. |
|
||||
| `docs/manual-qa/NEO-73.md` | Fresh node gather, four-node **R** nearest pick, depletion deny, salvage XP delta, no manual **I**. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/interaction_request_client.gd` | Emit **`interaction_result_received`**; add **`post_interact_id`**. |
|
||||
| `client/scripts/main.gd` | Catalog cache, nearest-node **R** routing, gather feedback orchestration, skill/inventory boot + signal wiring, HUD render helpers. |
|
||||
| `client/scenes/main.tscn` | Add **`SkillProgressionClient`**, **`GatherFeedbackLabel`**, **`SkillProgressionLabel`**. |
|
||||
| `client/test/interaction_request_client_test.gd` | Assert new signal on allow/deny mock responses. |
|
||||
| `client/README.md` | Gather feedback: **R** nearest-node rule, HUD labels, boot hydrate, server deps. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `client/test/skill_progression_client_test.gd` | Mock 200 with **`salvage`** row **`xp`/`level`** → **`progression_received`** + **`skill_row`**; 404 → **`progression_sync_failed`**. AAA layout. |
|
||||
| `client/test/interaction_request_client_test.gd` | Mock deny 200 → **`interaction_result_received(false, "node_depleted")`**; allow → **`allowed=true`**. AAA layout. |
|
||||
| `client/test/gather_feedback_refresh_test.gd` | Lightweight **`main.gd`** gather handler test or extracted callable: resource node allow → **`request_sync_from_server`** on inventory + skill clients (spy nodes). AAA layout. |
|
||||
|
||||
No new **C#** tests (client-only). No new Bruno (server covered by NEO-63).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Async refresh race (inventory vs skill)** | Paint gather delta when inventory arrives; refresh skill label independently on **`progression_received`** — acceptable lag ≤1 RTT. | **adopted** |
|
||||
| **Multi-item deltas** | Prototype yields only **`scrap_metal_bulk`**; sum bag qty for that id only. | **adopted** |
|
||||
| **No in-range node on R** | Fall back to alpha id; server **`out_of_range`** + HUD deny line — same as today when far from nodes. | **adopted** |
|
||||
| **Terminal E pollutes GatherFeedbackLabel** | Leave gather label unchanged on non-resource-node interacts. | **adopted** |
|
||||
| **HUD vertical space** | Offset labels below inventory; optional collapse deferred to NEO-75. | **deferred** (NEO-75) |
|
||||
| **Slice 5 module docs** | Update alignment register when NEO-73 lands, not at kickoff. | **deferred** (implementation) |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **R wiring:** nearest in-range **`resource_node`** from cached interactables catalog (user confirmed).
|
||||
- **Gather success copy:** inventory delta **`+N displayName`** after refresh (user confirmed).
|
||||
- **Skill HUD:** dedicated **`SkillProgressionLabel`** with salvage row; boot + post-gather refresh (user confirmed).
|
||||
- **Deny UX:** **`GatherFeedbackLabel`** + existing Output **`print`** (interaction client).
|
||||
- **No new gather HTTP** on client; reuse **`POST …/interact`**.
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# E?.M? — Prototype story backlog (<ModuleName>)
|
||||
|
||||
<!-- Copy this file to docs/plans/E{epic}M{module}-prototype-backlog.md or E{epic}S{n}-client-prototype-backlog.md when decomposing. -->
|
||||
<!-- Policy: [Full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) -->
|
||||
|
||||
Working backlog for **Epic ? — Slice ?** ([slice anchor](../decomposition/epics/epic_??_*.md#…)). Decomposition and contracts: [E?.M? — ModuleName](../decomposition/modules/E?_M?_*.md).
|
||||
|
||||
**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E1M3 (paired server+client)](../plans/E1M3-prototype-backlog.md), [E3S5 (client slice)](../plans/E3S5-client-prototype-backlog.md).
|
||||
|
||||
**Labels (Linear):** module label **`E?.M?`** on every issue; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E?M?-01 | server | [NEO-??](https://linear.app/neon-sprawl/issue/NEO-??) |
|
||||
| E?M?-01-client | client | [NEO-??](https://linear.app/neon-sprawl/issue/NEO-??) |
|
||||
| … | … | … |
|
||||
| E?M?-NN-capstone | client | [NEO-??](https://linear.app/neon-sprawl/issue/NEO-??) |
|
||||
|
||||
**Dependency graph in Linear:** document `blockedBy` for each issue (client stories blocked by their server API dependencies).
|
||||
|
||||
**Board order:** estimates **1…N** matching slug execution order; sort Epic project by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E?M?-01** | server | … |
|
||||
| 2 | **E?M?-01-client** | client | E?M?-01 |
|
||||
| … | … | … | … |
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| HUD fidelity | Prototype Labels / debug panels (not final art) | NEO-24 / NEO-28 precedent |
|
||||
| Authority | Client displays only; mutations via server routes | [client_server_authority.md](../decomposition/modules/client_server_authority.md) |
|
||||
|
||||
---
|
||||
|
||||
### E?M?-01 — <Server story title>
|
||||
|
||||
**Goal:** …
|
||||
|
||||
**In scope**
|
||||
|
||||
- …
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot UI — **client counterpart [NEO-??](#)** (not optional; separate issue)
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] …
|
||||
|
||||
**Client counterpart:** [NEO-??](https://linear.app/neon-sprawl/issue/NEO-??) — <one-line HUD summary>
|
||||
|
||||
---
|
||||
|
||||
### E?M?-01-client — <Client story title>
|
||||
|
||||
**Goal:** Godot …
|
||||
|
||||
**In scope**
|
||||
|
||||
- `client/scripts/*_client.gd`, `main.gd` wiring, HUD under `UICanvas`
|
||||
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md))
|
||||
- `docs/manual-qa/NEO-??.md` — **Godot** steps (server + client running)
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- …
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player-visible outcome in Godot (not Output-panel-only)
|
||||
- [ ] Manual QA checklist exercisable without Bruno
|
||||
|
||||
---
|
||||
|
||||
### E?M?-NN-capstone — Playable <feature> loop in client
|
||||
|
||||
**Goal:** Prove epic slice acceptance **in Godot** without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `docs/manual-qa/NEO-??.md` session script
|
||||
- `client/README.md` integration notes
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Vision prototype gate / epic AC satisfied by a human in Godot
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- Track delivery in Linear; keep `blockedBy` synchronized.
|
||||
- **Do not** mark module **prototype complete** until **client** stories (and capstone if any) are **Done** or explicitly deferred with a **created** follow-up issue id.
|
||||
- For each issue kickoff: `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [ ] Every player-visible AC has a **`client`** Linear issue
|
||||
- [ ] No “optional follow-up” / “future client” without NEO-XX
|
||||
- [ ] Epic DoD mentions **Godot** (not Bruno-only)
|
||||
- [ ] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) updated
|
||||
|
||||
## Related docs
|
||||
|
||||
- [epic_??_*.md](../decomposition/epics/)
|
||||
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Code review — NEO-56 inventory telemetry hook sites
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-56-item-created-transfer-failure-telemetry-hooks` · commits `786513b`–`dc5141e` vs `origin/main` (includes chore `3d43b00` kickoff rule hardening)
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-56 adds **comment-only** E9.M1 telemetry anchor sites in `PlayerInventoryOperations`: **`item_created`** before successful add (`Applied`), and **`inventory_transfer_denied`** + **`reasonCode`** on every rule **`Denied`** path (centralized early validation in **`Deny`**, inline markers on post-mutation denies). No runtime behavior, API, or test changes; docs (plan, manual QA, README, E3.M3 slice, alignment table) match kickoff decisions (engine-only, add-success only, single deny event shape, comments-only). Risk is very low for merge. The branch also carries an unrelated **kickoff rule** hardening commit (`story-kickoff.md`, `AGENTS.md`) — fine if intentional, otherwise consider splitting before PR.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-56-implementation-plan.md`](../plans/NEO-56-implementation-plan.md) | **Matches** — engine-only hooks, kickoff clarifications recorded, acceptance checklist complete; comments-only regression noted. |
|
||||
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-56 telemetry bullet under **Related implementation slices** with README + manual QA links. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row cites NEO-56 landed hook sites; no E9.M1 ingest. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 footnote includes NEO-56 telemetry hooks; Slice 1 backlog E3M3-01–07 noted complete. |
|
||||
| [`docs/decomposition/epics/epic_03_crafting_economy.md`](../decomposition/epics/epic_03_crafting_economy.md) | **Matches** — Slice 1 vocabulary `item_created`, `inventory_full` / transfer failures via stable `reasonCode`. |
|
||||
| [`docs/manual-qa/NEO-56.md`](../manual-qa/NEO-56.md) | **Matches** — code-review checklist, regression command, optional Bruno sanity. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — NEO-56 telemetry subsection under Player inventory. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — hooks remain on server-authoritative engine; no HTTP-layer duplication. |
|
||||
|
||||
Register/tracking: alignment table, E3.M3 module page, and dependency register footnote updated for NEO-56.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Dependency register footnote (optional)** — Extend the **E3.M3 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to mention **NEO-56** (telemetry hook sites) alongside NEO-50–NEO-55, matching the alignment table and NEO-55 review pattern.~~ **Done.**
|
||||
|
||||
2. ~~**PR scope clarity (optional)** — Commit `3d43b00` (kickoff `AskQuestion` + recommendation rule) is unrelated to NEO-56 inventory hooks. If the PR should be story-scoped only, cherry-pick or split; if bundled tooling hygiene is intentional, call it out in the PR description.~~ **Done.** — documented in [NEO-56 plan](../plans/NEO-56-implementation-plan.md) **Decisions (post-review)**; mention in PR body when opening.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: Plan allowed a cross-reference in `PlayerInventoryMutationOutcome.cs` **or** `PlayerInventoryReasonCodes.cs`; only ReasonCodes was updated — acceptable (“or” satisfied).
|
||||
|
||||
- ~~Nit: Post-mutation **`Denied`** hooks (lines ~54, ~124 in `PlayerInventoryOperations.cs`) use a one-line marker; **`Deny`** and **`item_created`** use fuller multi-line blocks (NEO-49 style). Consistent enough with NEO-30 deny precedent; optional payload-field notes on post-mutation paths would mirror **`Deny`** for E9.M1 implementers.~~ **Done.** — multi-line blocks with payload fields on add/remove post-mutation denies.
|
||||
|
||||
- ~~Nit: **`Deny`** hook comments list planned payload fields `itemId`, `quantity`, `mutationKind`, but the method signature does not carry those today — fine for comments-only; E9.M1 will likely extend **`Deny`** or pass a context struct when wiring emit.~~ **Done.** — comment clarifies emit call sites supply itemId/quantity/mutationKind.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj 2>/dev/null || dotnet test NeonSprawl.sln
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerInventory"
|
||||
```
|
||||
|
||||
Manual: walk [`docs/manual-qa/NEO-56.md`](../manual-qa/NEO-56.md) code-review checklist (confirm no hooks in `PlayerInventoryApi.cs`, `TODO(E9.M1)` present, no new logging).
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Code review — NEO-57 prototype resource node catalogs + CI
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-57-e3m1-prototype-resourcenodedef-yield-schemas-ci` · commits `17daf9a`–`ca1a15f` vs `origin/main` (decomposition merge + plan + implementation + review follow-ups)
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-57 lands **content-only** Slice 2 gather spine: two JSON Schemas (`resource-node-def`, `resource-yield-row`), four frozen `ResourceNodeDef` rows and matching yield rows under `content/resource-nodes/`, and extended `scripts/validate_content.py` with duplicate `nodeDefId` detection, frozen four-id / four-lens gates, yield↔node and yield↔item cross-checks, and `scrap_metal_bulk`-only yield item gate. The implementation mirrors the NEO-50 item-catalog pattern (row schema + `schemaVersion` envelope + prototype allowlists). Docs (E3.M1 module freeze, CT.M1 PR gate, alignment table, `content/README.md`, E3M1 backlog) align with the implementation plan. No server, client, or Bruno changes — appropriate for E3M1-01. Risk is low; CI script is the primary regression surface.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-57-implementation-plan.md`](../plans/NEO-57-implementation-plan.md) | **Matches** — two-file layout, `maxGathers`, differentiated quantities 1/2/3/5, acceptance checklist complete; no server scope. |
|
||||
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-01 acceptance checked; yield row schema name aligned (post-review). |
|
||||
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — Slice 2 freeze table, designer note, contracts, E1.M3 boundary; content ids match JSON. |
|
||||
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — NEO-57 PR gate paragraph for resource-node + yield validation. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-57 content + CI; server load still planned. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M1 note for NEO-57–NEO-64 Slice 2 backlog. |
|
||||
| [`content/README.md`](../../content/README.md) | **Matches** — `resource-nodes/` table row + Slice 2 freeze pointer. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no runtime authority change this story (content + CI only). |
|
||||
|
||||
Register/tracking: alignment table and CT.M1 updated; E3.M1 module status reflects Slice 2 backlog in progress.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
None (resolved in `ca1a15f` and follow-up backlog edit).
|
||||
|
||||
1. ~~**E3M1 backlog checkboxes** — In [`E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) § E3M1-01, flip acceptance criteria to `[x]` (or add a “NEO-57 landed” note) so the backlog matches [`NEO-57-implementation-plan.md`](../plans/NEO-57-implementation-plan.md).~~ **Done.**
|
||||
|
||||
2. ~~**Backlog schema name** — Update E3M1-01 bullet from `resource-yield-table.schema.json` to **`resource-yield-row.schema.json`** (kickoff chose row schema + `prototype_resource_yields.json`).~~ **Done.**
|
||||
|
||||
3. ~~**Backlog yield file wording** — Remove “or embedded yields — plan decides” from E3M1-01 in-scope list; two-file layout is landed.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `_prototype_slice2_resource_node_gate` enforces lens **set** equality, not a per-lens duplicate error message; duplicate `gatherLens` on two nodes still fails with a clear set-mismatch line — acceptable for prototype.
|
||||
|
||||
- Nit: CI does not freeze yield **quantities** (1/2/3/5) — only schema `quantity >= 1` and `scrap_metal_bulk` only; intentional per plan (differentiation is content, not gate).
|
||||
|
||||
- Nit: No `docs/manual-qa/NEO-57.md` — fine for content-only story; manual negative cases live in the implementation plan **Tests** table.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements-content.txt
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
Manual negatives (from plan): duplicate `nodeDefId`; remove a frozen id; invalid `gatherLens`; unknown yield `itemId`; duplicate yield `nodeDefId`; non-`scrap_metal_bulk` yield item — each should exit non-zero.
|
||||
|
||||
PR gate: `.github/workflows/pr-gate.yml` already invokes `validate_content.py` (no workflow change required).
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Code review — NEO-58 server resource-node catalog load (fail-fast)
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-58-e3m1-server-resource-node-catalog-load-fail-fast` · commits `2de699b`–`fdaf5b7` vs `main` (plan + implementation + rebase note)
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-58 adds fail-fast startup loading for `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` under `Game/Gathering/`, mirroring the NEO-51 item-catalog pattern: path discovery via `ContentPathsOptions`, in-process JSON Schema validation, duplicate `nodeDefId` detection, prototype Slice 2 gates (`PrototypeSlice2ResourceNodeCatalogRules` synced with `scripts/validate_content.py`), and yield `itemId` cross-check against `ItemDefinitionCatalog`. DI registers `ResourceNodeCatalog` after items; `Program.cs` eagerly resolves it at boot. Seventeen focused loader/host tests pass with full AAA. Docs (implementation plan, E3M1 backlog, alignment table, server README) match the landed behavior. Risk is low; main drift surface is duplicated Slice 2 constants in C# vs Python (documented, same as NEO-50/NEO-57).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-58-implementation-plan.md`](../plans/NEO-58-implementation-plan.md) | **Matches** — scope, acceptance checklist, technical approach, and file list align with the diff; checklist marked complete. |
|
||||
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-02 acceptance flipped with NEO-58 landed note. |
|
||||
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — content contracts and Slice 2 freeze; server load is the planned E3M1-02 step (registry/depletion still open). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-58 server loader + README link. |
|
||||
| [`docs/plans/NEO-57-implementation-plan.md`](../plans/NEO-57-implementation-plan.md) | **Matches** — C# loader enforces the same gates CI added in NEO-57. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no runtime gather authority change; startup validation only. |
|
||||
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — PR gate remains `validate_content.py`; server tests guard C#/Python Slice 2 drift. |
|
||||
|
||||
Register/tracking: alignment table updated; no further register change required beyond this row.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Plan vs Bruno** — [`NEO-58-implementation-plan.md`](../plans/NEO-58-implementation-plan.md) lists Bruno as out of scope, but `bruno/neon-sprawl-server/resource-node-catalog/` adds a health smoke request. Either add a one-line note under **Tests** (“optional Bruno smoke”) or drop the folder to keep plan scope literal; the request itself is fine for manual boot verification.~~ **Done.** Plan **Tests** table now documents optional Bruno health smoke (pre-commit when `Program.cs` changes).
|
||||
|
||||
2. ~~**Explicit empty-catalog file test (optional)** — `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid` covers an empty directory; a loader unit test asserting `no *_resource_nodes.json files` (and yields) would mirror item-catalog negative coverage and make failure messages grep-friendly in CI logs.~~ **Done.** Added `Load_ShouldThrow_WhenNoResourceNodesJsonFiles` and `Load_ShouldThrow_WhenNoResourceYieldsJsonFiles`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: Slice 2 lens gate checks **set** equality (same as Python/NEO-57); duplicate `gatherLens` on two nodes still fails via set mismatch — acceptable for prototype.
|
||||
|
||||
- ~~Nit: `Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds` uses `/health` in **Act** (NEO-51 item-catalog host test does the same); strict AAA would move the HTTP call to **Arrange** and keep **Act** as `GetRequiredService<ResourceNodeCatalog>()` only.~~ **Done.** Host test is sync; **Act** is `GetRequiredService<ResourceNodeCatalog>()` only (`CreateClient()` in **Arrange** builds the host).
|
||||
|
||||
- Nit: No `docs/manual-qa/NEO-58.md` — consistent with plan (server boot-only, no user-facing HTTP for resource nodes); Bruno health folder is sufficient smoke.
|
||||
|
||||
- Nit: C#/Python constant drift remains a maintenance coupling; sync comments on `PrototypeSlice2ResourceNodeCatalogRules` are present (NEO-50/NEO-57 precedent).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ResourceNodeCatalog"
|
||||
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
Manual: start server from repo root (content discovery); confirm Information log with node/yield counts; point `Content__ResourceNodesDirectory` at an empty dir and confirm process refuses boot with `Resource node catalog validation failed`.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Code review — NEO-59 resource-node definition registry + DI
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-59-e3m1-resource-node-definition-registry-di` · commits `4921f2c`–`01dec2b` vs `main`
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-59 adds `IResourceNodeDefinitionRegistry` backed by the startup-loaded `ResourceNodeCatalog` (NEO-58), following the NEO-52 `IItemDefinitionRegistry` pattern exactly. The registry exposes `TryGetDefinition`, `TryGetYield`, and `GetDefinitionsInIdOrder`; DI is wired inside the existing `AddResourceNodeCatalog` extension so no `Program.cs` change is needed. Nine focused AAA tests (null id, unknown id, known id with metadata, ordinal enumeration, loader-backed fixture, host DI resolution) all pass. Docs (implementation plan, alignment table, server README, E3M1 backlog) match the landed behavior.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-59-implementation-plan.md`](../plans/NEO-59-implementation-plan.md) | **Matches** — scope, acceptance checklist, technical approach, and file list align with the diff; all four checklist items marked complete. |
|
||||
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-03 acceptance items flipped with NEO-59 landed note. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-59 registry + plan link; "Still planned" updated to drop registry. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — resource-node catalog section updated: registry guidance and "do not inject `ResourceNodeCatalog` in new game code" advisory added. |
|
||||
|
||||
Register/tracking: alignment table updated; no further register change required.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
None.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~`GetDefinitionsInIdOrder()` iterates `.Keys` to build a sorted array then does a second dictionary lookup per key. Iterating key-value pairs directly is slightly more idiomatic and avoids the double lookup:~~
|
||||
|
||||
~~```csharp~~
|
||||
~~return catalog.NodesById~~
|
||||
~~ .OrderBy(kv => kv.Key, StringComparer.Ordinal)~~
|
||||
~~ .Select(kv => kv.Value)~~
|
||||
~~ .ToList();~~
|
||||
~~```~~
|
||||
|
||||
~~The current form matches `ItemDefinitionRegistry` exactly, so consistency may outweigh the change. No action required.~~ Done.
|
||||
|
||||
- The null-id and unknown-id tests each build a fully populated `nodes`/`yields` dictionary even though the exercised code paths exit before those entries are touched. Consistent with `ItemDefinitionRegistryTests` — no action required.
|
||||
|
||||
- No `docs/manual-qa/NEO-59.md` — consistent with plan (no user-facing HTTP endpoint in this ticket; NEO-60 covers that).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
dotnet test server/NeonSprawl.Server.Tests --filter "FullyQualifiedName~ResourceNodeDefinitionRegistry"
|
||||
# Passed! Failed: 0, Passed: 9, Skipped: 0
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue