diff --git a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md index f0575fd..8878d38 100644 --- a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md +++ b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md @@ -11,9 +11,21 @@ ## 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-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). + +### `CraftResult` (server-internal, NEO-69) + +| 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 when craft HTTP lands (NEO-70). Telemetry hooks deferred to NEO-71. ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c10c93f..b2b6bdc 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -55,7 +55,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 | | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **Slice 5 client (planned):** gather feedback HUD [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-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/` | | 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. **Slice 5 client (planned):** inventory HUD [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — [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), [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 | In Progress | **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-42 landed (prep):** **`RefineActivitySkillXpGrant`** — craft/refine success should award **`refine`** XP via shared NEO-38 grant path; no craft route yet. **Slice 3 backlog in Linear:** [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-05**–**E3M2-07** open. 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-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | +| E3.M2 | In Progress | **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). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. **Slice 3 backlog in Linear:** [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-06**–**E3M2-07** open. 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-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) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 11f28d0..074fb85 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -43,7 +43,7 @@ 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 | Ready | -| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Planned | +| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | In Progress | | 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 | diff --git a/docs/plans/E3M2-prototype-backlog.md b/docs/plans/E3M2-prototype-backlog.md index 6289120..5711fe0 100644 --- a/docs/plans/E3M2-prototype-backlog.md +++ b/docs/plans/E3M2-prototype-backlog.md @@ -176,11 +176,13 @@ Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../d **Acceptance criteria** -- [ ] Successful craft removes configured inputs and adds outputs deterministically. -- [ ] Insufficient materials returns stable `reasonCode` with **no** inventory change. -- [ ] Full bag on output returns stable `reasonCode`; inputs are **not** consumed (or are rolled back). -- [ ] **`refine`** skill XP increases by **10** on success (NEO-42). -- [ ] **`make_prototype_armor`** respects equipment slot rules from E3.M3 when bag is full but equip slot empty. +- [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). --- diff --git a/docs/plans/NEO-69-implementation-plan.md b/docs/plans/NEO-69-implementation-plan.md new file mode 100644 index 0000000..7c2c02b --- /dev/null +++ b/docs/plans/NEO-69-implementation-plan.md @@ -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. diff --git a/docs/reviews/2026-05-24-NEO-69.md b/docs/reviews/2026-05-24-NEO-69.md new file mode 100644 index 0000000..e1f781c --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-69.md @@ -0,0 +1,72 @@ +# Code review — NEO-69 CraftResult engine + +**Date:** 2026-05-24 +**Scope:** Branch `NEO-69-craftresult-engine-inputs-outputs-refine-xp` · commits `bba1415`–`308de6b` vs `main` +**Base:** `main` + +## Verdict + +**Approve with nits** — implementation matches the adopted plan and E3M2-05 acceptance criteria; ready for NEO-70 HTTP wiring after minor test-layout cleanup. + +## Summary + +The branch adds **`CraftOperations.TryCraft`** and supporting **`CraftResult`** / reason-code types under `server/NeonSprawl.Server/Game/Crafting/`, plus read-only inventory helpers **`GetHeldQuantity`** and **`TrySimulateAddStack`** on **`PlayerInventoryOperations`**. The orchestration flow follows the kickoff decisions: quantity validation, recipe resolve, input pre-flight, output simulation on a cloned snapshot (no input consumption when full), commit remove → add → refine XP, and compensating rollback on output or XP failure. Eight AAA integration tests cover the primary deny paths, equipment routing, and XP rollback. Docs (plan, module snapshot, backlog, alignment, README) are updated and E3M2-05 is marked landed. Risk is low for prototype scope; the main gaps are test AAA layout (verification reads in Act) and missing coverage for **`quantity > 1`** scaling. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-69-implementation-plan.md`](../plans/NEO-69-implementation-plan.md) | **Matches** — pre-flight output strategy, craft-layer reason codes, quantity parameter, XP rollback, engine-only scope, NEO-74 cross-link, acceptance checklist complete. Helper named **`TrySimulateAddStack`** instead of plan’s **`CanPlaceStack`** — equivalent behavior. | +| [`docs/plans/E3M2-prototype-backlog.md`](../plans/E3M2-prototype-backlog.md) · **E3M2-05** | **Matches** — acceptance criteria checked; landed note present. | +| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — implementation snapshot + **`CraftResult`** field table; NEO-69 craft engine documented. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row notes NEO-69 landed. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M2 **Status** updated to **In Progress** (was **Planned**). | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** (planned) — server-authoritative inventory mutation; no client in this story. | +| [NEO-62 / NEO-64 reference — `GatherOperations`](../../server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs) | **Matches** — same **`SkillProgressionGrantOperations.TryApplyGrant`** + compensating rollback pattern; craft correctly uses **`TryApplyGrant`** (not void **`RefineActivitySkillXpGrant`**) so XP deny can roll back inventory. | +| Full-stack epic decomposition | **Matches** — NEO-74 client counterpart linked; no claim of prototype-complete player-visible craft. | + +Register/tracking: ~~optional E3.M2 **Status** → **In Progress** in dependency register when authors next touch alignment docs.~~ **Done.** E3.M2 **Status** is **In Progress** in `module_dependency_register.md`. + +## Blocking issues + +None. + +## Suggestions + +1. ~~**AAA — move verification reads to Assert** — In `CraftOperationsTests`, `TryGetSnapshot` / `GetXpTotals` after `TryCraft` sit in the **Act** block; [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert) requires outcome verification reads in **Assert** (Act = SUT invocation only). Mirror the rule even though `GatherOperationsTests` has the same legacy layout.~~ **Done.** Verification reads moved to **Assert** in all `CraftOperationsTests` methods. + +2. ~~**Test `quantity > 1` scaling** — Plan and kickoff adopt batch **`quantity`** with scaled I/O. Add one test (e.g. **`refine_scrap_standard`** at **`quantity: 2`**) asserting 10× scrap consumed and 2× plate granted.~~ **Done.** `TryCraft_RefineScrapStandard_AtQuantityTwo_ShouldScaleInputsAndOutputs`. + +3. ~~**Defensive rollback on partial input removal** — If **`TryRemoveStack`** fails mid-loop after earlier inputs were removed (e.g. **`inventory_store_missing`**), **`TryCraft`** returns deny without restoring already-removed inputs. Pre-flight makes **`Denied`** unlikely; consider compensating restore for symmetry with output/XP rollback paths.~~ **Done.** Input loop tracks **`inputsRemoved`** and calls **`CompensatingRestoreInputs`** on mid-loop deny. + +4. ~~**Optional `TrySimulateAddStack` unit test** — Plan listed an optional dedicated test in **`PlayerInventoryOperationsTests`**; one bag-full vs equipment-route case would lock pre-flight simulation to **`TryAddStack`** behavior without a full craft scenario.~~ **Done.** Two tests: bag-full deny for bag item; equipment route succeeds when bag full. + +## Nits + +- ~~Nit: **`module_dependency_register.md`** E3.M2 **Status** (**Planned**) lags **`E3_M2_RefinementAndRecipeExecution.md`** (**In Progress**).~~ **Done.** + +- ~~Nit: No test for whitespace-only **`recipeId`** (maps to **`unknown_recipe`** via trim — fine behavior, low priority).~~ **Done.** `TryCraft_ForWhitespaceRecipeId_ShouldDenyWithUnknownRecipe`. + +- Nit: Compensating helpers discard rollback outcomes (`_ = TryAddStack` / `TryRemoveStack`) — same as gather engine; acceptable for prototype. + +## Bugbot follow-up (PR #103) + +1. ~~**Output pre-flight ignores input removal** — simulate output placement on snapshot with inputs still present; false `inventory_full` when input removal would free slots.~~ **Done.** Pre-flight now **`TrySimulateRemoveStack`** all inputs on clone before output simulation; test **`TryCraft_WhenBagFullWithScrapInputs_ShouldSucceedByFreeingInputSlots`**. + +2. ~~**Missing output rollback in store-missing branch** — output-add `StoreMissing` restored inputs but not partial outputs.~~ **Done.** `CompensatingRemoveOutputs` added before input restore on that path. + +3. ~~**Rollback order on output deny** — `Denied` branch restored inputs before removing partial outputs, so `TryAddStack` could fail silently when bag was full.~~ **Done.** Swapped to remove outputs first, then restore inputs (matches `StoreMissing` and XP-failure paths). + +## Verification + +```bash +cd server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~CraftOperationsTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +**Results (2026-05-24):** all **8** `CraftOperationsTests` pass. Full suite reported **2** failures in `SkillProgressionGrantApiTests` (unrelated to this branch; NEO-69 diff does not touch that file). + +**Results (post-review follow-up):** **11** `CraftOperationsTests` + **2** new `TrySimulateAddStack` tests pass; full suite **328** tests pass. + +Manual: none required for engine-only story — NEO-70 owns HTTP / Bruno verification. diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs new file mode 100644 index 0000000..51d2129 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs @@ -0,0 +1,420 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Crafting; + +public sealed class CraftOperationsTests +{ + private const string PlayerId = "dev-local-1"; + + [Fact] + public void TryCraft_RefineScrapStandard_ShouldConsumeInputsGrantOutputAndRefineXp() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "scrap_metal_bulk", 5); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "refine_scrap_standard", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + var refineXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("refine"); + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Single(result.InputsConsumed); + Assert.Equal("scrap_metal_bulk", result.InputsConsumed[0].ItemId); + Assert.Equal(5, result.InputsConsumed[0].Quantity); + Assert.Single(result.OutputsGranted); + Assert.Equal("refined_plate_stock", result.OutputsGranted[0].ItemId); + Assert.Equal(1, result.OutputsGranted[0].Quantity); + Assert.NotNull(result.XpGrantSummary); + Assert.Equal("refine", result.XpGrantSummary!.Value.SkillId); + Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, result.XpGrantSummary.Value.Amount); + Assert.Equal(RefineSkillXpConstants.ActivitySourceKind, result.XpGrantSummary.Value.SourceKind); + Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(inventory!, "refined_plate_stock")); + Assert.Equal(10, refineXp); + } + + [Fact] + public void TryCraft_RefineScrapStandard_AtQuantityTwo_ShouldScaleInputsAndOutputs() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "scrap_metal_bulk", 10); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "refine_scrap_standard", + quantity: 2, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.True(result.Success); + Assert.Single(result.InputsConsumed); + Assert.Equal(10, result.InputsConsumed[0].Quantity); + Assert.Single(result.OutputsGranted); + Assert.Equal(2, result.OutputsGranted[0].Quantity); + Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(2, CountBagItem(inventory!, "refined_plate_stock")); + } + + [Fact] + public void TryCraft_WhenMaterialsMissing_ShouldDenyWithInsufficientMaterialsWithoutMutatingInventory() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "refine_scrap_standard", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + var refineXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("refine"); + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.InsufficientMaterials, result.ReasonCode); + Assert.Empty(result.InputsConsumed); + Assert.Empty(result.OutputsGranted); + Assert.Null(result.XpGrantSummary); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(0, refineXp); + } + + [Fact] + public void TryCraft_WhenBagFullWithScrapInputs_ShouldSucceedByFreeingInputSlots() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "scrap_metal_bulk", 5); + FillBagExcept(deps, occupiedSlots: 1); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "refine_scrap_standard", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.True(result.Success); + Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(inventory!, "refined_plate_stock")); + } + + [Fact] + public void TryCraft_MakePrototypeArmor_WhenEquipmentOccupied_ShouldDenyWithInventoryFull() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "refined_plate_stock", 8); + SeedStack(deps, "scrap_metal_bulk", 5); + SeedStack(deps, "prototype_armor_shell", 1); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "make_prototype_armor", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.InventoryFull, result.ReasonCode); + Assert.Equal(8, CountBagItem(afterInventory!, "refined_plate_stock")); + Assert.Equal(5, CountBagItem(afterInventory!, "scrap_metal_bulk")); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + } + + [Fact] + public void TryCraft_MakePrototypeArmor_WhenBagFullButEquipmentEmpty_ShouldPlaceArmorInEquipmentSlot() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "refined_plate_stock", 8); + SeedStack(deps, "scrap_metal_bulk", 5); + FillBagExcept(deps, occupiedSlots: 2); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "make_prototype_armor", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.True(result.Success); + Assert.Equal("prototype_armor_shell", inventory!.EquipmentSlots[0].ItemId); + Assert.Equal(1, inventory.EquipmentSlots[0].Quantity); + Assert.Equal(0, CountBagItem(inventory, "refined_plate_stock")); + Assert.Equal(0, CountBagItem(inventory, "scrap_metal_bulk")); + } + + [Fact] + public void TryCraft_ForUnknownRecipe_ShouldDenyWithUnknownRecipe() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "not_a_real_recipe", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.UnknownRecipe, result.ReasonCode); + } + + [Fact] + public void TryCraft_ForWhitespaceRecipeId_ShouldDenyWithUnknownRecipe() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + " ", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.UnknownRecipe, result.ReasonCode); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void TryCraft_WhenQuantityInvalid_ShouldDenyWithInvalidQuantity(int quantity) + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "scrap_metal_bulk", 5); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "refine_scrap_standard", + quantity, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.InvalidQuantity, result.ReasonCode); + Assert.Equal(5, CountBagItem(inventory!, "scrap_metal_bulk")); + } + + [Fact] + public void TryCraft_WhenProgressionStoreMissing_ShouldRollbackInventoryAndDeny() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "scrap_metal_bulk", 5); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var failingXpStore = new ProgressionStoreAlwaysMissing(); + + // Act + var result = CraftOperations.TryCraft( + PlayerId, + "refine_scrap_standard", + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + failingXpStore, + deps.LevelCurve, + deps.PerkUnlockEngine); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.ProgressionStoreMissing, result.ReasonCode); + Assert.Equal(5, CountBagItem(afterInventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(afterInventory!, "refined_plate_stock")); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + } + + private static void SeedStack(CraftTestDependencies deps, string itemId, int quantity) + { + var outcome = PlayerInventoryOperations.TryAddStack( + PlayerId, + itemId, + quantity, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind); + } + + private static void FillBagExcept(CraftTestDependencies deps, int occupiedSlots) + { + for (var i = occupiedSlots; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + var add = PlayerInventoryOperations.TryAddStack( + PlayerId, + "survey_drone_kit", + quantity: 1, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind); + } + } + + private static CraftTestDependencies ResolveCraftDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new CraftTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) + { + var total = 0; + foreach (var slot in snapshot.BagSlots) + { + if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) + { + total += slot.Quantity; + } + } + + return total; + } + + private static int TotalBagQuantity(PlayerInventorySnapshot snapshot) + { + var total = 0; + foreach (var slot in snapshot.BagSlots) + { + total += slot.Quantity; + } + + return total; + } + + private readonly record struct CraftTestDependencies( + IRecipeDefinitionRegistry RecipeRegistry, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore XpStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine); + + private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore + { + public IReadOnlyDictionary GetXpTotals(string playerId) => + new Dictionary(StringComparer.Ordinal); + + public bool TryApplyXpDelta( + string playerId, + string skillId, + int amount, + out int previousXp, + out int newXp) + { + previousXp = 0; + newXp = 0; + return false; + } + + public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) => false; + + public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary skillXpTotals) => false; + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs index 4bed3ae..fa4be35 100644 --- a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs @@ -293,6 +293,78 @@ public sealed class PlayerInventoryOperationsTests Assert.Null(outcome.Snapshot); } + [Fact] + public void TrySimulateAddStack_WhenBagFullForBagItem_ShouldReturnFalse() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var store = factory.Services.GetRequiredService(); + for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + _ = PlayerInventoryOperations.TryAddStack( + "dev-local-1", + "survey_drone_kit", + quantity: 1, + registry, + store); + } + + store.TryGetSnapshot("dev-local-1", out var snapshot); + var simulated = new PlayerInventorySnapshot + { + BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot!.BagSlots), + EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots), + }; + + // Act + var canPlace = PlayerInventoryOperations.TrySimulateAddStack( + ref simulated, + "refined_plate_stock", + quantity: 1, + registry); + + // Assert + Assert.False(canPlace); + } + + [Fact] + public void TrySimulateAddStack_WhenBagFullButEquipmentRouteAvailable_ShouldReturnTrue() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var store = factory.Services.GetRequiredService(); + for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + _ = PlayerInventoryOperations.TryAddStack( + "dev-local-1", + "survey_drone_kit", + quantity: 1, + registry, + store); + } + + store.TryGetSnapshot("dev-local-1", out var snapshot); + var simulated = new PlayerInventorySnapshot + { + BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot!.BagSlots), + EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots), + }; + + // Act + var canPlace = PlayerInventoryOperations.TrySimulateAddStack( + ref simulated, + "prototype_armor_shell", + quantity: 1, + registry); + + // Assert + Assert.True(canPlace); + Assert.Equal("prototype_armor_shell", simulated.EquipmentSlots[0].ItemId); + Assert.Equal(1, simulated.EquipmentSlots[0].Quantity); + } + private static int CountItem(PlayerInventorySnapshot snapshot, string itemId) { var total = 0; diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs b/server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs new file mode 100644 index 0000000..b78d2ee --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// One input consumed or output granted on a successful craft (NEO-69). +public readonly record struct CraftIoApplied(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs new file mode 100644 index 0000000..57f66fa --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs @@ -0,0 +1,221 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Crafting; + +/// +/// Orchestrates craft side effects: recipe resolve, inventory pre-flight, input removal, output grant, refine XP (NEO-69). +/// Craft HTTP wiring is NEO-70. +/// +public static class CraftOperations +{ + private static readonly CraftIoApplied[] EmptyIo = []; + + /// + /// Resolves one craft for and at batch . + /// Mutations commit in order: remove inputs → add outputs → refine XP; compensating rollback on output or XP failure. + /// + public static CraftResult TryCraft( + string playerId, + string recipeId, + int quantity, + IRecipeDefinitionRegistry recipeRegistry, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore xpStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) + { + if (quantity <= 0) + { + return Deny(CraftReasonCodes.InvalidQuantity); + } + + var recipeKey = recipeId.Trim(); + if (recipeKey.Length == 0 || !recipeRegistry.TryGetDefinition(recipeKey, out var recipe)) + { + return Deny(CraftReasonCodes.UnknownRecipe); + } + + if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot)) + { + return Deny(CraftReasonCodes.InventoryStoreMissing); + } + + var scaledInputs = ScaleIoRows(recipe.Inputs, quantity); + var scaledOutputs = ScaleIoRows(recipe.Outputs, quantity); + + foreach (var input in scaledInputs) + { + var held = PlayerInventoryOperations.GetHeldQuantity(snapshot, input.ItemId, itemRegistry); + if (held < input.Quantity) + { + return Deny(CraftReasonCodes.InsufficientMaterials); + } + } + + var simulated = CloneSnapshot(snapshot); + foreach (var input in scaledInputs) + { + if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry)) + { + return Deny(CraftReasonCodes.InsufficientMaterials); + } + } + + foreach (var output in scaledOutputs) + { + if (!PlayerInventoryOperations.TrySimulateAddStack(ref simulated, output.ItemId, output.Quantity, itemRegistry)) + { + return Deny(CraftReasonCodes.InventoryFull); + } + } + + var inputsRemoved = new List(scaledInputs.Length); + foreach (var input in scaledInputs) + { + var removeOutcome = PlayerInventoryOperations.TryRemoveStack( + playerId, + input.ItemId, + input.Quantity, + itemRegistry, + inventoryStore); + + if (removeOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + CompensatingRestoreInputs(playerId, inputsRemoved, itemRegistry, inventoryStore); + return Deny(CraftReasonCodes.InventoryStoreMissing); + } + + if (removeOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + CompensatingRestoreInputs(playerId, inputsRemoved, itemRegistry, inventoryStore); + return Deny(CraftReasonCodes.InsufficientMaterials); + } + + inputsRemoved.Add(input); + } + + var outputsApplied = new List(scaledOutputs.Length); + foreach (var output in scaledOutputs) + { + var addOutcome = PlayerInventoryOperations.TryAddStack( + playerId, + output.ItemId, + output.Quantity, + itemRegistry, + inventoryStore); + + if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore); + CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); + return Deny(CraftReasonCodes.InventoryStoreMissing); + } + + if (addOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore); + CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); + return Deny(CraftReasonCodes.InventoryFull); + } + + outputsApplied.Add(output); + } + + var xpOutcome = SkillProgressionGrantOperations.TryApplyGrant( + playerId, + RefineSkillXpConstants.RefineSkillId, + RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, + RefineSkillXpConstants.ActivitySourceKind, + skillRegistry, + xpStore, + levelCurve, + perkUnlockEngine); + + if (xpOutcome.Kind != SkillProgressionGrantApplyKind.Granted) + { + CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore); + CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); + + var xpReason = xpOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing + ? CraftReasonCodes.ProgressionStoreMissing + : xpOutcome.Response?.ReasonCode ?? CraftReasonCodes.ProgressionStoreMissing; + + return Deny(xpReason); + } + + return new CraftResult( + Success: true, + ReasonCode: null, + InputsConsumed: scaledInputs, + OutputsGranted: outputsApplied, + XpGrantSummary: new CraftXpGrantSummary( + RefineSkillXpConstants.RefineSkillId, + RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, + RefineSkillXpConstants.ActivitySourceKind)); + } + + private static CraftIoApplied[] ScaleIoRows(IReadOnlyList rows, int quantity) + { + var scaled = new CraftIoApplied[rows.Count]; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + scaled[i] = new CraftIoApplied(row.ItemId, row.Quantity * quantity); + } + + return scaled; + } + + private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) => + new() + { + BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots), + EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots), + }; + + private static void CompensatingRestoreInputs( + string playerId, + IReadOnlyList inputs, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore) + { + foreach (var input in inputs) + { + _ = PlayerInventoryOperations.TryAddStack( + playerId, + input.ItemId, + input.Quantity, + itemRegistry, + inventoryStore); + } + } + + private static void CompensatingRemoveOutputs( + string playerId, + IReadOnlyList outputs, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore) + { + foreach (var output in outputs) + { + _ = PlayerInventoryOperations.TryRemoveStack( + playerId, + output.ItemId, + output.Quantity, + itemRegistry, + inventoryStore); + } + } + + private static CraftResult Deny(string reasonCode) => + new( + Success: false, + ReasonCode: reasonCode, + InputsConsumed: EmptyIo, + OutputsGranted: EmptyIo, + XpGrantSummary: null); +} diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs b/server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs new file mode 100644 index 0000000..46744f0 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Crafting; + +/// Stable deny reason codes for craft operations (NEO-69). +public static class CraftReasonCodes +{ + public const string UnknownRecipe = "unknown_recipe"; + + public const string InsufficientMaterials = "insufficient_materials"; + + public const string InvalidQuantity = "invalid_quantity"; + + /// Passthrough from . + public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull; + + /// returned false. + public const string ProgressionStoreMissing = "progression_store_missing"; + + /// Player inventory store could not read or write. + public const string InventoryStoreMissing = "inventory_store_missing"; +} diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs b/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs new file mode 100644 index 0000000..7ba6fa4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// +/// Server-internal craft resolution envelope (NEO-69). May promote to wire JSON when craft HTTP lands (NEO-70). +/// NEO-71 telemetry hook sites will live in . +/// +/// Scaled inputs removed on success; empty when denied. +/// Scaled outputs added on success; empty when denied. +/// Refine skill XP applied on success; null when denied. +public readonly record struct CraftResult( + bool Success, + string? ReasonCode, + IReadOnlyList InputsConsumed, + IReadOnlyList OutputsGranted, + CraftXpGrantSummary? XpGrantSummary); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs b/server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs new file mode 100644 index 0000000..372f740 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// Compact refine XP summary on successful craft (NEO-69). +public readonly record struct CraftXpGrantSummary(string SkillId, int Amount, string SourceKind); diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs index d6b4bd4..4e73da8 100644 --- a/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs @@ -134,6 +134,107 @@ public static class PlayerInventoryOperations return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result); } + /// + /// Returns held quantity for in the catalog-routed container (read-only, NEO-69 craft pre-flight). + /// + public static int GetHeldQuantity( + PlayerInventorySnapshot snapshot, + string itemId, + IItemDefinitionRegistry registry) + { + var lookup = itemId.Trim(); + if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) + { + return 0; + } + + var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); + return CountQuantity(snapshot.GetSlots(container), def.Id); + } + + /// + /// Simulates an all-or-nothing add on a snapshot clone without persisting (NEO-69 craft output pre-flight). + /// + public static bool TrySimulateAddStack( + ref PlayerInventorySnapshot snapshot, + string itemId, + int quantity, + IItemDefinitionRegistry registry) + { + if (quantity <= 0) + { + return false; + } + + var lookup = itemId.Trim(); + if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) + { + return false; + } + + var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); + var slots = PlayerInventorySnapshot.CloneSlots(snapshot.GetSlots(container)); + var remaining = quantity; + remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax); + remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax); + + if (remaining > 0) + { + return false; + } + + snapshot = snapshot.WithSlots(container, slots); + return true; + } + + /// + /// Simulates removing from a snapshot clone without persisting (NEO-69 craft pre-flight). + /// + public static bool TrySimulateRemoveStack( + ref PlayerInventorySnapshot snapshot, + string itemId, + int quantity, + IItemDefinitionRegistry registry) + { + if (quantity <= 0) + { + return false; + } + + var lookup = itemId.Trim(); + if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) + { + return false; + } + + var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); + var slots = PlayerInventorySnapshot.CloneSlots(snapshot.GetSlots(container)); + if (CountQuantity(slots, def.Id) < quantity) + { + return false; + } + + var remaining = quantity; + for (var i = 0; i < slots.Length && remaining > 0; i++) + { + var slot = slots[i]; + if (slot.IsEmpty || !string.Equals(slot.ItemId, def.Id, StringComparison.Ordinal)) + { + continue; + } + + var take = Math.Min(remaining, slot.Quantity); + var left = slot.Quantity - take; + slots[i] = left <= 0 + ? new InventorySlotState(slot.SlotIndex, null, 0) + : new InventorySlotState(slot.SlotIndex, def.Id, left); + remaining -= take; + } + + snapshot = snapshot.WithSlots(container, slots); + return true; + } + private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode) { // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` --- diff --git a/server/README.md b/server/README.md index bef322b..36dc5b5 100644 --- a/server/README.md +++ b/server/README.md @@ -88,6 +88,21 @@ On success, **Information** logs include the resolved recipes directory path, di curl -sS -i "http://localhost:5253/game/world/recipe-definitions" ``` +## Craft engine (NEO-69) + +**`CraftOperations.TryCraft`** in **`Game/Crafting/`** resolves server-internal **`CraftResult`**: recipe lookup via **`IRecipeDefinitionRegistry`**, read-only pre-flight for inputs and output capacity (simulated adds on a snapshot clone), input removal and output grant via **`PlayerInventoryOperations`**, then **`refine`** skill XP via **`SkillProgressionGrantOperations`** + **`RefineSkillXpConstants`** (same path as **`RefineActivitySkillXpGrant`**). On output-grant or XP failure after inputs were removed, the engine **rolls back** inventory before denying. Craft HTTP (**`POST /game/players/{id}/craft`**) is **NEO-70**; client craft UI is **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**. + +| Reason code | When | +|-------------|------| +| **`unknown_recipe`** | **`recipeId`** not in **`IRecipeDefinitionRegistry`**. | +| **`insufficient_materials`** | Pre-flight input totals below scaled recipe cost. | +| **`inventory_full`** | Pre-flight output simulation could not place full scaled yield (NEO-54 all-or-nothing). | +| **`invalid_quantity`** | Craft batch **`quantity`** is **≤ 0**. | +| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory rollback). | +| **`inventory_store_missing`** | Player inventory store could not read or write. | + +Plan: [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md). Telemetry hook sites (**`item_crafted`**, **`craft_failed`**) are **NEO-71**. + ## Resource node definitions (NEO-60) **`GET /game/world/resource-node-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`gatherLens`**, **`maxGathers`**, and nested **`yield`** (`itemId`, `quantity`). Plan: [NEO-60 implementation plan](../../docs/plans/NEO-60-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-60.md`](../../docs/manual-qa/NEO-60.md); Bruno: `bruno/neon-sprawl-server/resource-node-definitions/`. @@ -353,7 +368,7 @@ When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interac ### Craft / refine hook → skill XP (NEO-42) -**E3.M2** recipe/refine success paths should call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**, which applies **10** **`refine`** skill XP with **`sourceKind: activity`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). There is no separate craft HTTP in NEO-42; verify behavior with **`POST …/skill-progression`** for **`refine`**/**`activity`** until the craft module wires the helper. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md). +**E3.M2** craft/refine success paths call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`RefineSkillXpConstants`** (**10** **`refine`** XP, **`sourceKind: activity`**) — the same stack as **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** and **`POST …/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)). **`CraftOperations.TryCraft`** ([NEO-69](#craft-engine-neo-69)) owns the live call site; craft HTTP is NEO-70. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md). ### Mission / quest reward → skill XP (NEO-43)