From 16018586b753711469db772af24985a6e23f4da4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:35:42 -0400 Subject: [PATCH 1/9] NEO-108: Add kickoff implementation plan for encounter-progress GET. Documents HTTP projection from progress, completion, and event stores, Bruno defeat spine, and adopted grant-summary source from kickoff. --- docs/plans/NEO-108-implementation-plan.md | 160 ++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/plans/NEO-108-implementation-plan.md diff --git a/docs/plans/NEO-108-implementation-plan.md b/docs/plans/NEO-108-implementation-plan.md new file mode 100644 index 0000000..82ccab9 --- /dev/null +++ b/docs/plans/NEO-108-implementation-plan.md @@ -0,0 +1,160 @@ +# NEO-108 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-108 | +| **Title** | E5M3-08: GET encounter-progress + wire DTOs + Bruno | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-108/e5m3-08-get-encounter-progress-wire-dtos-bruno | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-08** | +| **Branch** | `NEO-108-get-encounter-progress-wire-dtos-bruno` | +| **Precursor** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores · [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants · [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring · [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) complete event store (**landed on `main`**) | +| **Pattern** | [NEO-103](NEO-103-implementation-plan.md) — world/player GET + Bruno; [NEO-44](NEO-44-implementation-plan.md) — player snapshot GET + position gate; [NEO-106](NEO-106-implementation-plan.md) — defeat-all-three cast helpers | +| **Blocks** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — client encounter HUD poll/refresh | +| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — Godot labels poll this GET; **not in this story** | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **`rewardGrantSummary` source** | Event store vs reward table vs fallback? | **`IEncounterCompleteEventStore.GrantedItems`** on `completed` — commit-time snapshot (NEO-107). | **Adopted** — event store | +| **Encounter rows in GET** | All catalog encounters vs only non-inactive? | **All** from **`GetDefinitionsInIdOrder()`** with derived **`inactive` / `active` / `completed`**. | **Adopted** (backlog) | +| **Player gate** | 404 for unknown player? | **`IPositionStateStore.TryGetPosition`** — gig/skill progression precedent. | **Adopted** | +| **Optional fields** | `completedAt` / `rewardGrantSummary` when not complete? | **Omit** (or JSON `null`) when not **`completed`**; **`defeatedTargetIds`** = `[]` when inactive. | **Adopted** | +| **Integration tests** | Dedicated API tests vs cast-only? | **`EncounterProgressApiTests`** + Bruno three-NPC spine; optional GET assertions after existing cast tests deferred unless useful. | **Adopted** | +| **Manual QA doc** | `docs/manual-qa/NEO-108.md`? | **Skip** — server-only (NEO-103 precedent); Bruno + automated API tests. | **Adopted** | + +## Goal, scope, and out-of-scope + +**Goal:** Expose authoritative per-player encounter progress over HTTP so clients and Bruno can read server-owned activation, defeat subset, completion timestamp, and grant summary without inferring from combat responses. + +**In scope (from Linear + [E5M3-08](E5M3-prototype-backlog.md#e5m3-08--get-gameplayersidencounter-progress--wire-dtos)):** + +- **`GET /game/players/{id}/encounter-progress`** with versioned envelope (`schemaVersion` **1**, **`encounters`** array). +- Per-encounter row: **`encounterId`**, **`state`** (`inactive` | `active` | `completed`), **`defeatedTargetIds`**, optional **`completedAt`**, optional **`rewardGrantSummary`** (`itemId` + `quantity` grants). +- Projection from **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IEncounterCompleteEventStore`** (no second source of truth). +- **`EncounterProgressApi`** + wire DTOs in `Game/Encounters/`. +- Integration tests (AAA): unknown player 404; inactive default; partial progress after 1–2 defeats; completed once with grant summary. +- Bruno `bruno/neon-sprawl-server/encounter-progress/` — self-contained three-NPC defeat pre-request → GET **`completed`** once. +- `server/README.md` route section; E5.M3 module snapshot + alignment register when landed. + +**Out of scope (from Linear + backlog):** + +- Godot poll client — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**. +- Telemetry hook sites ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)). +- Postgres persistence, cast response DTO changes. +- `docs/manual-qa/NEO-108.md` (server-only per kickoff). + +## Acceptance criteria checklist + +- [ ] GET reflects progress after each defeat without client-side inference. +- [ ] Completed encounter shows **`completed`** state and grant summary. +- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once. + +## Technical approach + +1. **Route:** **`GET /game/players/{id}/encounter-progress`** — inject **`IPositionStateStore`**, **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IEncounterCompleteEventStore`**. + +2. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`GigProgressionSnapshotApi`**). + +3. **State derivation** (per catalog encounter id, normalized lowercase keys in stores): + + | Condition | **`state`** | + |-----------|-------------| + | **`IEncounterCompletionStore.IsCompleted`** | **`completed`** | + | Progress snapshot **`Started`** | **`active`** | + | Otherwise | **`inactive`** | + +4. **Row fields:** + + - **`encounterId`**: catalog **`id`** (lowercase). + - **`defeatedTargetIds`**: from **`EncounterProgressSnapshot.DefeatedNpcInstanceIds`** when progress exists; else **`[]`** (sorted ordinal for stable JSON). + - **`completedAt`**: ISO-8601 UTC from **`IEncounterCompletionStore.TryGetCompletedAt`** when **`completed`**; omitted otherwise. + - **`rewardGrantSummary`**: when **`completed`**, map **`EncounterCompleteEvent.GrantedItems`** from **`IEncounterCompleteEventStore.TryGet`** → `{ itemId, quantity }[]`. If completion flag set but event missing (invariant violation), throw **`InvalidOperationException`** (fail-fast; same spirit as NEO-103 missing reward table). Omitted when not **`completed`**. + +5. **Ordering:** Build rows from **`encounterRegistry.GetDefinitionsInIdOrder()`** (prototype: single **`prototype_combat_pocket`**). + +6. **Implementation:** **`EncounterProgressApi`** + **`EncounterProgressListDtos.cs`** with internal **`BuildSnapshot(playerId, …)`** testable like **`GigProgressionSnapshotApi.BuildSnapshot`**. Register **`app.MapEncounterProgressApi()`** in **`Program.cs`** after **`MapGigProgressionSnapshotApi()`**. + +7. **Bruno:** `bruno/neon-sprawl-server/encounter-progress/` + + - **`folder.bru`** + - **`Get encounter progress inactive.bru`** — GET before engagement → **`inactive`**, empty defeats. + - **`Get encounter progress after defeat spine.bru`** — pre-request: **`resetPrototypeCombatTargets`** (shared helper pattern from gig-progression), move, bind slot **3** pulse, defeat melee → ranged → elite via cast; GET asserts **`active`** with growing **`defeatedTargetIds`** after 1–2 defeats (optional intermediate GETs in pre-request) and final **`completed`** with **`rewardGrantSummary`** matching **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**; second GET still **`completed`** (idempotent read). + +8. **Docs:** Update **`server/README.md`** (replace NEO-108 deferral under encounter progress section). Update [E5_M3](E5_M3_EncounterAndRewardTables.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) when landed. + +### Expected prototype row progression (frozen) + +| After | **`state`** | **`defeatedTargetIds`** | **`rewardGrantSummary`** | +|-------|-------------|-------------------------|--------------------------| +| No engagement | `inactive` | `[]` | omitted | +| First damaging cast vs required NPC | `active` | 0–3 ids | omitted | +| 1–2 lethal defeats | `active` | 1–2 ids | omitted | +| Third lethal defeat + grants | `completed` | 3 ids | scrap ×10, token ×1 | +| Subsequent GET / casts | `completed` | 3 ids | unchanged summary | + +### Prototype completion read flow + +```mermaid +sequenceDiagram + participant Client + participant API as EncounterProgressApi + participant Pos as IPositionStateStore + participant Reg as IEncounterDefinitionRegistry + participant Prog as IEncounterProgressStore + participant Comp as IEncounterCompletionStore + participant Ev as IEncounterCompleteEventStore + + Client->>API: GET /game/players/{id}/encounter-progress + API->>Pos: TryGetPosition + alt unknown player + API-->>Client: 404 + end + API->>Reg: GetDefinitionsInIdOrder + loop each encounter + API->>Comp: IsCompleted + API->>Prog: TryGetProgress + API->>Ev: TryGet when completed + end + API-->>Client: schemaVersion 1 + encounters[] +``` + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs` | `Map*` extension for GET route; **`BuildSnapshot`** projection. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs` | Versioned response + per-encounter row + grant summary DTOs. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs` | AAA HTTP: 404, inactive, partial active, completed + grant summary. | +| `bruno/neon-sprawl-server/encounter-progress/folder.bru` | Bruno folder metadata. | +| `bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru` | GET default **`inactive`**. | +| `bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru` | Three-NPC defeat pre-request → **`completed`** once. | +| `docs/plans/NEO-108-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Program.cs` | Register **`MapEncounterProgressApi()`** after gig progression routes. | +| `server/README.md` | Document GET route, curl, store projection, Bruno folder; remove NEO-108 deferral notes. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-08 HTTP read model. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-108 HTTP when landed. | +| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-108 per-player GET when landed. | +| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-08 acceptance criteria when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `EncounterProgressApiTests.cs` | **404** for unknown player. **Inactive:** fresh factory → GET → one encounter **`prototype_combat_pocket`**, **`inactive`**, empty defeats, no **`completedAt`** / **`rewardGrantSummary`**. **Partial:** cast/defeat helpers (reuse **`AbilityCastApiTests`** pattern or inline HTTP) — after 1 defeat **`active`** + 1 id; after 2 defeats still **`active`** + 2 ids, not **`completed`**. **Completed:** defeat all three → **`completed`**, **`completedAt`** present, **`rewardGrantSummary`** quantities 10 + 1; completion store + event store aligned. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Event missing when completed** | Throw **`InvalidOperationException`** at projection time — should not happen after NEO-107; surfaces wiring bugs in tests. | **adopted** | +| **Bruno slot cooldown** | Use hotbar slot **3** (or next free) in defeat spine — avoid slot 0/1 conflicts with other Bruno folders (NEO-44 precedent). | **adopted** | +| **Case on `defeatedTargetIds`** | Return normalized lowercase ids from store (catalog ids are lowercase). | **adopted** | +| **NEO-110 poll cadence** | Defer — client story chooses poll-after-cast vs interval. | **deferred** | From c1fd2053d0bc77badea115d83a9c0357f1314596 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:37:42 -0400 Subject: [PATCH 2/9] NEO-108: Add GET encounter-progress API and integration tests. Projects progress, completion, and event stores into versioned per-player rows with inactive/active/completed state and commit-time grant summary. --- .../Encounters/EncounterProgressApiTests.cs | 251 ++++++++++++++++++ .../Game/Encounters/EncounterProgressApi.cs | 138 ++++++++++ .../Encounters/EncounterProgressListDtos.cs | 41 +++ server/NeonSprawl.Server/Program.cs | 1 + 4 files changed, 431 insertions(+) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs new file mode 100644 index 0000000..f68851c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs @@ -0,0 +1,251 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class EncounterProgressApiTests +{ + private const string PrototypeEncounterId = "prototype_combat_pocket"; + + [Fact] + public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/encounter-progress"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetEncounterProgress_ShouldReturnInactive_WhenNoEngagement() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(EncounterProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + var row = Assert.Single(body.Encounters); + Assert.Equal(PrototypeEncounterId, row.EncounterId); + Assert.Equal(EncounterProgressApi.StateInactive, row.State); + Assert.Empty(row.DefeatedTargetIds); + Assert.Null(row.CompletedAt); + Assert.Null(row.RewardGrantSummary); + } + + [Fact] + public async Task GetEncounterProgress_ShouldReturnActiveWithOneDefeat_WhenFirstNpcDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Encounters); + Assert.Equal(EncounterProgressApi.StateActive, row.State); + Assert.Equal([PrototypeNpcRegistry.PrototypeNpcMeleeId], row.DefeatedTargetIds); + Assert.Null(row.CompletedAt); + Assert.Null(row.RewardGrantSummary); + } + + [Fact] + public async Task GetEncounterProgress_ShouldReturnActiveWithTwoDefeats_WhenTwoNpcDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcRangedId); + var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Encounters); + Assert.Equal(EncounterProgressApi.StateActive, row.State); + Assert.Equal( + [ + PrototypeNpcRegistry.PrototypeNpcMeleeId, + PrototypeNpcRegistry.PrototypeNpcRangedId, + ], + row.DefeatedTargetIds); + Assert.Null(row.CompletedAt); + Assert.Null(row.RewardGrantSummary); + var completionStore = factory.Services.GetRequiredService(); + Assert.False(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId)); + } + + [Fact] + public async Task GetEncounterProgress_ShouldReturnCompletedWithGrantSummary_WhenAllThreeNpcDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcRangedId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcEliteId); + var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Encounters); + Assert.Equal(EncounterProgressApi.StateCompleted, row.State); + Assert.Equal( + [ + PrototypeNpcRegistry.PrototypeNpcEliteId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, + PrototypeNpcRegistry.PrototypeNpcRangedId, + ], + row.DefeatedTargetIds); + Assert.NotNull(row.CompletedAt); + Assert.NotNull(row.RewardGrantSummary); + Assert.Equal(10, row.RewardGrantSummary!.Single(g => g.ItemId == "scrap_metal_bulk").Quantity); + Assert.Equal(1, row.RewardGrantSummary.Single(g => g.ItemId == "contract_handoff_token").Quantity); + var completionStore = factory.Services.GetRequiredService(); + var eventStore = factory.Services.GetRequiredService(); + Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId)); + Assert.True(eventStore.TryGet("dev-local-1", PrototypeEncounterId, out _)); + } + + private static async Task BindSlot0PulseAsync(HttpClient client) + { + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], + }); + post.EnsureSuccessStatusCode(); + } + + private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = targetId, + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.SelectionApplied); + } + + private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId) + { + Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry)); + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/move", + new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector + { + X = entry.X - 1, + Y = 0.9, + Z = entry.Z - 1, + }, + }); + response.EnsureSuccessStatusCode(); + } + + private static AbilityCastRequest PulseCastRequestForTarget(string targetId) => + new() + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = targetId, + }; + + private static async Task DefeatLockedTargetWithPulseAsync( + HttpClient client, + InMemoryWebApplicationFactory factory, + string targetId) + { + Assert.NotNull(factory.FakeClock); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + await BindSlot0PulseAsync(client); + await MoveDevPlayerNearNpcAsync(client, targetId); + await LockPrototypeTargetAsync(client, targetId); + var cast = PulseCastRequestForTarget(targetId); + AbilityCastResponse? defeatBody = null; + for (var i = 0; i < 12; i++) + { + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Accepted, body.ReasonCode); + Assert.NotNull(body.CombatResolution); + if (body.CombatResolution!.TargetDefeated) + { + defeatBody = body; + break; + } + + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + + Assert.NotNull(defeatBody); + Assert.True(defeatBody!.CombatResolution!.TargetDefeated); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs new file mode 100644 index 0000000..47d62a6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs @@ -0,0 +1,138 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Maps GET /game/players/{{id}}/encounter-progress (NEO-108). +public static class EncounterProgressApi +{ + public const string StateInactive = "inactive"; + public const string StateActive = "active"; + public const string StateCompleted = "completed"; + + public static WebApplication MapEncounterProgressApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/encounter-progress", + (string id, IPositionStateStore positions, IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + return Results.Json( + BuildSnapshot( + trimmedId, + encounterRegistry, + progressStore, + completionStore, + completeEventStore)); + }); + + return app; + } + + internal static EncounterProgressListResponse BuildSnapshot( + string playerId, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore) + { + var defs = encounterRegistry.GetDefinitionsInIdOrder(); + var rows = new List(defs.Count); + foreach (var def in defs) + { + rows.Add( + MapRow( + playerId, + def.Id, + progressStore, + completionStore, + completeEventStore)); + } + + return new EncounterProgressListResponse + { + PlayerId = playerId, + Encounters = rows, + SchemaVersion = EncounterProgressListResponse.CurrentSchemaVersion, + }; + } + + private static EncounterProgressRowJson MapRow( + string playerId, + string encounterId, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore) + { + var defeatedTargetIds = Array.Empty(); + if (progressStore.TryGetProgress(playerId, encounterId, out var progress)) + { + defeatedTargetIds = progress.DefeatedNpcInstanceIds + .OrderBy(static id => id, StringComparer.Ordinal) + .ToArray(); + } + + if (completionStore.IsCompleted(playerId, encounterId)) + { + if (!completionStore.TryGetCompletedAt(playerId, encounterId, out var completedAt)) + { + throw new InvalidOperationException( + $"Encounter '{encounterId}' is completed for player '{playerId}' but completedAt is missing."); + } + + if (!completeEventStore.TryGet(playerId, encounterId, out var completeEvent)) + { + throw new InvalidOperationException( + $"Encounter '{encounterId}' is completed for player '{playerId}' but EncounterCompleteEvent is missing."); + } + + return new EncounterProgressRowJson + { + EncounterId = encounterId, + State = StateCompleted, + DefeatedTargetIds = defeatedTargetIds, + CompletedAt = completedAt, + RewardGrantSummary = MapGrantSummary(completeEvent.GrantedItems), + }; + } + + if (progressStore.TryGetProgress(playerId, encounterId, out progress) && progress.Started) + { + return new EncounterProgressRowJson + { + EncounterId = encounterId, + State = StateActive, + DefeatedTargetIds = defeatedTargetIds, + }; + } + + return new EncounterProgressRowJson + { + EncounterId = encounterId, + State = StateInactive, + DefeatedTargetIds = defeatedTargetIds, + }; + } + + private static List MapGrantSummary(IReadOnlyList grants) + { + var summary = new List(grants.Count); + foreach (var grant in grants) + { + summary.Add( + new EncounterRewardGrantJson + { + ItemId = grant.ItemId, + Quantity = grant.Quantity, + }); + } + + return summary; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs new file mode 100644 index 0000000..9d789dc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Encounters; + +/// JSON body for GET /game/players/{{id}}/encounter-progress (NEO-108). +public sealed class EncounterProgressListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// Per-player rows ordered by catalog encounter id (ordinal). + [JsonPropertyName("encounters")] + public required IReadOnlyList Encounters { get; init; } +} + +/// Authoritative per-player progress for one encounter definition. +public sealed class EncounterProgressRowJson +{ + [JsonPropertyName("encounterId")] + public required string EncounterId { get; init; } + + /// inactive, active, or completed. + [JsonPropertyName("state")] + public required string State { get; init; } + + [JsonPropertyName("defeatedTargetIds")] + public required IReadOnlyList DefeatedTargetIds { get; init; } + + [JsonPropertyName("completedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CompletedAt { get; init; } + + [JsonPropertyName("rewardGrantSummary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? RewardGrantSummary { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 7b3c050..7bf916a 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -71,6 +71,7 @@ app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi(); +app.MapEncounterProgressApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || From 07ebb9efb865d9a524b29352aaf9afe7a31f8dca Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:37:43 -0400 Subject: [PATCH 3/9] NEO-108: Add Bruno smokes and document encounter-progress GET. Bruno folder covers inactive default and three-NPC defeat spine; README and E5.M3 docs mark E5M3-08 landed with plan reconciliation. --- ... encounter progress after defeat spine.bru | 151 ++++++++++++++++++ .../Get encounter progress inactive.bru | 36 +++++ .../encounter-progress/folder.bru | 3 + .../modules/E5_M3_EncounterAndRewardTables.md | 8 +- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E5M3-prototype-backlog.md | 8 +- docs/plans/NEO-108-implementation-plan.md | 14 +- server/README.md | 24 ++- 9 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru create mode 100644 bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru create mode 100644 bruno/neon-sprawl-server/encounter-progress/folder.bru diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru new file mode 100644 index 0000000..3eb4898 --- /dev/null +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru @@ -0,0 +1,151 @@ +meta { + name: GET encounter progress after defeat spine + type: http + seq: 2 +} + +docs { + NEO-108 AC: three-NPC defeat chain via cast (slot 3), then GET shows completed once with frozen grant summary. +} + +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" } }; + const slotIndex = 3; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function rowForPocket(body) { + const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket"); + if (!row) { + throw new Error(`missing prototype_combat_pocket row: ${JSON.stringify(body)}`); + } + return row; + } + + async function getProgress() { + const response = await axios.get( + `${baseUrl}/game/players/${playerId}/encounter-progress`, + jsonHeaders, + ); + if (response.status !== 200) { + throw new Error(`encounter-progress GET failed: ${response.status}`); + } + return response.data; + } + + async function defeatNpc(targetId) { + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId }, + jsonHeaders, + ); + const castBody = { + schemaVersion: 1, + slotIndex, + abilityId: "prototype_pulse", + targetId, + }; + 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(`cast ${i + 1} vs ${targetId} failed: ${JSON.stringify(body)}`); + } + if (body.combatResolution.targetDefeated) { + return; + } + if (i < 3) { + await sleep(3200); + } + } + throw new Error(`did not defeat ${targetId} within 4 pulses`); + } + + 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, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await defeatNpc("prototype_npc_melee"); + const afterOne = await getProgress(); + const rowOne = rowForPocket(afterOne); + if (rowOne.state !== "active" || rowOne.defeatedTargetIds.length !== 1) { + throw new Error(`after melee: expected active with 1 defeat, got ${JSON.stringify(rowOne)}`); + } + + await defeatNpc("prototype_npc_ranged"); + const afterTwo = await getProgress(); + const rowTwo = rowForPocket(afterTwo); + if (rowTwo.state !== "active" || rowTwo.defeatedTargetIds.length !== 2) { + throw new Error(`after ranged: expected active with 2 defeats, got ${JSON.stringify(rowTwo)}`); + } + + await defeatNpc("prototype_npc_elite"); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/encounter-progress + 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 second = await axios.get(`${baseUrl}/game/players/${playerId}/encounter-progress`); + bru.setVar("neo108SecondGetJson", JSON.stringify(second.data)); +} + +tests { + test("completed row shows three defeats and grant summary", function () { + const body = res.getBody(); + expect(res.getStatus()).to.equal(200); + expect(body.schemaVersion).to.equal(1); + const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket"); + expect(row).to.be.an("object"); + expect(row.state).to.equal("completed"); + expect(row.defeatedTargetIds).to.eql([ + "prototype_npc_elite", + "prototype_npc_melee", + "prototype_npc_ranged", + ]); + expect(row.completedAt).to.be.a("string"); + expect(row.rewardGrantSummary).to.eql([ + { itemId: "scrap_metal_bulk", quantity: 10 }, + { itemId: "contract_handoff_token", quantity: 1 }, + ]); + }); + + test("second GET still completed (idempotent read)", function () { + const second = JSON.parse(bru.getVar("neo108SecondGetJson")); + const row = second.encounters.find((x) => x.encounterId === "prototype_combat_pocket"); + expect(row.state).to.equal("completed"); + expect(row.rewardGrantSummary).to.eql([ + { itemId: "scrap_metal_bulk", quantity: 10 }, + { itemId: "contract_handoff_token", quantity: 1 }, + ]); + }); +} diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru new file mode 100644 index 0000000..b7a4357 --- /dev/null +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru @@ -0,0 +1,36 @@ +meta { + name: GET encounter progress inactive + type: http + seq: 1 +} + +docs { + NEO-108: fresh player row before engagement — inactive with empty defeatedTargetIds. +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/encounter-progress + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + expect(body.encounters).to.be.an("array"); + expect(body.encounters.length).to.equal(1); + }); + + test("prototype_combat_pocket is inactive with no defeats", function () { + const body = res.getBody(); + const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket"); + expect(row).to.be.an("object"); + expect(row.state).to.equal("inactive"); + expect(row.defeatedTargetIds).to.eql([]); + expect(row.completedAt).to.equal(undefined); + expect(row.rewardGrantSummary).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/encounter-progress/folder.bru b/bruno/neon-sprawl-server/encounter-progress/folder.bru new file mode 100644 index 0000000..0bf70b8 --- /dev/null +++ b/bruno/neon-sprawl-server/encounter-progress/folder.bru @@ -0,0 +1,3 @@ +meta { + name: encounter-progress +} diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index 87c5dc1..907ce09 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M3 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring landed · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) event record landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | +| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring landed · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) event record landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) per-player GET landed · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | ## Purpose @@ -86,14 +86,16 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin **HTTP read model landed ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + DTOs in `Game/Encounters/`; nested **`rewardTable`** summary via **`IRewardTableDefinitionRegistry`**. Plan: [NEO-103 implementation plan](../../plans/NEO-103-implementation-plan.md); [server README — Encounter definitions](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. -**Progress + completion stores landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** (in-memory) + **`EncounterProgressOperations`** — activation, order-independent defeat tracking, idempotent completion flag; combat/HTTP wiring deferred to NEO-106/108. Plan: [NEO-104 implementation plan](../../plans/NEO-104-implementation-plan.md); [server README — Encounter progress](../../../server/README.md#encounter-progress--completion-stores-neo-104). +**Progress + completion stores landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** (in-memory) + **`EncounterProgressOperations`** — activation, order-independent defeat tracking, idempotent completion flag; combat wiring NEO-106, HTTP read NEO-108. Plan: [NEO-104 implementation plan](../../plans/NEO-104-implementation-plan.md); [server README — Encounter progress](../../../server/README.md#encounter-progress--completion-stores-neo-104). **Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event record persisted via **`IEncounterCompleteEventStore`** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)). Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105). -**Combat wiring landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring.TryProcessCastOutcome`** on **`AbilityCastApi`** — activate on first damaging hit, mark defeats, invoke completion grants when all required NPCs defeated; NEO-44 gig XP preserved. Per-player progress HTTP deferred to NEO-108. Plan: [NEO-106 implementation plan](../../plans/NEO-106-implementation-plan.md); [server README — Encounter combat wiring](../../../server/README.md#encounter-combat-wiring-neo-106). +**Combat wiring landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring.TryProcessCastOutcome`** on **`AbilityCastApi`** — activate on first damaging hit, mark defeats, invoke completion grants when all required NPCs defeated; NEO-44 gig XP preserved. Plan: [NEO-106 implementation plan](../../plans/NEO-106-implementation-plan.md); [server README — Encounter combat wiring](../../../server/README.md#encounter-combat-wiring-neo-106). **Complete event record landed ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + in-memory implementation — idempotent **`EncounterCompleteEvent`** record on successful grant commit; E7.M2 **`reward_delivery`** hook stub (comments only). Plan: [NEO-107 implementation plan](../../plans/NEO-107-implementation-plan.md); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). +**Per-player progress HTTP landed ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — **`EncounterProgressApi`** + DTOs; projects progress, completion, and event stores; **`rewardGrantSummary`** from commit-time **`GrantedItems`**. Plan: [NEO-108 implementation plan](../../plans/NEO-108-implementation-plan.md); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. Client counterpart: [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 0906eb1..502c67f 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -58,7 +58,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | 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 | Ready | **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 init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **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/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **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), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | -| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **HTTP progress read (NEO-108+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | +| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **E5M3-10+ telemetry / client HUD outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 71f0b22..a223854 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. -**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **NEO-107 landed:** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107 plan](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **HTTP progress read (NEO-108+) outstanding.** +**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **NEO-107 landed:** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107 plan](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **NEO-108 landed:** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108 plan](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10+ outstanding.** ### Epic 6 — PvP Security diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index a0447fd..0e44db9 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -263,9 +263,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] GET reflects progress after each defeat without client-side inference. -- [ ] Completed encounter shows **`completed`** state and grant summary. -- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once. +- [x] GET reflects progress after each defeat without client-side inference. +- [x] Completed encounter shows **`completed`** state and grant summary. +- [x] Bruno smoke: defeat chain → GET shows **`completed`** once. + +**Landed ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** + Bruno defeat spine; plan [NEO-108-implementation-plan.md](NEO-108-implementation-plan.md). **Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — poll/refresh from this GET. diff --git a/docs/plans/NEO-108-implementation-plan.md b/docs/plans/NEO-108-implementation-plan.md index 82ccab9..8ae2b69 100644 --- a/docs/plans/NEO-108-implementation-plan.md +++ b/docs/plans/NEO-108-implementation-plan.md @@ -48,9 +48,17 @@ ## Acceptance criteria checklist -- [ ] GET reflects progress after each defeat without client-side inference. -- [ ] Completed encounter shows **`completed`** state and grant summary. -- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once. +- [x] GET reflects progress after each defeat without client-side inference. +- [x] Completed encounter shows **`completed`** state and grant summary. +- [x] Bruno smoke: defeat chain → GET shows **`completed`** once. + +## Implementation reconciliation (shipped) + +- **Route:** `GET /game/players/{id}/encounter-progress` — `EncounterProgressApi.BuildSnapshot` projects progress, completion, and event stores; 404 via `IPositionStateStore`. +- **DTOs:** `EncounterProgressListResponse` (schema v1); row `state` inactive/active/completed; `rewardGrantSummary` from `IEncounterCompleteEventStore.GrantedItems`; optional fields omitted when not completed. +- **Tests:** five AAA tests in `EncounterProgressApiTests` (404, inactive, 1-defeat active, 2-defeat active, completed + grant summary). +- **Bruno:** `bruno/neon-sprawl-server/encounter-progress/` — inactive GET + three-NPC defeat spine. +- **Docs:** `server/README.md`; E5.M3 module snapshot; alignment register; module dependency register; backlog E5M3-08 AC. ## Technical approach diff --git a/server/README.md b/server/README.md index 0f3d515..3510340 100644 --- a/server/README.md +++ b/server/README.md @@ -151,7 +151,7 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act - **`TryMarkTargetDefeated(…)`** — requires prior activation; accumulates defeats in any order; idempotent per NPC. - **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — set equality vs catalog **`requiredNpcInstanceIds`**. -**`GET /game/players/{id}/encounter-progress`** lands in NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md). +**Per-player HTTP read:** see [Per-player encounter progress (NEO-108)](#per-player-encounter-progress-neo-108). Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md). **Combat wiring (NEO-106):** see [Encounter combat wiring (NEO-106)](#encounter-combat-wiring-neo-106) — **`EncounterCombatWiring`** on **`POST …/ability-cast`** accept path. @@ -186,6 +186,26 @@ Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_ha Plan: [NEO-107 implementation plan](../../docs/plans/NEO-107-implementation-plan.md). +## Per-player encounter progress (NEO-108) + +**`GET /game/players/{id}/encounter-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`encounters`**) projecting in-memory stores — no client-side defeat inference. Unknown players return **404** (same **`IPositionStateStore`** gate as gig/skill progression). + +Each row (catalog order via **`IEncounterDefinitionRegistry.GetDefinitionsInIdOrder()`**): + +| Field | Source | +|-------|--------| +| **`encounterId`** | Catalog encounter **`id`**. | +| **`state`** | **`inactive`** (no progress), **`active`** (started, not completed), **`completed`**. | +| **`defeatedTargetIds`** | **`IEncounterProgressStore`** defeated NPC ids (ordinal sort); `[]` when inactive. | +| **`completedAt`** | **`IEncounterCompletionStore`** when **`completed`**; omitted otherwise. | +| **`rewardGrantSummary`** | **`IEncounterCompleteEventStore.GrantedItems`** when **`completed`**; omitted otherwise. | + +Prototype: defeat all three E5.M2 NPCs via cast → row **`prototype_combat_pocket`** becomes **`completed`** with **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** in **`rewardGrantSummary`**. Plan: [NEO-108 implementation plan](../../docs/plans/NEO-108-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-progress/`. + +```bash +curl -sS -i "http://localhost:5253/game/players/dev-local-1/encounter-progress" +``` + ## Encounter combat wiring (NEO-106) On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring.TryProcessCastOutcome`** (best-effort; does not change cast JSON): @@ -196,7 +216,7 @@ On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring | **`targetDefeated: true`** | **`TryMarkTargetDefeated`**; when all three E5.M2 NPC ids defeated, **`EncounterCompletionOperations.TryCompleteAndGrant`**. | | Inventory full on completion | Defeat progress retained; completion/grants fail closed (retry when bag has space). | -**Call order on lethal accept:** aggro → cooldown → NPC runtime stop → **`CombatDefeatGigXpGrant`** (NEO-44) → **`EncounterCombatWiring`**. Gig XP per defeat is unchanged (**25** to **`breach`** each). Verify loot with **`GET …/inventory`**; per-player progress HTTP lands in NEO-108. Plan: [NEO-106 implementation plan](../../docs/plans/NEO-106-implementation-plan.md). +**Call order on lethal accept:** aggro → cooldown → NPC runtime stop → **`CombatDefeatGigXpGrant`** (NEO-44) → **`EncounterCombatWiring`**. Gig XP per defeat is unchanged (**25** to **`breach`** each). Verify loot with **`GET …/inventory`** or **`GET …/encounter-progress`** (NEO-108). Plan: [NEO-106 implementation plan](../../docs/plans/NEO-106-implementation-plan.md). ## NPC behavior definitions (NEO-90) From ebb27aed2df7d8e657fed5ba55938588c269b63d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:38:31 -0400 Subject: [PATCH 4/9] NEO-108: Add code review for encounter-progress GET. --- docs/reviews/2026-05-31-NEO-108.md | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/reviews/2026-05-31-NEO-108.md diff --git a/docs/reviews/2026-05-31-NEO-108.md b/docs/reviews/2026-05-31-NEO-108.md new file mode 100644 index 0000000..8e26cfa --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-108.md @@ -0,0 +1,62 @@ +# Code review — NEO-108 (E5M3-08) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-108-get-encounter-progress-wire-dtos-bruno` vs `51e2007` (merge-base on `main`) — commits `1601858` … `07ebb9e` +**Base:** `51e2007` (main at branch point, post NEO-107 merge) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-108 adds **`GET /game/players/{id}/encounter-progress`** — a versioned read model projecting catalog encounters from **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, and **`IEncounterCompleteEventStore`**, with the same **`IPositionStateStore`** 404 gate as gig/skill progression. **`EncounterProgressApi.BuildSnapshot`** is internal and testable; state derivation (`inactive` / `active` / `completed`), ordinal-sorted **`defeatedTargetIds`**, and commit-time **`rewardGrantSummary`** match the kickoff plan. Five AAA integration tests cover 404, inactive default, 1- and 2-defeat active rows, and completed + grant summary; Bruno adds inactive smoke and a three-NPC defeat spine with intermediate GET assertions and idempotent second GET. Docs (plan, backlog E5M3-08, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. Godot poll client remains correctly deferred to **NEO-110**. Risk is low: read-only projection with fail-fast invariant checks on completed rows. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-108-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; frozen row progression table reflected in code, tests, and Bruno. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-08) | **Matches** — acceptance criteria checked; landed note cites plan; client counterpart **NEO-110** called out. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status row includes **E5M3-08 / NEO-108**; implementation snapshot bullet added with README + Bruno links. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-108** per-player GET. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-108** GET landed. | +| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110**; no prototype slice completion claim. | +| `server/README.md` | **Matches** — new **Per-player encounter progress (NEO-108)** section; NEO-106 section cross-links GET for loot verification. | + +## Blocking issues + +None. + +## Suggestions + +1. **Invariant violation tests for `BuildSnapshot`** — Plan adopts fail-fast **`InvalidOperationException`** when **`completed`** but **`completedAt`** or **`EncounterCompleteEvent`** is missing. No direct unit test exercises these paths (would require a small fake/mocked store pair). A focused **`BuildSnapshot`** test would lock the contract without a full defeat spine. + +2. **Idempotent second GET in C# tests** — Bruno **`Get encounter progress after defeat spine.bru`** asserts a second GET stays **`completed`** with unchanged grant summary; the C# suite stops after one GET on the completed case. A second **`GetAsync`** in the completed test (or a dedicated test) would mirror Bruno at the integration layer. + +3. **Whitespace / empty player id 404** — Route trims **`id`** and returns 404 when empty; only **`missing-player`** is tested. A **`/game/players/%20/encounter-progress`** (or `""` after trim) case would match the documented gate explicitly (same gap exists on gig progression — low priority). + +## Nits + +- Nit: **`MapRow`** calls **`progressStore.TryGetProgress`** twice on the **`active`** path (once for **`defeatedTargetIds`**, again for **`Started`**). A single **`TryGet`** with reused **`progress`** variable would avoid the duplicate lookup (prototype catalog size = 1; cosmetic only). + +- Nit: **`EncounterProgressApiTests`** defeat helper binds hotbar **slot 0**; Bruno spine uses **slot 3** per plan (cooldown isolation). Both are fine in isolation; no change required unless slot 0 conflicts emerge in combined Bruno runs. + +- Nit: **`MapGrantSummary`** preserves reward-table grant order (scrap then token). C# completed test asserts quantities via **`Single(g => g.ItemId == …)`** (order-agnostic); Bruno asserts array order. Document or test order explicitly if clients will diff arrays. + +## Verification + +```bash +# NEO-108-focused (5 tests — passed locally) +cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~EncounterProgressApiTests" + +# Encounters module parity (79 tests — passed locally) +cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~Encounters" + +# Bruno (server running on baseUrl) +# bruno/neon-sprawl-server/encounter-progress/ +``` + +**Local run:** 5/5 `EncounterProgressApiTests` passed; 79/79 `Encounters` filter passed. From 25f3714554fa5183d1cf2224fc5933a9ac413b23 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:39:52 -0400 Subject: [PATCH 5/9] NEO-108: Address code review findings for encounter-progress GET. Add BuildSnapshot invariant tests, whitespace 404, idempotent second GET, grant-order assertions; dedupe MapRow progress lookup; strike review items. --- docs/reviews/2026-05-31-NEO-108.md | 10 +- .../Encounters/EncounterProgressApiTests.cs | 114 +++++++++++++++++- .../Game/Encounters/EncounterProgressApi.cs | 14 +-- 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/docs/reviews/2026-05-31-NEO-108.md b/docs/reviews/2026-05-31-NEO-108.md index 8e26cfa..ee3c6f2 100644 --- a/docs/reviews/2026-05-31-NEO-108.md +++ b/docs/reviews/2026-05-31-NEO-108.md @@ -30,19 +30,19 @@ None. ## Suggestions -1. **Invariant violation tests for `BuildSnapshot`** — Plan adopts fail-fast **`InvalidOperationException`** when **`completed`** but **`completedAt`** or **`EncounterCompleteEvent`** is missing. No direct unit test exercises these paths (would require a small fake/mocked store pair). A focused **`BuildSnapshot`** test would lock the contract without a full defeat spine. +1. ~~**Invariant violation tests for `BuildSnapshot`** — Plan adopts fail-fast **`InvalidOperationException`** when **`completed`** but **`completedAt`** or **`EncounterCompleteEvent`** is missing. No direct unit test exercises these paths (would require a small fake/mocked store pair). A focused **`BuildSnapshot`** test would lock the contract without a full defeat spine.~~ **Done.** `BuildSnapshot_ShouldThrow_WhenCompletedButCompleteEventMissing` + `BuildSnapshot_ShouldThrow_WhenCompletedButCompletedAtMissing` (`CompletedWithoutTimestampStore` fake). -2. **Idempotent second GET in C# tests** — Bruno **`Get encounter progress after defeat spine.bru`** asserts a second GET stays **`completed`** with unchanged grant summary; the C# suite stops after one GET on the completed case. A second **`GetAsync`** in the completed test (or a dedicated test) would mirror Bruno at the integration layer. +2. ~~**Idempotent second GET in C# tests** — Bruno **`Get encounter progress after defeat spine.bru`** asserts a second GET stays **`completed`** with unchanged grant summary; the C# suite stops after one GET on the completed case. A second **`GetAsync`** in the completed test (or a dedicated test) would mirror Bruno at the integration layer.~~ **Done.** Second **`GetAsync`** in completed test asserts stable state, **`completedAt`**, and grant summary. -3. **Whitespace / empty player id 404** — Route trims **`id`** and returns 404 when empty; only **`missing-player`** is tested. A **`/game/players/%20/encounter-progress`** (or `""` after trim) case would match the documented gate explicitly (same gap exists on gig progression — low priority). +3. ~~**Whitespace / empty player id 404** — Route trims **`id`** and returns 404 when empty; only **`missing-player`** is tested. A **`/game/players/%20/encounter-progress`** (or `""` after trim) case would match the documented gate explicitly (same gap exists on gig progression — low priority).~~ **Done.** `GetEncounterProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly`. ## Nits -- Nit: **`MapRow`** calls **`progressStore.TryGetProgress`** twice on the **`active`** path (once for **`defeatedTargetIds`**, again for **`Started`**). A single **`TryGet`** with reused **`progress`** variable would avoid the duplicate lookup (prototype catalog size = 1; cosmetic only). +- ~~Nit: **`MapRow`** calls **`progressStore.TryGetProgress`** twice on the **`active`** path (once for **`defeatedTargetIds`**, again for **`Started`**). A single **`TryGet`** with reused **`progress`** variable would avoid the duplicate lookup (prototype catalog size = 1; cosmetic only).~~ **Done.** - Nit: **`EncounterProgressApiTests`** defeat helper binds hotbar **slot 0**; Bruno spine uses **slot 3** per plan (cooldown isolation). Both are fine in isolation; no change required unless slot 0 conflicts emerge in combined Bruno runs. -- Nit: **`MapGrantSummary`** preserves reward-table grant order (scrap then token). C# completed test asserts quantities via **`Single(g => g.ItemId == …)`** (order-agnostic); Bruno asserts array order. Document or test order explicitly if clients will diff arrays. +- ~~Nit: **`MapGrantSummary`** preserves reward-table grant order (scrap then token). C# completed test asserts quantities via **`Single(g => g.ItemId == …)`** (order-agnostic); Bruno asserts array order. Document or test order explicitly if clients will diff arrays.~~ **Done.** XML on **`MapGrantSummary`**; completed test asserts full grant array order (matches Bruno). ## Verification diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs index f68851c..d7dd7a4 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs @@ -29,6 +29,20 @@ public sealed class EncounterProgressApiTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/%20/encounter-progress"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + [Fact] public async Task GetEncounterProgress_ShouldReturnInactive_WhenNoEngagement() { @@ -138,13 +152,19 @@ public sealed class EncounterProgressApiTests factory, PrototypeNpcRegistry.PrototypeNpcEliteId); var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); + var secondResponse = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); var body = await response.Content.ReadFromJsonAsync(); + var secondBody = await secondResponse.Content.ReadFromJsonAsync(); Assert.NotNull(body); + Assert.NotNull(secondBody); var row = Assert.Single(body!.Encounters); + var secondRow = Assert.Single(secondBody!.Encounters); Assert.Equal(EncounterProgressApi.StateCompleted, row.State); + Assert.Equal(EncounterProgressApi.StateCompleted, secondRow.State); Assert.Equal( [ PrototypeNpcRegistry.PrototypeNpcEliteId, @@ -152,16 +172,106 @@ public sealed class EncounterProgressApiTests PrototypeNpcRegistry.PrototypeNpcRangedId, ], row.DefeatedTargetIds); + Assert.Equal(row.DefeatedTargetIds, secondRow.DefeatedTargetIds); Assert.NotNull(row.CompletedAt); Assert.NotNull(row.RewardGrantSummary); - Assert.Equal(10, row.RewardGrantSummary!.Single(g => g.ItemId == "scrap_metal_bulk").Quantity); - Assert.Equal(1, row.RewardGrantSummary.Single(g => g.ItemId == "contract_handoff_token").Quantity); + Assert.Equal(row.CompletedAt, secondRow.CompletedAt); + Assert.Equal(2, row.RewardGrantSummary!.Count); + Assert.Equal("scrap_metal_bulk", row.RewardGrantSummary[0].ItemId); + Assert.Equal(10, row.RewardGrantSummary[0].Quantity); + Assert.Equal("contract_handoff_token", row.RewardGrantSummary[1].ItemId); + Assert.Equal(1, row.RewardGrantSummary[1].Quantity); + Assert.Equal(row.RewardGrantSummary[0].ItemId, secondRow.RewardGrantSummary![0].ItemId); + Assert.Equal(row.RewardGrantSummary[1].ItemId, secondRow.RewardGrantSummary[1].ItemId); var completionStore = factory.Services.GetRequiredService(); var eventStore = factory.Services.GetRequiredService(); Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId)); Assert.True(eventStore.TryGet("dev-local-1", PrototypeEncounterId, out _)); } + [Fact] + public async Task BuildSnapshot_ShouldThrow_WhenCompletedButCompleteEventMissing() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progressStore = factory.Services.GetRequiredService(); + var completionStore = factory.Services.GetRequiredService(); + var eventStore = factory.Services.GetRequiredService(); + Assert.True( + completionStore.TryMarkCompleted( + "dev-local-1", + PrototypeEncounterId, + DateTimeOffset.UtcNow)); + + // Act + var ex = Record.Exception( + () => EncounterProgressApi.BuildSnapshot( + "dev-local-1", + registry, + progressStore, + completionStore, + eventStore)); + + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("EncounterCompleteEvent is missing", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void BuildSnapshot_ShouldThrow_WhenCompletedButCompletedAtMissing() + { + // Arrange + var registry = CreatePrototypeEncounterRegistry(); + var progressStore = new InMemoryEncounterProgressStore(); + var completionStore = new CompletedWithoutTimestampStore(); + var eventStore = new InMemoryEncounterCompleteEventStore(); + + // Act + var ex = Record.Exception( + () => EncounterProgressApi.BuildSnapshot( + "dev-local-1", + registry, + progressStore, + completionStore, + eventStore)); + + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("completedAt is missing", ioe.Message, StringComparison.Ordinal); + } + + private static EncounterDefinitionRegistry CreatePrototypeEncounterRegistry() + { + var row = new EncounterDefRow( + PrototypeEncounterId, + "Prototype Combat Pocket", + "defeat_all_targets", + ["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"], + "prototype_combat_pocket_clear"); + var catalog = new EncounterDefinitionCatalog( + "/tmp/encounters", + new Dictionary(StringComparer.Ordinal) { [PrototypeEncounterId] = row }, + catalogJsonFileCount: 1); + return new EncounterDefinitionRegistry(catalog); + } + + private sealed class CompletedWithoutTimestampStore : IEncounterCompletionStore + { + public bool IsCompleted(string playerId, string encounterId) => + string.Equals(playerId, "dev-local-1", StringComparison.Ordinal) + && string.Equals(encounterId, PrototypeEncounterId, StringComparison.Ordinal); + + public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => + throw new NotSupportedException(); + + public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) + { + completedAt = default; + return false; + } + } + private static async Task BindSlot0PulseAsync(HttpClient client) { var post = await client.PostAsJsonAsync( diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs index 47d62a6..099a05c 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs @@ -70,13 +70,10 @@ public static class EncounterProgressApi IEncounterCompletionStore completionStore, IEncounterCompleteEventStore completeEventStore) { - var defeatedTargetIds = Array.Empty(); - if (progressStore.TryGetProgress(playerId, encounterId, out var progress)) - { - defeatedTargetIds = progress.DefeatedNpcInstanceIds - .OrderBy(static id => id, StringComparer.Ordinal) - .ToArray(); - } + var hasProgress = progressStore.TryGetProgress(playerId, encounterId, out var progress); + var defeatedTargetIds = hasProgress + ? progress.DefeatedNpcInstanceIds.OrderBy(static id => id, StringComparer.Ordinal).ToArray() + : Array.Empty(); if (completionStore.IsCompleted(playerId, encounterId)) { @@ -102,7 +99,7 @@ public static class EncounterProgressApi }; } - if (progressStore.TryGetProgress(playerId, encounterId, out progress) && progress.Started) + if (hasProgress && progress.Started) { return new EncounterProgressRowJson { @@ -120,6 +117,7 @@ public static class EncounterProgressApi }; } + /// Preserves commit-time grant order from (reward-table apply order). private static List MapGrantSummary(IReadOnlyList grants) { var summary = new List(grants.Count); From a5182a4c1486a8ece0512a9e08dc27148fe06d00 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:43:50 -0400 Subject: [PATCH 6/9] NEO-108: Fix Bruno encounter-progress and combat-health smokes. Use collection-relative ./scripts require path; reset encounter stores in combat-targets dev fixture for inactive GET; combat-health uses slot 3. --- .../Get combat health after elite attack.bru | 6 ++-- ... encounter progress after defeat spine.bru | 2 +- .../Get encounter progress inactive.bru | 6 ++++ .../Combat/CombatTargetFixtureApiTests.cs | 18 +++++++++++ .../Game/Combat/CombatTargetFixtureApi.cs | 12 +++++++ .../EncounterDevFixtureOperations.cs | 32 +++++++++++++++++++ .../InMemoryEncounterCompleteEventStore.cs | 17 ++++++++++ .../InMemoryEncounterCompletionStore.cs | 17 ++++++++++ .../InMemoryEncounterProgressStore.cs | 17 ++++++++++ server/README.md | 2 +- 10 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDevFixtureOperations.cs diff --git a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru index 3e2667f..faf175a 100644 --- a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru +++ b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru @@ -29,11 +29,13 @@ script:pre-request { jsonHeaders, ); + const slotIndex = 3; + await axios.post( `${baseUrl}/game/players/${playerId}/hotbar-loadout`, { schemaVersion: 1, - slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + slots: [{ slotIndex, abilityId: "prototype_pulse" }], }, jsonHeaders, ); @@ -48,7 +50,7 @@ script:pre-request { `${baseUrl}/game/players/${playerId}/ability-cast`, { schemaVersion: 1, - slotIndex: 0, + slotIndex, abilityId: "prototype_pulse", targetId: "prototype_npc_elite", }, diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru index 3eb4898..5a5194f 100644 --- a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru @@ -10,7 +10,7 @@ docs { script:pre-request { const axios = require("axios"); - const { resetPrototypeCombatTargets } = require("../scripts/combat-targets-reset-helper.js"); + 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" } }; diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru index b7a4357..ccd29e6 100644 --- a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru @@ -6,6 +6,12 @@ meta { docs { NEO-108: fresh player row before engagement — inactive with empty defeatedTargetIds. + Pre-request uses combat-targets dev fixture (also clears encounter progress for dev-local-1). +} + +script:pre-request { + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + await resetPrototypeCombatTargets(bru); } get { diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index 584fbe9..0cf8c35 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Tests; using Xunit; @@ -23,6 +24,20 @@ public sealed class CombatTargetFixtureApiTests var threatStore = factory.Services.GetRequiredService(); var runtimeStore = factory.Services.GetRequiredService(); var playerHealthStore = factory.Services.GetRequiredService(); + var progressStore = factory.Services.GetRequiredService(); + var completionStore = factory.Services.GetRequiredService(); + var eventStore = factory.Services.GetRequiredService(); + _ = progressStore.TryActivate("dev-local-1", "prototype_combat_pocket"); + _ = progressStore.TryAddDefeatedTarget("dev-local-1", "prototype_combat_pocket", "prototype_npc_melee"); + _ = completionStore.TryMarkCompleted("dev-local-1", "prototype_combat_pocket", DateTimeOffset.UtcNow); + _ = eventStore.TryRecord( + new EncounterCompleteEvent( + "dev-local-1", + "prototype_combat_pocket", + "prototype_combat_pocket_clear", + [new EncounterGrantApplied("scrap_metal_bulk", 10)], + DateTimeOffset.UtcNow, + "dev-local-1:prototype_combat_pocket")); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); _ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); @@ -59,6 +74,9 @@ public sealed class CombatTargetFixtureApiTests playerHealthStore.TryGet("dev-local-1", out var playerHealth); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp); Assert.False(playerHealth.Defeated); + Assert.False(progressStore.TryGetProgress("dev-local-1", "prototype_combat_pocket", out _)); + Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket")); + Assert.False(eventStore.TryGet("dev-local-1", "prototype_combat_pocket", out _)); } [Fact] diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 7befa5b..246e6c8 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; @@ -17,6 +18,10 @@ public static class CombatTargetFixtureApi IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore, IPlayerCombatHealthStore playerHealthStore, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore encounterProgressStore, + IEncounterCompletionStore encounterCompletionStore, + IEncounterCompleteEventStore encounterCompleteEventStore, IOptions gameOptions) => { if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) @@ -41,6 +46,13 @@ public static class CombatTargetFixtureApi return Results.NotFound(); } + EncounterDevFixtureOperations.ClearPlayerEncounterState( + devPlayerId, + encounterRegistry, + encounterProgressStore, + encounterCompletionStore, + encounterCompleteEventStore); + return Results.Json( new CombatTargetFixtureResponse { diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDevFixtureOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDevFixtureOperations.cs new file mode 100644 index 0000000..658f612 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDevFixtureOperations.cs @@ -0,0 +1,32 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Clears per-player encounter stores for dev Bruno/QA resets (NEO-108). +internal static class EncounterDevFixtureOperations +{ + /// Removes progress, completion, and event rows for across catalog encounters. + public static void ClearPlayerEncounterState( + string playerId, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore eventStore) + { + foreach (var def in encounterRegistry.GetDefinitionsInIdOrder()) + { + if (progressStore is InMemoryEncounterProgressStore inMemoryProgress) + { + inMemoryProgress.TryClear(playerId, def.Id); + } + + if (completionStore is InMemoryEncounterCompletionStore inMemoryCompletion) + { + inMemoryCompletion.TryClear(playerId, def.Id); + } + + if (eventStore is InMemoryEncounterCompleteEventStore inMemoryEvent) + { + inMemoryEvent.TryClear(playerId, def.Id); + } + } + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs index bd7236d..539b549 100644 --- a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs @@ -48,4 +48,21 @@ public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEven return eventsByKey.TryGetValue(key, out completeEvent); } } + + /// Dev fixture only — removes one player+encounter event row (NEO-108 Bruno reset). + public bool TryClear(string playerId, string encounterId) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + var removed = eventsByKey.TryRemove(key, out _); + keyLocks.TryRemove(key, out _); + return removed; + } + } } diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs index 656f674..dfafb72 100644 --- a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs @@ -60,4 +60,21 @@ public sealed class InMemoryEncounterCompletionStore : IEncounterCompletionStore return completedAtByKey.TryGetValue(key, out completedAt); } } + + /// Dev fixture only — removes one player+encounter completion row (NEO-108 Bruno reset). + public bool TryClear(string playerId, string encounterId) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + var removed = completedAtByKey.TryRemove(key, out _); + keyLocks.TryRemove(key, out _); + return removed; + } + } } diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs index 3adf4c4..01e5ef5 100644 --- a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs @@ -85,4 +85,21 @@ public sealed class InMemoryEncounterProgressStore : IEncounterProgressStore return row.DefeatedNpcInstanceIds.Add(npcKey); } } + + /// Dev fixture only — removes one player+encounter progress row (NEO-108 Bruno reset). + public bool TryClear(string playerId, string encounterId) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + var removed = byKey.TryRemove(key, out _); + keyLocks.TryRemove(key, out _); + return removed; + } + } } diff --git a/server/README.md b/server/README.md index 3510340..06d6bb5 100644 --- a/server/README.md +++ b/server/README.md @@ -264,7 +264,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m curl -sS -i "http://localhost:5253/game/world/combat-targets" ``` -**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), and restores dev player combat HP to full (**NEO-95**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. +**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), restores dev player combat HP to full (**NEO-95**), and clears in-memory encounter progress/completion/event rows for the dev player across catalog encounters (**NEO-108** Bruno). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js` (request scripts use `./scripts/…` relative to the collection). ## Threat / aggro state (NEO-92) From af80b878ad1ade50c6921a36f343cd98cca69a01 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:46:00 -0400 Subject: [PATCH 7/9] NEO-108: Bruno move near each NPC and isolate pulse slot. Ranged/elite casts failed out_of_range after melee at (-5,-5); add prototype-npc-bruno-helper moves and pulse cooldown between chains. Combat-health uses slot 4 and moves near elite before cast. --- .../Get combat health after elite attack.bru | 18 ++++----- ... encounter progress after defeat spine.bru | 23 ++++++----- .../scripts/prototype-npc-bruno-helper.js | 38 +++++++++++++++++++ 3 files changed, 58 insertions(+), 21 deletions(-) create mode 100644 bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js diff --git a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru index faf175a..b953592 100644 --- a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru +++ b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru @@ -13,24 +13,24 @@ docs { script:pre-request { const axios = require("axios"); const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + const { + moveNearPrototypeNpc, + sleep, + } = require("./scripts/prototype-npc-bruno-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 slotIndex = 4; await resetPrototypeCombatTargets(bru); - await axios.post( - `${baseUrl}/game/players/${playerId}/move`, - { schemaVersion: 1, target: { x: -2, y: 0.9, z: -2 } }, + await moveNearPrototypeNpc( + baseUrl, + playerId, + "prototype_npc_elite", jsonHeaders, ); - const slotIndex = 3; - await axios.post( `${baseUrl}/game/players/${playerId}/hotbar-loadout`, { diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru index 5a5194f..d715e61 100644 --- a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru @@ -11,15 +11,16 @@ docs { script:pre-request { const axios = require("axios"); const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + const { + moveNearPrototypeNpc, + PULSE_COOLDOWN_MS, + sleep, + } = require("./scripts/prototype-npc-bruno-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" } }; const slotIndex = 3; - function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - function rowForPocket(body) { const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket"); if (!row) { @@ -40,11 +41,13 @@ script:pre-request { } async function defeatNpc(targetId) { + await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders); await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, { schemaVersion: 1, targetId }, jsonHeaders, ); + await sleep(PULSE_COOLDOWN_MS); const castBody = { schemaVersion: 1, slotIndex, @@ -59,13 +62,15 @@ script:pre-request { ); const body = response.data; if (!body?.accepted || !body?.combatResolution) { - throw new Error(`cast ${i + 1} vs ${targetId} failed: ${JSON.stringify(body)}`); + throw new Error( + `cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`, + ); } if (body.combatResolution.targetDefeated) { return; } if (i < 3) { - await sleep(3200); + await sleep(PULSE_COOLDOWN_MS); } } throw new Error(`did not defeat ${targetId} within 4 pulses`); @@ -73,12 +78,6 @@ script:pre-request { 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`, { diff --git a/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js b/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js new file mode 100644 index 0000000..2638715 --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js @@ -0,0 +1,38 @@ +const axios = require("axios"); + +/** Matches server anchors + AbilityCastApiTests move offset. */ +const PROTOTYPE_NPC_ANCHORS = { + prototype_npc_melee: { x: -3, y: 0.5, z: -3 }, + prototype_npc_ranged: { x: 3, y: 0, z: 3 }, + prototype_npc_elite: { x: 0, y: 0.5, z: 0 }, +}; + +/** prototype_pulse catalog cooldown (seconds) — real-time Bruno waits. */ +const PULSE_COOLDOWN_MS = 3200; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders) { + const anchor = PROTOTYPE_NPC_ANCHORS[targetId]; + if (!anchor) { + throw new Error(`unknown prototype npc: ${targetId}`); + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { + schemaVersion: 1, + target: { x: anchor.x - 1, y: 0.9, z: anchor.z - 1 }, + }, + jsonHeaders, + ); +} + +module.exports = { + PROTOTYPE_NPC_ANCHORS, + PULSE_COOLDOWN_MS, + moveNearPrototypeNpc, + sleep, +}; From 05beec9644c7404b1ed81adeb40b4a32e6af25a6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:48:22 -0400 Subject: [PATCH 8/9] NEO-108: Use bru.sleep in Bruno helper (no setTimeout). Bruno pre-request sandbox does not define setTimeout; pulse waits and telegraph polls now call bru.sleep via prototype-npc-bruno-helper. --- .../combat-health/Get combat health after elite attack.bru | 4 ++-- .../Get encounter progress after defeat spine.bru | 4 ++-- .../neon-sprawl-server/scripts/prototype-npc-bruno-helper.js | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru index b953592..41314ea 100644 --- a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru +++ b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru @@ -64,10 +64,10 @@ script:pre-request { await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); - await sleep(5000); + await sleep(bru, 5000); await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); - await sleep(2500); + await sleep(bru, 2500); await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); bru.setVar("neo95CastBody", JSON.stringify(castBody)); diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru index d715e61..02e23b5 100644 --- a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru @@ -47,7 +47,7 @@ script:pre-request { { schemaVersion: 1, targetId }, jsonHeaders, ); - await sleep(PULSE_COOLDOWN_MS); + await sleep(bru, PULSE_COOLDOWN_MS); const castBody = { schemaVersion: 1, slotIndex, @@ -70,7 +70,7 @@ script:pre-request { return; } if (i < 3) { - await sleep(PULSE_COOLDOWN_MS); + await sleep(bru, PULSE_COOLDOWN_MS); } } throw new Error(`did not defeat ${targetId} within 4 pulses`); diff --git a/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js b/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js index 2638715..a1cd06c 100644 --- a/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js +++ b/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js @@ -10,8 +10,9 @@ const PROTOTYPE_NPC_ANCHORS = { /** prototype_pulse catalog cooldown (seconds) — real-time Bruno waits. */ const PULSE_COOLDOWN_MS = 3200; -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); +/** Bruno sandbox has no setTimeout — use bru.sleep. */ +async function sleep(bru, ms) { + await bru.sleep(ms); } async function moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders) { From 40d948ff2cbfdf7b55de7342b4f387c73c94a177 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:50:29 -0400 Subject: [PATCH 9/9] NEO-108: Bruno elite defeat uses 12 pulses; reset HP baseline. Elite has 200 HP (8 pulses at 25 damage); defeat loop matches C# tests. Combat-health seq 1 resets dev fixture so currentHp is 100 after prior smokes. --- .../combat-health/Get player combat health.bru | 5 +++++ .../Get encounter progress after defeat spine.bru | 9 ++++++--- .../scripts/prototype-npc-bruno-helper.js | 4 ++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bruno/neon-sprawl-server/combat-health/Get player combat health.bru b/bruno/neon-sprawl-server/combat-health/Get player combat health.bru index 9ca6dcd..25c1be3 100644 --- a/bruno/neon-sprawl-server/combat-health/Get player combat health.bru +++ b/bruno/neon-sprawl-server/combat-health/Get player combat health.bru @@ -4,6 +4,11 @@ meta { seq: 1 } +script:pre-request { + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + await resetPrototypeCombatTargets(bru); +} + get { url: {{baseUrl}}/game/players/dev-local-1/combat-health body: none diff --git a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru index 02e23b5..4bd75a4 100644 --- a/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru +++ b/bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru @@ -14,6 +14,7 @@ script:pre-request { const { moveNearPrototypeNpc, PULSE_COOLDOWN_MS, + MAX_PULSE_DEFEAT_ATTEMPTS, sleep, } = require("./scripts/prototype-npc-bruno-helper.js"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); @@ -54,7 +55,7 @@ script:pre-request { abilityId: "prototype_pulse", targetId, }; - for (let i = 0; i < 4; i++) { + for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i++) { const response = await axios.post( `${baseUrl}/game/players/${playerId}/ability-cast`, castBody, @@ -69,11 +70,13 @@ script:pre-request { if (body.combatResolution.targetDefeated) { return; } - if (i < 3) { + if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) { await sleep(bru, PULSE_COOLDOWN_MS); } } - throw new Error(`did not defeat ${targetId} within 4 pulses`); + throw new Error( + `did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`, + ); } await resetPrototypeCombatTargets(bru); diff --git a/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js b/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js index a1cd06c..40e4b8e 100644 --- a/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js +++ b/bruno/neon-sprawl-server/scripts/prototype-npc-bruno-helper.js @@ -10,6 +10,9 @@ const PROTOTYPE_NPC_ANCHORS = { /** prototype_pulse catalog cooldown (seconds) — real-time Bruno waits. */ const PULSE_COOLDOWN_MS = 3200; +/** Matches AbilityCastApiTests defeat loop (elite 200 HP needs 8×25 damage). */ +const MAX_PULSE_DEFEAT_ATTEMPTS = 12; + /** Bruno sandbox has no setTimeout — use bru.sleep. */ async function sleep(bru, ms) { await bru.sleep(ms); @@ -34,6 +37,7 @@ async function moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders) { module.exports = { PROTOTYPE_NPC_ANCHORS, PULSE_COOLDOWN_MS, + MAX_PULSE_DEFEAT_ATTEMPTS, moveNearPrototypeNpc, sleep, };