diff --git a/docs/plans/NEO-44-implementation-plan.md b/docs/plans/NEO-44-implementation-plan.md new file mode 100644 index 0000000..98b99c3 --- /dev/null +++ b/docs/plans/NEO-44-implementation-plan.md @@ -0,0 +1,164 @@ +# 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 + +- [ ] Combat awards **gig** XP (main gig **`breach`**); no default skill XP grant from encounters. +- [ ] Mission/scripted skill payouts remain on the **mission_reward** / reward-router path. +- [ ] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition. +- [ ] Skill progression snapshot unchanged by combat defeat in tests. +- [ ] Documented invariant matches [progression.md](../game-design/progression.md). + +## 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.