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

181 lines
11 KiB
Markdown

# 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
- [x] GET returns stable JSON for prototype player after quest rep grants.
- [x] Unknown player returns **404** (position gate precedent).
- [x] `dotnet test` covers default snapshot, post-rep standing, and unknown-player 404.
## Implementation reconciliation (shipped)
- **API:** `FactionStandingApi.MapFactionStandingApi``GET /game/players/{id}/faction-standing`.
- **DTOs:** `FactionStandingSnapshotResponse`, `FactionStandingRowJson` (`schemaVersion` 1).
- **Gate:** `IPositionStateStore.TryGetPosition`**404** for unknown player.
- **Tests:** `FactionStandingApiTests` (5 cases: 404 unknown/whitespace-only, trim-path echo, defaults, post-rep); **821** tests green.
- **Bruno:** `Get faction standing default.bru`, `unknown player.bru`, `after operator chain.bru`.
- **Docs:** `server/README.md` Faction standing read section; E7.M3 module + alignment register updated.
## 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.