neon-sprawl/docs/plans/NEO-44-implementation-plan.md

172 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 13 | 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.