From 5fc780c8aba87988d566c942889a0b7941e5013e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Tue, 16 Jun 2026 17:17:42 -0400 Subject: [PATCH] NEO-139: kickoff implementation plan for faction-standing GET API. --- docs/plans/NEO-139-implementation-plan.md | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/plans/NEO-139-implementation-plan.md diff --git a/docs/plans/NEO-139-implementation-plan.md b/docs/plans/NEO-139-implementation-plan.md new file mode 100644 index 0000000..5410f4f --- /dev/null +++ b/docs/plans/NEO-139-implementation-plan.md @@ -0,0 +1,171 @@ +# NEO-139 — E7M3-07: GET faction-standing HTTP read + +**Linear:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) +**Branch:** `NEO-139-e7m3-get-faction-standing-http-read` +**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-07** +**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) +**Pattern:** [NEO-37](NEO-37-implementation-plan.md) / [NEO-44](NEO-44-implementation-plan.md) — per-player snapshot GET + `schemaVersion`; [NEO-6](NEO-6-implementation-plan.md) — unknown player **404** via position gate +**Precursors:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore`; [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) **`Done`** — operator-chain rep grant **+15** via reward router +**Blocks:** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) (quest HTTP rep projections), [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) (Godot HUD) +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — parses **`GET …/faction-standing`**; blocked until this story lands + +## Goal + +Client-readable standing snapshot for all frozen factions. + +## Kickoff clarifications + +**No clarifications needed.** Linear AC, [E7M3-07 backlog scope](E7M3-pre-production-backlog.md#e7m3-07--get-gameplayersidfaction-standing-http-read), and snapshot-read precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Unknown player | **404** before building body | Linear AC “position gate precedent”; `SkillProgressionSnapshotApi`, `GigProgressionSnapshotApi`, `PositionStateApi` | +| Player existence gate | **`IPositionStateStore.TryGetPosition(trimmedId)`** — not `CanWritePlayer` alone | Same trio of snapshot APIs; position row is the shared “known player” contract | +| Path id handling | **Trim** path segment; echo trimmed id in JSON **`playerId`** | Skill/gig snapshot APIs (position GET additionally case-insensitive lookup — snapshot APIs use trim-only + position gate) | +| Response shape | **`schemaVersion` 1**, **`playerId`**, **`factions`** array of **`{ id, standing }`** | Backlog “lists each catalog faction with current standing (default 0)”; mirrors skill progression row minimalism (display names deferred — no `GET /game/world/faction-definitions` yet) | +| Faction roster | **One row per catalog faction** via **`IFactionDefinitionRegistry.GetDefinitionsInIdOrder()`** | Backlog “all frozen factions”; prototype roster = 2 ids (`PrototypeE7M3FactionCatalogRules`) | +| Missing standing row | **`standing: 0`** (clamped) via **`IFactionStandingStore.TryGetStanding`** per faction | NEO-135 store contract; HTTP layer does not reimplement neutral default | +| Array order | **Not part of public contract** — consumers key by **`id`** (doc on DTO) | `SkillProgressionSnapshotResponse` XML doc precedent | +| Bruno scope | **Default snapshot smoke** + **operator-chain standing assertion (+15 Grid Operators)** | Backlog Bruno folder; closes NEO-138 deferral (“No standing GET until NEO-139”) | +| Godot / quest projections | **Out of scope** | E7M3-08 (NEO-140), E7M3-10 (NEO-142) | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`GET /game/players/{id}/faction-standing`** — `FactionStandingApi` + DTOs in `Game/Factions/`. +- Response lists each catalog faction with current standing (default **0** when no row). +- Integration tests + Bruno `bruno/neon-sprawl-server/faction-standing/`. +- `server/README.md` section. +- Optional: extend **`Accept grid contract after operator chain.bru`** docs or add sibling Bruno that GETs standing after chain (assert **+15** on Grid Operators). + +**Out of scope:** + +- Godot HUD (NEO-142), quest HTTP rep/gate projections (NEO-140), telemetry ingest (NEO-141). +- **`GET /game/world/faction-definitions`** (no backlog item; client can join display names later). +- Write/mutation endpoints (standing changes stay on **`ReputationOperations`** / quest reward path). + +**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — standing label + gate feedback after NEO-139/NEO-140. + +## Acceptance criteria checklist + +- [ ] GET returns stable JSON for prototype player after quest rep grants. +- [ ] Unknown player returns **404** (position gate precedent). +- [ ] `dotnet test` covers default snapshot, post-rep standing, and unknown-player 404. + +## Technical approach + +### 1. DTOs (`Game/Factions/FactionStandingSnapshotDtos.cs`) + +| Type | JSON | Notes | +|------|------|-------| +| **`FactionStandingSnapshotResponse`** | `schemaVersion`, `playerId`, `factions` | `CurrentSchemaVersion = 1` | +| **`FactionStandingRowJson`** | `id`, `standing` | One row per catalog faction | + +Document that **`factions` array order is not contractual** — key rows by **`id`**. + +### 2. `FactionStandingApi` (`Game/Factions/FactionStandingApi.cs`) + +```csharp +app.MapGet( + "/game/players/{id}/faction-standing", + (string id, IPositionStateStore positions, + IFactionDefinitionRegistry factionRegistry, + IFactionStandingStore standingStore) => { ... }); +``` + +**Flow:** + +1. `trimmedId = id.Trim()`; if empty or `!positions.TryGetPosition(trimmedId, out _)` ⇒ **`Results.NotFound()`**. +2. **`BuildSnapshot(trimmedId, factionRegistry, standingStore)`**: + - Iterate **`factionRegistry.GetDefinitionsInIdOrder()`**. + - For each definition, **`standingStore.TryGetStanding(playerId, definition.Id)`** — on success use **`outcome.Standing`**; store deny for catalog faction should be unreachable (defense: skip/500 only if violated — prefer **`Debug.Assert`** in dev; integration tests use real catalog). +3. Return **`Results.Json(response)`**. + +**`BuildSnapshot`** is **`internal static`** for unit/integration reuse (mirror **`SkillProgressionSnapshotApi.BuildSnapshot`**). + +### 3. Wiring + +| Site | Change | +|------|--------| +| **`Program.cs`** | **`app.MapFactionStandingApi()`** after faction standing store registration (near skill/gig snapshot maps) | + +### 4. Prototype test scenarios + +| Scenario | Setup | Expected | +|----------|-------|----------| +| Fresh dev player | GET **`dev-local-1`** | **200**; **`schemaVersion` 1**; **2** factions; both **`standing: 0`** | +| Unknown player | GET **`missing-player`** | **404** | +| After operator-chain rep | Deliver operator-chain completion via **`RewardRouterOperations`** or HTTP quest flow helper | Grid Operators **`standing: 15`**; Rust Collective **`standing: 0`** | +| Case on path | GET with trimmed id | **200**; body **`playerId`** echoes trimmed path (skill progression precedent) | + +Constants: **`PrototypeE7M3FactionCatalogRules.ExpectedFactionIds`**, **`PrototypeE7M3QuestFactionRules.GridContractGateFactionId`**. + +### 5. Bruno (`bruno/neon-sprawl-server/faction-standing/`) + +| Request | seq | Role | +|---------|-----|------| +| **`Health after faction standing store load.bru`** | 1 | Keep existing health smoke (NEO-135) | +| **`Get faction standing default.bru`** | 2 | **200**; 2 factions; all **`standing: 0`** on fresh dev player (document restart/reset) | +| **`Get faction standing unknown player.bru`** | 3 | **404** | +| **`Get faction standing after operator chain.bru`** | 4 | Pre-request **`operator-chain-quest-flow-helper`**; assert Grid Operators **15**, Rust Collective **0** | + +Update **`Accept grid contract after operator chain.bru`** docs to reference standing GET sibling at seq **4+**. + +### 6. README + +Add **`## Faction standing read (NEO-139)`** after standing store section: + +- Route, sample JSON (defaults + post-chain example). +- Unknown player **404**; join display names from content client-side until world API exists. +- Cross-link NEO-142 client consumer. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Factions/FactionStandingSnapshotDtos.cs` | Response + row JSON types | +| `server/NeonSprawl.Server/Game/Factions/FactionStandingApi.cs` | Route map + `BuildSnapshot` | +| `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingApiTests.cs` | Integration tests (404, defaults, post-rep) | +| `bruno/neon-sprawl-server/faction-standing/Get faction standing default.bru` | Default snapshot smoke | +| `bruno/neon-sprawl-server/faction-standing/Get faction standing unknown player.bru` | 404 smoke | +| `bruno/neon-sprawl-server/faction-standing/Get faction standing after operator chain.bru` | +15 assertion after HTTP quest flow | + +## Files to modify + +| Path | Change | +|------|--------| +| `server/NeonSprawl.Server/Program.cs` | Register **`MapFactionStandingApi()`** | +| `server/README.md` | Faction standing GET section + curl example | +| `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` | Doc cross-link to standing GET Bruno | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-07 plan kicked off (update to **landed** when shipped) | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M3 row note when shipped | + +## Tests + +| File | Coverage | +|------|----------| +| **`FactionStandingApiTests`** | Unknown player **404**; default **200** with 2 factions at **0**; after **`RewardRouterOperations`** operator-chain delivery Grid Operators **15** | +| **CI** | Existing **`dotnet test`** green; Bruno collection optional in local/CI per repo convention | + +Manual QA: **none** (server HTTP read; client verification deferred to NEO-142 / NEO-143). + +## Implementation order + +1. DTOs + **`FactionStandingApi.BuildSnapshot`**. +2. **`Program.cs`** map. +3. **`FactionStandingApiTests`** (AAA layout). +4. Bruno requests. +5. **`server/README.md`** + decomposition alignment on ship. + +## Open questions / risks + +| Item | Agent recommendation | Status | +|------|---------------------|--------| +| Store read deny for catalog faction | **Should not happen** — if it does, treat as **500** or omit row (prefer **500** to surface misconfiguration) | `adopted` — use standing outcome; test happy path only | +| Postgres mode Bruno | **Same assertions** on dev player after operator-chain flow; document DB persistence like skill progression Bruno | `adopted` | +| Extend grid-contract accept Bruno with inline GET | **Separate Bruno file** in `faction-standing/` — keeps quest-progress folder focused | `adopted` | + +## Client counterpart + +[NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — **`faction_standing_client.gd`** parses this route; requires NEO-140 for gate/rep quest row projections.