Merge pull request #118 from ViPro-Technologies/NEO-44-gig-xp-combat-defeat
NEO-44: Grant gig XP on combat defeat (E5M1-10)pull/119/head
commit
a216219aab
|
|
@ -0,0 +1,123 @@
|
|||
meta {
|
||||
name: GET gig progression after defeat spine (NEO-44)
|
||||
type: http
|
||||
seq: 31
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-44 AC spine: pre-request resets prototype target HP (NEO-83 fixture), then four pulse casts on alpha (slot 2 — avoids slot 0/1 cooldown from ability-cast and combat-targets folders).
|
||||
Asserts breach xp increased by 25 from pre-spine baseline; salvage/refine skill xp unchanged.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||
|
||||
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));
|
||||
|
||||
const gigBefore = await axios.get(
|
||||
`${baseUrl}/game/players/${playerId}/gig-progression`,
|
||||
jsonHeaders,
|
||||
);
|
||||
const breachBefore = gigBefore.data.gigs.find((x) => x.id === "breach");
|
||||
bru.setVar(
|
||||
"neo44BreachXpBefore",
|
||||
String(breachBefore?.xp ?? 0),
|
||||
);
|
||||
|
||||
await resetPrototypeCombatTargets(bru);
|
||||
|
||||
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: 2, 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: 2,
|
||||
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/{{playerId}}/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 increases by 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");
|
||||
const breachXpBefore = Number(bru.getVar("neo44BreachXpBefore"));
|
||||
expect(breach.xp).to.equal(breachXpBefore + 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
meta {
|
||||
name: Gig progression folder
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-44: read-only gig XP snapshot (combat defeat path — not E2.M2 skill XP).
|
||||
Run order (folder runs after ability-cast + combat-targets alphabetically): Get gig progression → Get gig progression after defeat spine (~13s pre-request).
|
||||
Defeat spine resets prototype target HP via __dev/combat-targets-fixture (same as combat-targets folder) and uses slot 2 to avoid cooldown pollution from earlier folders.
|
||||
Cross-link: bruno/neon-sprawl-server/ability-cast/ and combat-targets/
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request exhausts the node (10 successful gathers), then this interact should deny with node_depleted.
|
||||
NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request gathers until node_depleted (craft spine may consume one gather first). POST interact should deny with node_depleted.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
@ -21,7 +21,7 @@ script:pre-request {
|
|||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
while (true) {
|
||||
const res = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{
|
||||
|
|
@ -30,9 +30,13 @@ script:pre-request {
|
|||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
if (!res.data.allowed) {
|
||||
throw new Error(`expected gather ${i + 1}/10 to succeed, got ${res.data.reasonCode}`);
|
||||
if (res.data?.allowed === true) {
|
||||
continue;
|
||||
}
|
||||
if (res.data?.reasonCode === "node_depleted") {
|
||||
break;
|
||||
}
|
||||
throw new Error(`expected gather to succeed or end with node_depleted, got ${JSON.stringify(res.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@
|
|||
|
||||
**NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** for scripted **`mission_reward`** skill XP ([implementation plan](../../plans/NEO-43-implementation-plan.md)); manual QA **[`NEO-43.md`](../../manual-qa/NEO-43.md)**; full quest hand-in awaits **E7.M2**.
|
||||
|
||||
**Still backlog within E2.M2 / Slice 3:** **NEO-44** (gig XP from combat, Epic 5 — not skill XP via E2.M2). **NEO-42** end-to-end craft wiring awaits **E3.M2** success handler.
|
||||
**NEO-44 landed (E5.M1 / combat path, not E2.M2 skill grant):** combat defeat on **`AbilityCastApi`** accept when **`targetDefeated`** grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** + **`IPlayerGigProgressionStore`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); read model **`GET /game/players/{id}/gig-progression`**; manual QA **[`NEO-44.md`](../../manual-qa/NEO-44.md)**; **[server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44)**; module handoff [E5.M1](E5_M1_CombatRulesEngine.md).
|
||||
|
||||
**Still backlog within E2.M2 / Slice 3:** **NEO-42** end-to-end craft wiring awaits **E3.M2** success handler (prep helper landed).
|
||||
|
||||
**E2.M3:** [MasteryAndPerkUnlocks](E2_M3_MasteryAndPerkUnlocks.md) re-evaluates perk eligibility on skill **level-up** after grants ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
| **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
|
||||
|
||||
**Authority:** [Client vs server](client_server_authority.md#e5m1-combatrulesengine) — server validates intents and computes all outcomes; never trust client damage/healing/crit/death.
|
||||
|
||||
Core tab-target combat resolution: valid actions against a locked target, hit resolution, cooldown and resource timing, and threat state as needed for prototype encounters. Presents readable outcomes in fixed isometric view and feeds XP awards through [E2.M2](E2_M2_XpAwardAndLevelEngine.md).
|
||||
Core tab-target combat resolution: valid actions against a locked target, hit resolution, cooldown and resource timing, and threat state as needed for prototype encounters. Presents readable outcomes in fixed isometric view. **Combat defeat** awards **gig XP** via **`IPlayerGigProgressionStore`** ([NEO-44](../../plans/NEO-44-implementation-plan.md)) — **not** E2.M2 skill XP ([progression.md](../../game-design/progression.md)).
|
||||
|
||||
**PvP:** Single engine for PvE and PvP; player-target hostility **gates** on [E6.M1](E6_M1_PvPEligibilityAndFlagState.md). See [PvP and the combat engine](pvp_combat_integration.md).
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
- Combat state machine for tab-target flow (attacks, abilities per prototype scope).
|
||||
- Validate and apply `CombatAction`; produce `CombatResolution` for UI and logs (include **PvP deny reasons** when player targets are blocked; see [pvp_combat_integration.md](pvp_combat_integration.md)).
|
||||
- Integrate with targeting/interaction from **E1.M3** (target lock, valid target checks).
|
||||
- Award combat XP via E2.M2 integration where specified in slices.
|
||||
- Award **gig XP** on combat defeat via dedicated gig progression store ([NEO-44](../../plans/NEO-44-implementation-plan.md)); do **not** route default encounter clears through E2.M2 skill XP.
|
||||
|
||||
## Key contracts
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
## Module dependencies
|
||||
|
||||
- **E1.M3** — [InteractionAndTargetingLayer](E1_M3_InteractionAndTargetingLayer.md): `TargetState`, selection, and alignment with **E1.M4** ([AbilityInputScaffold](E1_M4_AbilityInputScaffold.md)) for `AbilityCastRequest` wiring.
|
||||
- **E2.M2** — [XpAwardAndLevelEngine](E2_M2_XpAwardAndLevelEngine.md): combat XP grants.
|
||||
- **E2.M2** — [XpAwardAndLevelEngine](E2_M2_XpAwardAndLevelEngine.md): **skill** XP for gather/craft/mission callers only; combat → gig XP stays on E5.M1 (NEO-44).
|
||||
|
||||
## Dependents (by design)
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
- **E5.M3** — EncounterAndRewardTables.
|
||||
- **E1.M4** — AbilityInputScaffold sends `AbilityCastRequest` to combat.
|
||||
- **E7.M1** — QuestStateMachine for combat-related quest steps.
|
||||
- **E2.M2** — Invoked as integration caller for XP (not a structural child).
|
||||
- **E2.M2** — Skill XP integration caller for gather/craft/mission paths (not structural child; not combat gig XP).
|
||||
|
||||
## Related implementation slices
|
||||
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`** increased by **25** from the pre-spine baseline (Postgres retains prior totals across the full Bruno collection).
|
||||
|
||||
## 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.
|
||||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
# NEO-44 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-44 |
|
||||
| **Title** | E5M1-10: Combat encounter awards gig XP only (no skill XP via E2.M2) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-44/e5m1-10-combat-encounter-awards-gig-xp-only-no-skill-xp-via-e2m2 |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-10** |
|
||||
| **Branch** | `NEO-44-gig-xp-combat-defeat` |
|
||||
| **Precursor** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + `combatResolution.targetDefeated` (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) — player-scoped GET snapshot; [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) — activity grant helper + constants; **not** `SkillProgressionGrantOperations` |
|
||||
| **Blocks** | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone optional gig visibility |
|
||||
| **Client counterpart** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) / [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) — Godot gig row optional (**out of scope** here; no client change) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Prototype main gig id** | Which gig id receives combat XP before loadout/hub swap exists? | Frozen **`breach`** — E5M1-10 backlog default; damage gig from [gigs.md](../game-design/gigs.md). | **User:** **`breach`**. |
|
||||
| **Gig XP per defeat** | How much XP on `targetDefeated` transition? | **25** — matches **`prototype_pulse`** `baseDamage`; one readable step per alpha defeat. | **User:** **25**. |
|
||||
| **Wire visibility** | GET route vs cast response block? | **`GET /game/players/{id}/gig-progression` only** — NEO-37 pattern; keeps gig path outside E2.M2. | **User:** GET only. |
|
||||
| **Persistence** | Postgres migration vs in-memory only? | **Postgres + in-memory split** — mirror **`IPlayerSkillProgressionStore`** / V003 (NEO-29-style). | **User:** Postgres + in-memory. |
|
||||
| **Level field** | Derive level from curve or fixed? | **`level: 1`** fixed, **`xp`** accumulates — no gig level-curve content yet (NEO-37 pre-NEO-39 pattern). | **Adopted** (implicit with GET-only + prototype scope). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** When combat resolution transitions a prototype target to defeated (`combatResolution.targetDefeated === true`), grant **gig XP** to the attacker's **main gig** (`breach`) via a dedicated gig progression store. **Do not** call **`SkillProgressionGrantOperations`** or mutate skill XP. Expose a read-only **`GET`** snapshot for verification.
|
||||
|
||||
**In scope (from Linear + [E5M1-10](E5M1-prototype-backlog.md#e5m1-10--gig-xp-on-combat-defeat-neo-44)):**
|
||||
|
||||
- Minimal **`IPlayerGigProgressionStore`** + in-memory + Postgres implementations (**`V007__player_gig_progression.sql`**).
|
||||
- **`CombatDefeatGigXpGrant`** (or equivalent) + **`GigProgressionConstants`** (`breach`, **25** XP per defeat).
|
||||
- Hook in **`AbilityCastApi`** after successful **`CombatOperations.TryResolve`** when **`result.TargetDefeated`** is true.
|
||||
- **`GET /game/players/{id}/gig-progression`** — versioned snapshot with prototype **`mainGigId`** + single row for **`breach`** (`xp`, `level: 1`).
|
||||
- Integration tests (AAA): defeat grants gig XP once; **`target_defeated`** re-hit does not; skill snapshot unchanged.
|
||||
- Bruno: gig-progression GET + extend defeat spine to assert gig XP after 4th pulse.
|
||||
- **`server/README.md`** gig progression + combat defeat invariant; **`docs/manual-qa/NEO-44.md`**.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Full nine-gig catalog, hub swap, sub-gig XP ([gigs.md](../game-design/gigs.md)).
|
||||
- Gig XP on cast response wire block (kickoff: GET only).
|
||||
- Data-driven gig level curves / content catalog.
|
||||
- Godot gig bar — **[NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)** optional.
|
||||
- **`POST …/gig-progression`** grant route (combat hook is the only prototype writer).
|
||||
- Mission / **`mission_reward`** skill XP — unchanged ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [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
|
||||
|
||||
1. **Constants (`GigProgressionConstants`)** — frozen prototype values:
|
||||
- **`PrototypeMainGigId`** = **`breach`**
|
||||
- **`CombatDefeatXpAmount`** = **25**
|
||||
- Document: all players implicitly main **`breach`** until loadout/hub swap lands.
|
||||
|
||||
2. **Store (`Game/Gigs/`)** — parallel to skill progression, keyed by **`(playerId, gigId)`**:
|
||||
- **`IPlayerGigProgressionStore`**: **`GetXpTotals(playerId)`**, **`TryApplyXpDelta(playerId, gigId, amount, out previous, out new)`**.
|
||||
- **`InMemoryPlayerGigProgressionStore`** — seeds dev player bucket (same pattern as **`InMemoryPlayerSkillProgressionStore`**).
|
||||
- **`PostgresPlayerGigProgressionStore`** + **`PostgresGigProgressionBootstrap`** — **`V007__player_gig_progression.sql`**; FK to **`player_position`** like V003.
|
||||
- **`GigProgressionServiceCollectionExtensions.AddGigProgressionStore`** — Postgres when connection string set, else in-memory.
|
||||
|
||||
3. **Grant helper (`CombatDefeatGigXpGrant`)** — static method invoked from cast accept path:
|
||||
- **`GrantOnCombatDefeat(playerId, gigStore)`** → **`TryApplyXpDelta(playerId, breach, 25, …)`**.
|
||||
- **No** dependency on **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, or **`SkillProgressionGrantOperations`**.
|
||||
|
||||
4. **Cast hook (`AbilityCastApi`)** — after combat success, before returning accept JSON:
|
||||
- If **`combatResult.TargetDefeated`**: call **`CombatDefeatGigXpGrant.GrantOnCombatDefeat(trimmedPlayerId, gigStore)`**.
|
||||
- Inject **`IPlayerGigProgressionStore`** alongside existing combat dependencies.
|
||||
- Cast response **unchanged** (no `gigXpGrant` block — kickoff decision).
|
||||
|
||||
5. **GET snapshot (`GigProgressionSnapshotApi`)** — **`GET /game/players/{id}/gig-progression`**:
|
||||
- **404** when player unknown to **`IPositionStateStore`** (NEO-37 gate).
|
||||
- Response **`schemaVersion` 1**, **`playerId`**, **`mainGigId`** (`breach`), **`gigs`**: array with one row **`{ id: "breach", xp, level: 1 }`** (level fixed until gig curves exist).
|
||||
- Wire in **`Program.cs`** via **`app.MapGigProgressionSnapshotApi()`**.
|
||||
|
||||
6. **Tests** — extend **`InMemoryWebApplicationFactory`** to replace **`IPlayerGigProgressionStore`** with in-memory implementation (same loop as skill store).
|
||||
|
||||
7. **Bruno (`bruno/neon-sprawl-server/gig-progression/`)**:
|
||||
- **`Get gig progression.bru`** — fresh server: **`xp` 0**, **`level` 1**, **`mainGigId` breach**.
|
||||
- **`Get gig progression after defeat spine.bru`** — pre-request: reuse ability-cast defeat spine (4 pulses on alpha); GET asserts **`breach.xp === 25`**; optional GET skill-progression asserts **`salvage`/`refine`** unchanged from pre-defeat baseline.
|
||||
|
||||
8. **Docs** — README subsection: combat → gig XP invariant; link [progression.md](../game-design/progression.md); contrast with **`MissionRewardSkillXpGrant`** and gather/craft **`activity`** paths. Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-10 checkboxes and [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md) when landed.
|
||||
|
||||
### Expected outcomes (4th pulse defeats alpha)
|
||||
|
||||
| Step | Cast `accepted` | `targetDefeated` | Gig XP (`breach`) | Skill XP (`salvage`/`refine`) |
|
||||
|------|-----------------|------------------|-------------------|-------------------------------|
|
||||
| Pulses 1–3 | true | false | 0 | unchanged |
|
||||
| Pulse 4 | true | true | **+25 → 25** | unchanged |
|
||||
| Pulse 5 | false (`target_defeated`) | — | **25** (no delta) | unchanged |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionConstants.cs` | Frozen `breach` main gig + **25** combat defeat XP. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/IPlayerGigProgressionStore.cs` | Gig XP persistence contract. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/InMemoryPlayerGigProgressionStore.cs` | Dev/test in-memory store. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/PostgresPlayerGigProgressionStore.cs` | Postgres-backed store. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/PostgresGigProgressionBootstrap.cs` | Flyway DDL bootstrap for V007. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionServiceCollectionExtensions.cs` | DI registration (Postgres vs in-memory). |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/CombatDefeatGigXpGrant.cs` | Defeat transition grant helper. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotApi.cs` | `GET …/gig-progression` route. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotDtos.cs` | Versioned response DTOs. |
|
||||
| `server/db/migrations/V007__player_gig_progression.sql` | Postgres DDL for gig XP totals. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs` | GET snapshot + 404 gate (AAA). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs` | Unit tests for grant helper. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs` | Optional Postgres round-trip (mirror NEO-38 pattern). |
|
||||
| `bruno/neon-sprawl-server/gig-progression/Get gig progression.bru` | Fresh snapshot smoke. |
|
||||
| `bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru` | Defeat → gig XP AC spine. |
|
||||
| `bruno/neon-sprawl-server/gig-progression/folder.bru` | Run order + cross-links. |
|
||||
| `docs/manual-qa/NEO-44.md` | Bruno/curl defeat → GET gig XP; skill snapshot unchanged. |
|
||||
| `docs/plans/NEO-44-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject gig store; call grant when `TargetDefeated` on combat success. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | `AddGigProgressionStore` + `MapGigProgressionSnapshotApi`. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Replace Postgres gig store with in-memory in tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Defeat chain: gig XP +25 on 4th pulse; 5th no delta; skill GET unchanged. |
|
||||
| `server/README.md` | Gig progression GET + combat defeat → gig XP invariant. |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | Mark E5M1-10 landed when complete. |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | E5M1-10 handoff paragraph + Linear row. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 / E2.M2 Slice 3 rows when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs` | **Grant:** delta +25 on **`breach`**. **Idempotent store failure:** no throw when player missing. **AAA**. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs` | **GET** 404 unknown player; **GET** dev player **`xp` 0**, **`level` 1**, **`mainGigId` breach**. **AAA**. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **Defeat chain:** after 4th pulse, **`GET …/gig-progression`** → **`breach.xp` 25**; **`GET …/skill-progression`** salvage/refine unchanged; 5th cast deny → gig XP still 25. **AAA**. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs` | **Optional:** Postgres store persists defeat grant (skip if no test DB — mirror skill persistence test guard). |
|
||||
|
||||
Bruno defeat spine + manual QA complement automated coverage.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Main gig assignment without loadout** | **Frozen `breach` for all players** (user kickoff). | **adopted** |
|
||||
| **Gig XP amount** | **25 per defeat** (user kickoff). | **adopted** |
|
||||
| **Cast response gig block** | **Defer** — GET only (user kickoff); NEO-85/86 may poll GET. | **adopted** |
|
||||
| **Level curve absence** | **`level: 1` fixed** until gig content issue. | **adopted** |
|
||||
| **Beta dummy defeat** | **Same grant path** for any prototype target defeat (alpha AC explicit; beta covered by shared hook). | **adopted** |
|
||||
| **Grant failure on defeat** | **Best-effort apply** — cast accept still returns combat resolution even if gig store write fails (log/monitor later); prototype in-memory path should not fail for known dev player. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- Prototype main gig **`breach`**; **25** gig XP per **`targetDefeated`** transition.
|
||||
- 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.
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Code review — NEO-44 gig XP on combat defeat
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-44-gig-xp-combat-defeat` · commits `30d379c`–`71a6761` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-44 delivers **E5M1-10**: when **`AbilityCastApi`** accepts a cast with **`combatResolution.targetDefeated === true`**, the server grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** and a dedicated **`IPlayerGigProgressionStore`** (in-memory + Postgres **`V007`**), explicitly **not** calling **`SkillProgressionGrantOperations`**. A read-only **`GET /game/players/{id}/gig-progression`** snapshot (NEO-37 gate pattern) exposes **`mainGigId`**, fixed **`level: 1`**, and accumulating **`xp`**. Integration tests cover GET 404/default, grant helper, defeat chain (4th pulse +25, 5th no delta, skill snapshot unchanged), and optional Postgres persistence; Bruno `gig-progression/` + **`docs/manual-qa/NEO-44.md`** complete verification. Implementation matches the kickoff plan and acceptance checklist. Residual risk is low — server-only slice; Godot gig row remains **NEO-86** out of scope.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-44-implementation-plan.md`](../plans/NEO-44-implementation-plan.md) | **Matches** — kickoff decisions (`breach`, 25 XP, GET-only wire, Postgres + in-memory), scope, expected outcomes table, reconciliation, and acceptance checklist align with code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-10** | **Matches** — AC checked; landed note and gig-vs-skill invariant documented. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Purpose/Responsibilities updated for gig XP via NEO-44 (not E2.M2 skill XP). |
|
||||
| [`docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md`](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) | **Matches** — NEO-44 landed paragraph added; removed from backlog list. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M2 and E5.M1 rows updated with NEO-44 landed + gig progression links. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-authoritative defeat grant; client polls GET when NEO-86 lands. |
|
||||
| [`docs/game-design/progression.md`](../game-design/progression.md) | **Matches** — combat → gig XP invariant referenced in README and manual QA. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — gig progression GET section, combat defeat invariant, ability-cast NEO-44 note. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents NEO-85/NEO-86 client counterpart; no Godot change in scope. |
|
||||
| [`docs/manual-qa/NEO-44.md`](../manual-qa/NEO-44.md) | **Matches** — curl defeat spine + skill control snapshot. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Stale E5.M1 Purpose / Responsibilities** — [`E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) line 17 still says combat “feeds XP awards through E2.M2”; line 26 says “Award combat XP via E2.M2 integration.” NEO-44 explicitly routes combat defeat to **`IPlayerGigProgressionStore`**. Update those bullets to “gig XP via dedicated store (NEO-44); not E2.M2 skill XP” so module intent matches the landed slice.~~ **Done.** Purpose, Responsibilities, and E2.M2 dependency bullets updated.
|
||||
|
||||
2. ~~**Stale E2.M2 implementation snapshot** — [`E2_M2_XpAwardAndLevelEngine.md`](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) **Still backlog** paragraph lists NEO-44; alignment register already marks NEO-44 landed. Add a **NEO-44 landed (E5.M1)** paragraph (cross-link E5.M1 / gig GET) and remove NEO-44 from backlog.~~ **Done.** NEO-44 landed paragraph added; backlog line trimmed to NEO-42 craft wiring only.
|
||||
|
||||
3. ~~**Best-effort grant observability (optional)** — `CombatDefeatGigXpGrant` discards **`TryApplyXpDelta`** return value per kickoff; acceptable for prototype. Consider a debug/diagnostic log when apply returns **`false`** on Postgres (unknown player, FK miss) before production hardening — cast accept should remain unchanged.~~ **Done.** `LogDebug` when store write fails; `AbilityCastApi` passes logger from `ILoggerFactory`.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Bruno **`Get gig progression after defeat spine.bru`** hardcodes **`dev-local-1`** in the GET URL while pre-request uses **`playerId`** env — use **`{{playerId}}`** (or env var) for consistency if multi-player Bruno runs matter.~~ **Done.** GET URL uses **`{{playerId}}`**.
|
||||
|
||||
- Nit: No automated test for **`prototype_target_beta`** defeat grant — plan adopts shared hook; alpha chain + integration test suffice for prototype; optional one-pulse-on-beta smoke if beta HP path diverges later.
|
||||
|
||||
- Nit: **`Get gig progression.bru`** asserts **`xp`** is a number but not **`0`** on fresh server — docs note Postgres may retain prior totals; intentional for shared DB dev; fine for CI in-memory.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~Gigs|FullyQualifiedName~PostAbilityCast_ShouldGrantGigXp"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~AbilityCastApiTests"
|
||||
```
|
||||
|
||||
**NEO-44 filtered tests:** 6/6 passed (local run).
|
||||
**AbilityCastApiTests:** 21/21 passed (local run).
|
||||
|
||||
**Postgres (optional):** `GigProgressionGrantPersistenceIntegrationTests` when **`ConnectionStrings__NeonSprawl`** is set.
|
||||
|
||||
**Bruno (manual):** Run `bruno/neon-sprawl-server/gig-progression/` — seq 1 fresh GET; seq 2 defeat spine (~13s pre-request) asserts **`breach.xp === 25`** and salvage/refine unchanged.
|
||||
|
|
@ -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<SkillProgressionSnapshotResponse>(
|
||||
"/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<GigProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/gig-progression");
|
||||
var skillAfter = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/skill-progression");
|
||||
|
||||
// Assert
|
||||
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.NotNull(fourth);
|
||||
Assert.True(fourth!.CombatResolution!.TargetDefeated);
|
||||
var fifth = await fifthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Gigs;
|
||||
|
||||
/// <summary>NEO-44: combat defeat hook → <c>breach</c> gig XP via dedicated store (not E2.M2).</summary>
|
||||
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<IPlayerGigProgressionStore>();
|
||||
|
||||
// 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<IPlayerGigProgressionStore>();
|
||||
|
||||
// Act
|
||||
var exception = Record.Exception(() =>
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat("missing-player", gigStore));
|
||||
|
||||
// Assert
|
||||
Assert.Null(exception);
|
||||
Assert.Empty(gigStore.GetXpTotals("missing-player"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantOnCombatDefeat_WhenPlayerUnknown_ShouldNotThrow_WithLogger()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var gigStore = scope.ServiceProvider.GetRequiredService<IPlayerGigProgressionStore>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(nameof(CombatDefeatGigXpGrant));
|
||||
|
||||
// Act
|
||||
var exception = Record.Exception(() =>
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat("missing-player", gigStore, logger));
|
||||
|
||||
// Assert
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GigProgressionSnapshotResponse>(
|
||||
"/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<IOptions<GamePositionOptions>>().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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GigProgressionSnapshotResponse>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Progra
|
|||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
|
|
@ -82,6 +84,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
|
|
|
|||
|
|
@ -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 <c>AbilityCastResponse</c>).
|
||||
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
|
||||
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
|
||||
/// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP).
|
||||
/// </remarks>
|
||||
public static class AbilityCastApi
|
||||
{
|
||||
|
|
@ -61,6 +63,8 @@ public static class AbilityCastApi
|
|||
IPlayerAbilityCooldownStore cooldowns,
|
||||
IAbilityDefinitionRegistry abilities,
|
||||
ICombatEntityHealthStore healthStore,
|
||||
IPlayerGigProgressionStore gigStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
|
||||
|
|
@ -224,6 +228,14 @@ public static class AbilityCastApi
|
|||
now,
|
||||
TimeSpan.FromSeconds(definition.CooldownSeconds));
|
||||
|
||||
if (combatResult.TargetDefeated)
|
||||
{
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat(
|
||||
id.Trim(),
|
||||
gigStore,
|
||||
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant)));
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class CombatDefeatGigXpGrant
|
||||
{
|
||||
/// <summary>Applies one combat-defeat grant to the prototype main gig when <paramref name="playerId"/> is writable.</summary>
|
||||
public static void GrantOnCombatDefeat(
|
||||
string playerId,
|
||||
IPlayerGigProgressionStore gigStore,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
if (!gigStore.TryApplyXpDelta(
|
||||
playerId,
|
||||
GigProgressionConstants.PrototypeMainGigId,
|
||||
GigProgressionConstants.CombatDefeatXpAmount,
|
||||
out _,
|
||||
out _))
|
||||
{
|
||||
logger?.LogDebug(
|
||||
"Combat defeat gig XP grant skipped for player {PlayerId} (store write failed)",
|
||||
playerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>NEO-44: prototype combat defeat → main gig XP (outside E2.M2 skill progression).</summary>
|
||||
public static class GigProgressionConstants
|
||||
{
|
||||
/// <summary>All players implicitly main this gig until loadout/hub swap lands.</summary>
|
||||
public const string PrototypeMainGigId = "breach";
|
||||
|
||||
public const int CombatDefeatXpAmount = 25;
|
||||
|
||||
/// <summary>Fixed until gig level-curve content exists (NEO-37 pre-curve pattern).</summary>
|
||||
public const int PrototypeFixedLevel = 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Registers gig XP persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-44).</summary>
|
||||
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<IPlayerGigProgressionStore, PostgresPlayerGigProgressionStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Maps <c>GET /game/players/{{id}}/gig-progression</c> — read snapshot (NEO-44).</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/players/{{id}}/gig-progression</c> (NEO-44).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Prototype main gig until loadout/hub swap lands.</summary>
|
||||
[JsonPropertyName("mainGigId")]
|
||||
public required string MainGigId { get; init; }
|
||||
|
||||
[JsonPropertyName("gigs")]
|
||||
public required IReadOnlyList<GigProgressionRowJson> Gigs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>XP and level for one gig row in the prototype snapshot.</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Persisted totals of gig XP per player (NEO-44); level is derived when gig curves exist.</summary>
|
||||
public interface IPlayerGigProgressionStore
|
||||
{
|
||||
/// <summary>Keyed by gig id; omitted gigs imply XP 0.</summary>
|
||||
IReadOnlyDictionary<string, int> GetXpTotals(string playerId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="amount"/> to <paramref name="gigId"/> for <paramref name="playerId"/>.
|
||||
/// Returns <c>false</c> when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from <c>player_position</c>).
|
||||
/// </summary>
|
||||
bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Thread-safe in-memory gig progression; seeds the configured dev player with an empty XP map (NEO-44).</summary>
|
||||
public sealed class InMemoryPlayerGigProgressionStore(IOptions<GamePositionOptions> options) : IPlayerGigProgressionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, int>> byPlayer = CreateInitialMap(options.Value);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static ConcurrentDictionary<string, ConcurrentDictionary<string, int>> CreateInitialMap(GamePositionOptions o)
|
||||
{
|
||||
var id = NormalizePlayerId(o.DevPlayerId);
|
||||
var map = new ConcurrentDictionary<string, ConcurrentDictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
|
||||
map[id] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, int> 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<string, int>(inner, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, int> Empty = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Applies NEO-44 gig progression table DDL once per process.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>PostgreSQL-backed gig XP totals keyed by normalized player id (NEO-44).</summary>
|
||||
public sealed class PostgresPlayerGigProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerGigProgressionStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
return new Dictionary<string, int>(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<string, int>(StringComparer.Ordinal);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
map[reader.GetString(0)] = reader.GetInt32(1);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
|
|
@ -54,6 +56,7 @@ app.MapResourceNodeDefinitionsWorldApi();
|
|||
app.MapPlayerInventoryApi();
|
||||
app.MapPlayerCraftApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
Loading…
Reference in New Issue