From 71a6761f3cb91ae0c6d3c5a62cdc20553f19d804 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 12:04:40 -0400 Subject: [PATCH] NEO-44: Grant gig XP on combat defeat via dedicated store. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add IPlayerGigProgressionStore (in-memory + Postgres V007), GET /gig-progression snapshot, and CombatDefeatGigXpGrant on cast accept when targetDefeated — 25 XP to breach, outside E2.M2 skill XP. Includes tests, Bruno, README, manual QA, and plan reconciliation. --- ...Get gig progression after defeat spine.bru | 109 ++++++++++++++++ .../gig-progression/Get gig progression.bru | 32 +++++ .../gig-progression/folder.bru | 10 ++ .../modules/E5_M1_CombatRulesEngine.md | 4 +- ...umentation_and_implementation_alignment.md | 4 +- docs/manual-qa/NEO-44.md | 74 +++++++++++ docs/plans/E5M1-prototype-backlog.md | 8 +- docs/plans/NEO-44-implementation-plan.md | 17 ++- .../Game/AbilityInput/AbilityCastApiTests.cs | 51 ++++++++ .../Game/Gigs/CombatDefeatGigXpGrantTests.cs | 45 +++++++ ...ressionGrantPersistenceIntegrationTests.cs | 121 ++++++++++++++++++ .../Gigs/GigProgressionSnapshotApiTests.cs | 48 +++++++ .../InMemoryWebApplicationFactory.cs | 3 + .../Game/AbilityInput/AbilityCastApi.cs | 8 ++ .../Game/Gigs/CombatDefeatGigXpGrant.cs | 19 +++ .../Game/Gigs/GigProgressionConstants.cs | 13 ++ ...gProgressionServiceCollectionExtensions.cs | 22 ++++ .../Game/Gigs/GigProgressionSnapshotApi.cs | 49 +++++++ .../Game/Gigs/GigProgressionSnapshotDtos.cs | 35 +++++ .../Game/Gigs/IPlayerGigProgressionStore.cs | 14 ++ .../Gigs/InMemoryPlayerGigProgressionStore.cs | 80 ++++++++++++ .../Gigs/PostgresGigProgressionBootstrap.cs | 39 ++++++ .../Gigs/PostgresPlayerGigProgressionStore.cs | 121 ++++++++++++++++++ server/NeonSprawl.Server/Program.cs | 3 + server/README.md | 18 ++- .../V007__player_gig_progression.sql | 10 ++ 26 files changed, 944 insertions(+), 13 deletions(-) create mode 100644 bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru create mode 100644 bruno/neon-sprawl-server/gig-progression/Get gig progression.bru create mode 100644 bruno/neon-sprawl-server/gig-progression/folder.bru create mode 100644 docs/manual-qa/NEO-44.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/CombatDefeatGigXpGrant.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/GigProgressionConstants.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/GigProgressionServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotApi.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/IPlayerGigProgressionStore.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/InMemoryPlayerGigProgressionStore.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/PostgresGigProgressionBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Gigs/PostgresPlayerGigProgressionStore.cs create mode 100644 server/db/migrations/V007__player_gig_progression.sql diff --git a/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru b/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru new file mode 100644 index 0000000..a41fee6 --- /dev/null +++ b/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru @@ -0,0 +1,109 @@ +meta { + name: GET gig progression after defeat spine (NEO-44) + type: http + seq: 10 +} + +docs { + NEO-44 AC spine: pre-request runs four pulse casts on prototype_target_alpha (slot 1, 3.2s between). + Asserts breach xp === 25 and salvage/refine skill xp unchanged from pre-defeat baseline. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + const skillBefore = await axios.get( + `${baseUrl}/game/players/${playerId}/skill-progression`, + jsonHeaders, + ); + bru.setVar("neo44SalvageXpBefore", String(skillBefore.data.skills.find((x) => x.id === "salvage").xp)); + bru.setVar("neo44RefineXpBefore", String(skillBefore.data.skills.find((x) => x.id === "refine").xp)); + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: 1, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_target_alpha" }, + jsonHeaders, + ); + + const castBody = { + schemaVersion: 1, + slotIndex: 1, + abilityId: "prototype_pulse", + targetId: "prototype_target_alpha", + }; + + for (let i = 0; i < 4; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error(`neo44 cast ${i + 1} failed: ${JSON.stringify(body)}`); + } + if (i < 3) { + await sleep(3200); + } + } +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/gig-progression + body: none + auth: none +} + +script:post-response { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const skillAfter = await axios.get( + `${baseUrl}/game/players/${playerId}/skill-progression`, + ); + bru.setVar("neo44SkillAfterJson", JSON.stringify(skillAfter.data)); +} + +tests { + test("breach gig xp is 25 after alpha defeat", function () { + const body = res.getBody(); + expect(res.getStatus()).to.equal(200); + expect(body.mainGigId).to.equal("breach"); + const breach = body.gigs.find((x) => x.id === "breach"); + expect(breach).to.be.an("object"); + expect(breach.xp).to.equal(25); + expect(breach.level).to.equal(1); + }); + + test("salvage and refine skill xp unchanged by combat defeat", function () { + const skillAfter = JSON.parse(bru.getVar("neo44SkillAfterJson")); + const salvageBefore = Number(bru.getVar("neo44SalvageXpBefore")); + const refineBefore = Number(bru.getVar("neo44RefineXpBefore")); + const salvageAfter = skillAfter.skills.find((x) => x.id === "salvage").xp; + const refineAfter = skillAfter.skills.find((x) => x.id === "refine").xp; + expect(salvageAfter).to.equal(salvageBefore); + expect(refineAfter).to.equal(refineBefore); + }); +} diff --git a/bruno/neon-sprawl-server/gig-progression/Get gig progression.bru b/bruno/neon-sprawl-server/gig-progression/Get gig progression.bru new file mode 100644 index 0000000..f65a9a5 --- /dev/null +++ b/bruno/neon-sprawl-server/gig-progression/Get gig progression.bru @@ -0,0 +1,32 @@ +meta { + name: GET gig progression + type: http + seq: 1 +} + +docs { + NEO-44: prototype main gig breach; xp 0 on fresh in-memory server (Postgres may retain prior totals until reset). +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/gig-progression + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1 and breach row", 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.playerId).to.equal("dev-local-1"); + expect(body.mainGigId).to.equal("breach"); + expect(body.gigs).to.be.an("array"); + expect(body.gigs.length).to.equal(1); + const breach = body.gigs[0]; + expect(breach.id).to.equal("breach"); + expect(breach.xp).to.be.a("number"); + expect(breach.level).to.equal(1); + }); +} diff --git a/bruno/neon-sprawl-server/gig-progression/folder.bru b/bruno/neon-sprawl-server/gig-progression/folder.bru new file mode 100644 index 0000000..9557925 --- /dev/null +++ b/bruno/neon-sprawl-server/gig-progression/folder.bru @@ -0,0 +1,10 @@ +meta { + name: Gig progression folder + seq: 1 +} + +docs { + NEO-44: read-only gig XP snapshot (combat defeat path — not E2.M2 skill XP). + Run order: Get gig progression → Get gig progression after defeat spine (depends on ability-cast defeat spine setup). + Cross-link: bruno/neon-sprawl-server/ability-cast/ +} diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index ea571d8..987abbb 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M1 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09+ pending (see [dependency register](module_dependency_register.md)) | +| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-10 [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat landed; E5M1-09+ pending (see [dependency register](module_dependency_register.md)) | | **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) | ## Purpose @@ -60,6 +60,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a **E5M1-08 (NEO-83):** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs in `Game/Combat/`; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. +**E5M1-10 (NEO-44):** **`CombatDefeatGigXpGrant`** on cast accept when **`targetDefeated`** — **25** gig XP to prototype main gig **`breach`** via **`IPlayerGigProgressionStore`** (not E2.M2 skill XP); **`GET /game/players/{id}/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression snapshot (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. + ## Linear backlog (decomposed) Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index f91713e..e5e3f1e 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -52,11 +52,11 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) | | 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 | +| 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). **NEO-44 landed (E5.M1 / combat path):** combat defeat grants **gig XP** via **`CombatDefeatGigXpGrant`** + **`IPlayerGigProgressionStore`** — **not** E2.M2 skill XP; **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **Epic 2 Slice 3 integration complete** for documented payout paths (gather/craft **`activity`**, mission **`mission_reward`**, combat → gig XP on E5.M1). 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), [NEO-44](../../plans/NEO-44-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-43 | | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | -| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-09** [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | +| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-09** [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | --- diff --git a/docs/manual-qa/NEO-44.md b/docs/manual-qa/NEO-44.md new file mode 100644 index 0000000..70b091a --- /dev/null +++ b/docs/manual-qa/NEO-44.md @@ -0,0 +1,74 @@ +# Manual QA — NEO-44 (combat defeat → gig XP, not skill XP) + +Reference: [implementation plan](../plans/NEO-44-implementation-plan.md), [NEO-82 plan](../plans/NEO-82-implementation-plan.md) (cast defeat spine), [progression.md](../game-design/progression.md). + +## Preconditions + +- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`). +- Player **`dev-local-1`** (default seed). +- For a clean gig XP baseline on in-memory mode, restart the server before the defeat spine. + +```bash +BASE=http://localhost:5253 +ID=dev-local-1 +``` + +## Automated coverage + +- `GigProgressionSnapshotApiTests`, `CombatDefeatGigXpGrantTests`, `AbilityCastApiTests.PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression` +- Bruno: `bruno/neon-sprawl-server/gig-progression/` + +## 1. Fresh gig snapshot + +```bash +curl -sS "${BASE}/game/players/${ID}/gig-progression" +``` + +Expect **`schemaVersion` 1**, **`mainGigId` `"breach"`**, one gig row with **`xp` 0** and **`level` 1** (fresh in-memory server). + +## 2. Baseline skill snapshot (control) + +```bash +curl -sS "${BASE}/game/players/${ID}/skill-progression" | jq '.skills[] | select(.id=="salvage" or .id=="refine")' +``` + +Note **`salvage`** and **`refine`** **`xp`** values — combat defeat must not change them. + +## 3. Defeat `prototype_target_alpha` (four pulses) + +Move in range, bind pulse, lock alpha, cast four times with ~3.2s between casts (or run Bruno **`Get gig progression after defeat spine.bru`**). + +Minimal curl sequence (slot 0): + +```bash +curl -sS -X POST "${BASE}/game/players/${ID}/hotbar-loadout" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"slots":[{"slotIndex":0,"abilityId":"prototype_pulse"}]}' + +curl -sS -X POST "${BASE}/game/players/${ID}/target/select" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}' + +# Repeat cast (respect 3s pulse cooldown between each): +curl -sS -X POST "${BASE}/game/players/${ID}/ability-cast" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"slotIndex":0,"abilityId":"prototype_pulse","targetId":"prototype_target_alpha"}' +``` + +Fourth accept should show **`targetDefeated": true`**. Fifth cast should deny **`target_defeated`**. + +## 4. Verify gig XP granted once + +```bash +curl -sS "${BASE}/game/players/${ID}/gig-progression" +``` + +Expect **`breach`** row **`xp` 25** (one defeat transition). + +## 5. Verify skill XP unchanged + +Re-run step 2 — **`salvage`** and **`refine`** **`xp`** must match the pre-defeat baseline. + +## Invariant + +Combat encounters grant **gig XP on main gig only** ([progression.md](../game-design/progression.md)). Mission/scripted skill payouts use **`mission_reward`** via E2.M2 ([NEO-43](./NEO-43.md)) — not the combat defeat path. diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index a8279ff..113232c 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -311,11 +311,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/ **Acceptance criteria** -- [ ] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition. -- [ ] Skill progression snapshot unchanged by combat defeat in tests. -- [ ] Documented invariant matches [progression.md](../game-design/progression.md). +- [x] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition. +- [x] Skill progression snapshot unchanged by combat defeat in tests. +- [x] Documented invariant matches [progression.md](../game-design/progression.md). -**Note:** Issue pre-dates this backlog; slug **E5M1-10** maps to existing **[NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)**. +**Landed ([NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)):** **`IPlayerGigProgressionStore`** + **`CombatDefeatGigXpGrant`** on cast accept when **`targetDefeated`**; **`GET …/gig-progression`**; main gig **`breach`**, **25** XP per defeat; plan [NEO-44-implementation-plan.md](NEO-44-implementation-plan.md); manual QA [`NEO-44`](../manual-qa/NEO-44.md). --- diff --git a/docs/plans/NEO-44-implementation-plan.md b/docs/plans/NEO-44-implementation-plan.md index 98b99c3..b843ea8 100644 --- a/docs/plans/NEO-44-implementation-plan.md +++ b/docs/plans/NEO-44-implementation-plan.md @@ -49,11 +49,11 @@ ## Acceptance criteria checklist -- [ ] Combat awards **gig** XP (main gig **`breach`**); no default skill XP grant from encounters. -- [ ] Mission/scripted skill payouts remain on the **mission_reward** / reward-router path. -- [ ] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition. -- [ ] Skill progression snapshot unchanged by combat defeat in tests. -- [ ] Documented invariant matches [progression.md](../game-design/progression.md). +- [x] Combat awards **gig** XP (main gig **`breach`**); no default skill XP grant from encounters. +- [x] Mission/scripted skill payouts remain on the **mission_reward** / reward-router path. +- [x] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition. +- [x] Skill progression snapshot unchanged by combat defeat in tests. +- [x] Documented invariant matches [progression.md](../game-design/progression.md). ## Technical approach @@ -162,3 +162,10 @@ Bruno defeat spine + manual QA complement automated coverage. - Dedicated **`GET /game/players/{id}/gig-progression`**; no cast wire extension. - Postgres + in-memory gig store (V007); **no** E2.M2 skill grant on combat defeat. - **`docs/manual-qa/NEO-44.md`** for server verification spine. + +## Reconciliation (implementation) + +- **`Game/Gigs/`** — **`IPlayerGigProgressionStore`**, in-memory + Postgres (V007), **`CombatDefeatGigXpGrant`**, **`GET …/gig-progression`**. +- **`AbilityCastApi`** — best-effort gig XP grant when **`combatResult.TargetDefeated`** on accept path. +- Tests: **`GigProgressionSnapshotApiTests`**, **`CombatDefeatGigXpGrantTests`**, extended **`AbilityCastApiTests`** defeat + skill unchanged; optional Postgres persistence test. +- Bruno `gig-progression/`; **`server/README.md`**, E5M1 backlog, E5_M1 module, alignment register updated. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 99b0ae7..bbb8685 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -3,7 +3,9 @@ using System.Net.Http.Json; using System.Text; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; using Xunit; @@ -626,6 +628,55 @@ public sealed class AbilityCastApiTests Assert.Null(fifth.CombatResolution); } + [Fact] + public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var skillBefore = await client.GetFromJsonAsync( + "/game/players/dev-local-1/skill-progression"); + Assert.NotNull(skillBefore); + var salvageBefore = skillBefore!.Skills!.Single(static s => s.Id == "salvage"); + var refineBefore = skillBefore.Skills!.Single(static s => s.Id == "refine"); + var cast = PulseCastRequest(); + + // Act + for (var i = 0; i < 3; i++) + { + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + response.EnsureSuccessStatusCode(); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + + var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + var gigSnapshot = await client.GetFromJsonAsync( + "/game/players/dev-local-1/gig-progression"); + var skillAfter = await client.GetFromJsonAsync( + "/game/players/dev-local-1/skill-progression"); + + // Assert + var fourth = await fourthResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(fourth); + Assert.True(fourth!.CombatResolution!.TargetDefeated); + var fifth = await fifthResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(fifth); + Assert.False(fifth!.Accepted); + Assert.NotNull(gigSnapshot); + var breach = Assert.Single(gigSnapshot!.Gigs!); + Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, breach.Xp); + Assert.NotNull(skillAfter); + var salvageAfter = skillAfter!.Skills!.Single(static s => s.Id == "salvage"); + var refineAfter = skillAfter.Skills!.Single(static s => s.Id == "refine"); + Assert.Equal(salvageBefore.Xp, salvageAfter.Xp); + Assert.Equal(refineBefore.Xp, refineAfter.Xp); + } + [Fact] public async Task PostAbilityCast_ShouldUseCatalogCooldown_WhenGuardCooldownLongerThanPulse() { diff --git a/server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs b/server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs new file mode 100644 index 0000000..e6770ac --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Gigs; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gigs; + +/// NEO-44: combat defeat hook → breach gig XP via dedicated store (not E2.M2). +public sealed class CombatDefeatGigXpGrantTests +{ + [Fact] + public async Task GrantOnCombatDefeat_WhenPlayerKnown_ShouldIncreaseBreachXp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + _ = factory.CreateClient(); + using var scope = factory.Services.CreateScope(); + var gigStore = scope.ServiceProvider.GetRequiredService(); + + // Act + CombatDefeatGigXpGrant.GrantOnCombatDefeat("dev-local-1", gigStore); + + // Assert + var totals = gigStore.GetXpTotals("dev-local-1"); + Assert.True(totals.TryGetValue(GigProgressionConstants.PrototypeMainGigId, out var xp)); + Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, xp); + } + + [Fact] + public async Task GrantOnCombatDefeat_WhenPlayerUnknown_ShouldNotThrow() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + _ = factory.CreateClient(); + using var scope = factory.Services.CreateScope(); + var gigStore = scope.ServiceProvider.GetRequiredService(); + + // Act + var exception = Record.Exception(() => + CombatDefeatGigXpGrant.GrantOnCombatDefeat("missing-player", gigStore)); + + // Assert + Assert.Null(exception); + Assert.Empty(gigStore.GetXpTotals("missing-player")); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs new file mode 100644 index 0000000..17f4b66 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs @@ -0,0 +1,121 @@ +using System.Linq; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Gigs; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gigs; + +[Collection("Postgres integration")] +public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + [RequirePostgresFact] + public async Task DefeatGrantThenGetAcrossNewFactory_ShouldPersistGigXp() + { + // Arrange + await ResetGigProgressionTableAsync(); + var cast = new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + + // Act — defeat alpha through first host (four pulses) + using (var firstClient = Factory.CreateClient()) + { + await firstClient.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], + }); + await firstClient.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + for (var i = 0; i < 4; i++) + { + var response = await firstClient.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + response.EnsureSuccessStatusCode(); + if (i < 3) + { + await Task.Delay(TimeSpan.FromSeconds(3.2)); + } + } + } + + await using var secondFactory = new PostgresWebApplicationFactory(); + using var secondClient = secondFactory.CreateClient(); + var snapshot = await secondClient.GetFromJsonAsync( + "/game/players/dev-local-1/gig-progression"); + + // Assert + Assert.NotNull(snapshot); + var breach = Assert.Single(snapshot!.Gigs!); + Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, breach.Xp); + } + + private async Task ResetGigProgressionTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; + + var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + var gigDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V007__player_gig_progression.sql"); + if (!File.Exists(positionDdlPath)) + { + throw new FileNotFoundException($"Test DDL not found at '{positionDdlPath}'.", positionDdlPath); + } + + if (!File.Exists(gigDdlPath)) + { + throw new FileNotFoundException($"Test DDL not found at '{gigDdlPath}'.", gigDdlPath); + } + + var positionDdl = await File.ReadAllTextAsync(positionDdlPath); + var gigDdl = await File.ReadAllTextAsync(gigDdlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using (var applyPosition = new NpgsqlCommand(positionDdl, conn)) + { + await applyPosition.ExecuteNonQueryAsync(); + } + + await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn)) + { + await truncate.ExecuteNonQueryAsync(); + } + + PostgresPositionBootstrap.SeedDevPlayer(conn, options); + + await using (var applyGig = new NpgsqlCommand(gigDdl, conn)) + { + await applyGig.ExecuteNonQueryAsync(); + } + + await using (var truncateGig = new NpgsqlCommand("TRUNCATE player_gig_progression;", conn)) + { + await truncateGig.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs new file mode 100644 index 0000000..ed11013 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Gigs; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gigs; + +public sealed class GigProgressionSnapshotApiTests +{ + [Fact] + public async Task GetGigProgression_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/gig-progression"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetGigProgression_ShouldReturnSchemaV1_WithDefaultXpAndLevel_ForPrototypeMainGig() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/dev-local-1/gig-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(GigProgressionSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + Assert.Equal(GigProgressionConstants.PrototypeMainGigId, body.MainGigId); + Assert.NotNull(body.Gigs); + var breach = Assert.Single(body.Gigs!); + Assert.Equal(GigProgressionConstants.PrototypeMainGigId, breach.Id); + Assert.Equal(0, breach.Xp); + Assert.Equal(GigProgressionConstants.PrototypeFixedLevel, breach.Level); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index dc283ad..65b2821 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -8,6 +8,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Mastery; @@ -62,6 +63,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 0b924b5..7c830cb 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -1,5 +1,6 @@ using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.World; @@ -15,6 +16,7 @@ namespace NeonSprawl.Server.Game.AbilityInput; /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON AbilityCastResponse). /// NEO-32: JSON deny on_cooldown when the slot is inside the server cooldown window. /// NEO-82: after E1 gates pass, invokes ; per-ability catalog cooldown on successful resolve. +/// NEO-44: on accept when is true, best-effort gig XP grant via (not E2.M2 skill XP). /// public static class AbilityCastApi { @@ -61,6 +63,7 @@ public static class AbilityCastApi IPlayerAbilityCooldownStore cooldowns, IAbilityDefinitionRegistry abilities, ICombatEntityHealthStore healthStore, + IPlayerGigProgressionStore gigStore, TimeProvider clock) => { // NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body. @@ -224,6 +227,11 @@ public static class AbilityCastApi now, TimeSpan.FromSeconds(definition.CooldownSeconds)); + if (combatResult.TargetDefeated) + { + CombatDefeatGigXpGrant.GrantOnCombatDefeat(id.Trim(), gigStore); + } + // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). return JsonAbilityCast( diff --git a/server/NeonSprawl.Server/Game/Gigs/CombatDefeatGigXpGrant.cs b/server/NeonSprawl.Server/Game/Gigs/CombatDefeatGigXpGrant.cs new file mode 100644 index 0000000..5e1ded3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/CombatDefeatGigXpGrant.cs @@ -0,0 +1,19 @@ +namespace NeonSprawl.Server.Game.Gigs; + +/// +/// NEO-44: combat encounter defeat grants gig XP on the prototype main gig only — not E2.M2 skill XP. +/// Outcome is ignored on the cast accept path (best-effort apply); inspect store/GET when verification matters. +/// +public static class CombatDefeatGigXpGrant +{ + /// Applies one combat-defeat grant to the prototype main gig when is writable. + public static void GrantOnCombatDefeat(string playerId, IPlayerGigProgressionStore gigStore) + { + _ = gigStore.TryApplyXpDelta( + playerId, + GigProgressionConstants.PrototypeMainGigId, + GigProgressionConstants.CombatDefeatXpAmount, + out _, + out _); + } +} diff --git a/server/NeonSprawl.Server/Game/Gigs/GigProgressionConstants.cs b/server/NeonSprawl.Server/Game/Gigs/GigProgressionConstants.cs new file mode 100644 index 0000000..1b245f2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/GigProgressionConstants.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Gigs; + +/// NEO-44: prototype combat defeat → main gig XP (outside E2.M2 skill progression). +public static class GigProgressionConstants +{ + /// All players implicitly main this gig until loadout/hub swap lands. + public const string PrototypeMainGigId = "breach"; + + public const int CombatDefeatXpAmount = 25; + + /// Fixed until gig level-curve content exists (NEO-37 pre-curve pattern). + public const int PrototypeFixedLevel = 1; +} diff --git a/server/NeonSprawl.Server/Game/Gigs/GigProgressionServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Gigs/GigProgressionServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd255d3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/GigProgressionServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Gigs; + +/// Registers gig XP persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-44). +public static class GigProgressionServiceCollectionExtensions +{ + public static IServiceCollection AddGigProgressionStore(this IServiceCollection services, IConfiguration configuration) + { + var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotApi.cs b/server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotApi.cs new file mode 100644 index 0000000..381e44d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotApi.cs @@ -0,0 +1,49 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Gigs; + +/// Maps GET /game/players/{{id}}/gig-progression — read snapshot (NEO-44). +public static class GigProgressionSnapshotApi +{ + public static WebApplication MapGigProgressionSnapshotApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/gig-progression", + (string id, IPositionStateStore positions, IPlayerGigProgressionStore gigStore) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + return Results.Json(BuildSnapshot(trimmedId, gigStore)); + }); + + return app; + } + + internal static GigProgressionSnapshotResponse BuildSnapshot( + string playerId, + IPlayerGigProgressionStore store) + { + var xpByGig = store.GetXpTotals(playerId); + xpByGig.TryGetValue(GigProgressionConstants.PrototypeMainGigId, out var xp); + + return new GigProgressionSnapshotResponse + { + PlayerId = playerId, + MainGigId = GigProgressionConstants.PrototypeMainGigId, + Gigs = + [ + new GigProgressionRowJson + { + Id = GigProgressionConstants.PrototypeMainGigId, + Xp = xp, + Level = GigProgressionConstants.PrototypeFixedLevel, + }, + ], + SchemaVersion = GigProgressionSnapshotResponse.CurrentSchemaVersion, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotDtos.cs b/server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotDtos.cs new file mode 100644 index 0000000..d846e4c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotDtos.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Gigs; + +/// JSON body for GET /game/players/{{id}}/gig-progression (NEO-44). +public sealed class GigProgressionSnapshotResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// Prototype main gig until loadout/hub swap lands. + [JsonPropertyName("mainGigId")] + public required string MainGigId { get; init; } + + [JsonPropertyName("gigs")] + public required IReadOnlyList Gigs { get; init; } +} + +/// XP and level for one gig row in the prototype snapshot. +public sealed class GigProgressionRowJson +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("xp")] + public int Xp { get; init; } + + [JsonPropertyName("level")] + public int Level { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Gigs/IPlayerGigProgressionStore.cs b/server/NeonSprawl.Server/Game/Gigs/IPlayerGigProgressionStore.cs new file mode 100644 index 0000000..1852afc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/IPlayerGigProgressionStore.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Gigs; + +/// Persisted totals of gig XP per player (NEO-44); level is derived when gig curves exist. +public interface IPlayerGigProgressionStore +{ + /// Keyed by gig id; omitted gigs imply XP 0. + IReadOnlyDictionary GetXpTotals(string playerId); + + /// + /// Adds to for . + /// Returns false when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from player_position). + /// + bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp); +} diff --git a/server/NeonSprawl.Server/Game/Gigs/InMemoryPlayerGigProgressionStore.cs b/server/NeonSprawl.Server/Game/Gigs/InMemoryPlayerGigProgressionStore.cs new file mode 100644 index 0000000..cecd71b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/InMemoryPlayerGigProgressionStore.cs @@ -0,0 +1,80 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Gigs; + +/// Thread-safe in-memory gig progression; seeds the configured dev player with an empty XP map (NEO-44). +public sealed class InMemoryPlayerGigProgressionStore(IOptions options) : IPlayerGigProgressionStore +{ + private readonly ConcurrentDictionary> byPlayer = CreateInitialMap(options.Value); + + private readonly ConcurrentDictionary playerLocks = new(StringComparer.OrdinalIgnoreCase); + + private static ConcurrentDictionary> CreateInitialMap(GamePositionOptions o) + { + var id = NormalizePlayerId(o.DevPlayerId); + var map = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + map[id] = new ConcurrentDictionary(StringComparer.Ordinal); + return map; + } + + /// + public IReadOnlyDictionary GetXpTotals(string playerId) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return ReadOnlyDic.Empty; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + return new Dictionary(inner, StringComparer.Ordinal); + } + } + + /// + public bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp) + { + previousXp = 0; + newXp = 0; + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || gigId.Trim().Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + var gid = gigId.Trim(); + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + inner.TryGetValue(gid, out var prev); + previousXp = prev; + newXp = prev + amount; + if (newXp < 0) + { + return false; + } + + inner[gid] = newXp; + return true; + } + } + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } + + private static class ReadOnlyDic + { + internal static readonly IReadOnlyDictionary Empty = new Dictionary(StringComparer.Ordinal); + } +} diff --git a/server/NeonSprawl.Server/Game/Gigs/PostgresGigProgressionBootstrap.cs b/server/NeonSprawl.Server/Game/Gigs/PostgresGigProgressionBootstrap.cs new file mode 100644 index 0000000..905f6f4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/PostgresGigProgressionBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Gigs; + +/// Applies NEO-44 gig progression table DDL once per process. +public static class PostgresGigProgressionBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V007__player_gig_progression.sql"); + + private static readonly object SchemaGate = new(); + + private static int _schemaReady; + + public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + lock (SchemaGate) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"NEO-44 DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + System.Threading.Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Gigs/PostgresPlayerGigProgressionStore.cs b/server/NeonSprawl.Server/Game/Gigs/PostgresPlayerGigProgressionStore.cs new file mode 100644 index 0000000..d58101b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gigs/PostgresPlayerGigProgressionStore.cs @@ -0,0 +1,121 @@ +namespace NeonSprawl.Server.Game.Gigs; + +/// PostgreSQL-backed gig XP totals keyed by normalized player id (NEO-44). +public sealed class PostgresPlayerGigProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerGigProgressionStore +{ + /// + public IReadOnlyDictionary GetXpTotals(string playerId) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + PostgresGigProgressionBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT gig_id, xp + FROM player_gig_progression + WHERE player_id = @pid; + """, + conn); + cmd.Parameters.AddWithValue("pid", norm); + var map = new Dictionary(StringComparer.Ordinal); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + map[reader.GetString(0)] = reader.GetInt32(1); + } + + return map; + } + + /// + public bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp) + { + previousXp = 0; + newXp = 0; + var norm = NormalizePlayerId(playerId); + var gid = gigId.Trim(); + if (norm.Length == 0 || gid.Length == 0) + { + return false; + } + + PostgresGigProgressionBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, norm, tx)) + { + tx.Rollback(); + return false; + } + + using (var sel = new Npgsql.NpgsqlCommand( + """ + SELECT xp + FROM player_gig_progression + WHERE player_id = @pid AND gig_id = @gid + LIMIT 1 + FOR UPDATE; + """, + conn, + tx)) + { + sel.Parameters.AddWithValue("pid", norm); + sel.Parameters.AddWithValue("gid", gid); + var scalar = sel.ExecuteScalar(); + previousXp = scalar is null || Equals(scalar, DBNull.Value) + ? 0 + : scalar is int xi + ? xi + : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); + } + + newXp = previousXp + amount; + if (newXp < 0) + { + tx.Rollback(); + return false; + } + + using var upsert = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_gig_progression (player_id, gig_id, xp, updated_at) + VALUES (@pid, @gid, @xp, now()) + ON CONFLICT (player_id, gig_id) + DO UPDATE SET xp = EXCLUDED.xp, updated_at = now(); + """, + conn, + tx); + upsert.Parameters.AddWithValue("pid", norm); + upsert.Parameters.AddWithValue("gid", gid); + upsert.Parameters.AddWithValue("xp", newXp); + upsert.ExecuteNonQuery(); + tx.Commit(); + return true; + } + + private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx) + { + using var cmd = new Npgsql.NpgsqlCommand( + "SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;", + conn, + tx); + cmd.Parameters.AddWithValue("pid", playerIdNormalized); + return cmd.ExecuteScalar() is not null; + } + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index b1889ed..f10b458 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -13,6 +14,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddSkillProgressionStore(builder.Configuration); +builder.Services.AddGigProgressionStore(builder.Configuration); builder.Services.AddPerkStateStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); @@ -54,6 +56,7 @@ app.MapResourceNodeDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); +app.MapGigProgressionSnapshotApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || diff --git a/server/README.md b/server/README.md index 7d74509..6ff820a 100644 --- a/server/README.md +++ b/server/README.md @@ -327,6 +327,8 @@ curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression" **`POST /game/players/{id}/skill-progression`** accepts a versioned body (`schemaVersion` **1**, **`skillId`**, **`amount`** positive int, **`sourceKind`**) and validates the skill against **`ISkillDefinitionRegistry`**, then validates **`sourceKind`** against that skill’s **`allowedXpSourceKinds`** (case-insensitive). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persisted XP totals use the same **[NEO-29-style](#position-persistence-neo-8) split** as hotbar: **`player_skill_progression`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V003__player_skill_progression.sql`](../db/migrations/V003__player_skill_progression.sql)), otherwise the in-memory fallback (dev bucket only, matching hotbar semantics). +**Combat encounters do not grant skill XP by default** — prototype defeat awards **gig XP** on the main gig via **[NEO-44](#gig-progression-snapshot-neo-44)** ([`docs/game-design/progression.md`](../docs/game-design/progression.md)). Gather/craft use **`activity`**; mission payouts use **`mission_reward`** ([NEO-43](#mission--quest-skill-xp-neo-43)). + **Structured responses (HTTP 200):** **`granted`** (`true`/`false`), optional **`reasonCode`** on deny, plus **`progression`** mirroring the GET snapshot. Stable deny codes: **`unknown_skill`**, **`source_kind_not_allowed`**, **`invalid_amount`**. On **`granted: true`**, **`levelUps`** lists skills whose level increased on this request (`skillId`, `previousLevel`, `newLevel`). If a single grant crosses **multiple** level boundaries (e.g. a large XP jump under the placeholder curve), the server emits **one** object per skill with **`previousLevel`** and **`newLevel`** set to the **start and end** levels after the grant — not one array entry per boundary crossed. A future issue can add per-step events if product needs them. Plan: [NEO-38 implementation plan](../../docs/plans/NEO-38-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-38.md`](../../docs/manual-qa/NEO-38.md); Bruno: `bruno/neon-sprawl-server/skill-progression/` (happy **POST** + deny smoke). @@ -337,6 +339,18 @@ curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/skill-progre -d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"activity"}' ``` +## Gig progression snapshot (NEO-44) + +**`GET /game/players/{id}/gig-progression`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`mainGigId`**, **`gigs`**) with prototype **`mainGigId`** **`breach`** and one row (**`id`**, **`xp`**, **`level`**). **`level`** is fixed at **1** until gig level-curve content exists; **`xp`** accumulates from combat defeat grants only in this slice. Unknown players receive **404** (same gate as skill progression GET). + +**Combat defeat → gig XP:** when **`POST …/ability-cast`** accepts with **`combatResolution.targetDefeated: true`**, the server best-effort applies **25** gig XP to **`breach`** via **`CombatDefeatGigXpGrant`** — **not** **`SkillProgressionGrantOperations`**. Re-hits on a defeated target (`target_defeated` deny) grant no additional gig XP. Persisted totals use the NEO-29-style split: **`player_gig_progression`** in PostgreSQL when configured (DDL [`V007__player_gig_progression.sql`](../db/migrations/V007__player_gig_progression.sql)), otherwise in-memory. + +Plan: [NEO-44 implementation plan](../../docs/plans/NEO-44-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-44.md`](../../docs/manual-qa/NEO-44.md); Bruno: `bruno/neon-sprawl-server/gig-progression/`. + +```bash +curl -sS -i "http://localhost:5253/game/players/dev-local-1/gig-progression" +``` + ## Position persistence (NEO-8) When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request). @@ -597,7 +611,9 @@ Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-2 | `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). | | `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). | | `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful combat resolve (NEO-32). | -| `target_defeated` | Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. | +| `target_defeated` | Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. No gig XP grant (NEO-44). | + +**NEO-44:** on accept when **`combatResolution.targetDefeated`** is **`true`**, the server grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** (outside E2.M2). Verify with **`GET …/gig-progression`**; cast response has no gig block in this slice. Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above). diff --git a/server/db/migrations/V007__player_gig_progression.sql b/server/db/migrations/V007__player_gig_progression.sql new file mode 100644 index 0000000..7f34675 --- /dev/null +++ b/server/db/migrations/V007__player_gig_progression.sql @@ -0,0 +1,10 @@ +-- NEO-44: per-player gig XP totals (combat encounter path; level derived server-side when gig curves exist). +CREATE TABLE IF NOT EXISTS player_gig_progression ( + player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE, + gig_id TEXT NOT NULL, + xp INTEGER NOT NULL CHECK (xp >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (player_id, gig_id) +); + +COMMENT ON TABLE player_gig_progression IS 'Persisted gig XP per player (NEO-44); prototype main gig breach only until loadout/hub swap lands.';