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