NEO-135: add E7M3-03 faction standing store implementation plan

Kickoff plan for per-player FactionStanding snapshot and append-only
ReputationDelta audit stores (in-memory + NEO-116-style Postgres).
pull/174/head
VinPropane 2026-06-15 20:20:48 -04:00
parent d90d325a3d
commit 08ebfcebcf
1 changed files with 212 additions and 0 deletions

View File

@ -0,0 +1,212 @@
# NEO-135 — E7M3-03: Faction standing store + reputation delta audit store
**Linear:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)
**Branch:** `NEO-135-e7m3-faction-standing-reputation-stores`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-03**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-116-implementation-plan.md](NEO-116-implementation-plan.md) (in-memory + Postgres when configured); [NEO-126](NEO-126-implementation-plan.md) (append-only audit/event store shape)
**Precursor:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `IFactionDefinitionRegistry`, fail-fast faction catalog (**landed on `main`**)
**Blocks:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) (ReputationOperations), [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) (gate eval reads standing), [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) (HTTP read)
## Goal
Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log. Stores validate faction ids fail-closed at the boundary; missing standing reads as **0** (neutral) clamped to faction min/max.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-03 backlog scope](E7M3-pre-production-backlog.md#e7m3-03--faction-standing-store--reputation-delta-audit-store), kickoff decisions table, and NEO-116/NEO-134 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Postgres persistence | **Include when configured** — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116 adopted same |
| Store split | **`IFactionStandingStore`** + **`IReputationDeltaStore`** | Backlog in-scope list; audit append-only separate from snapshot |
| Public apply API | **Out of scope** — store-level mutation primitives only | Backlog defers **`ReputationOperations`** to E7M3-04 / NEO-136 |
| Unknown faction deny | **Structured outcome** with stable reason code at store boundary | Linear AC; kickoff “Fail-closed \| Unknown factionId → deny” |
| Default standing | **0** when row missing; **clamp on read** to registry min/max | Backlog + kickoff standing band **-100…100**, neutral **0** |
| Clamp on write | **Store primitive clamps** resulting standing before persist | Backlog unit tests “clamp at bounds”; NEO-136 AC inherits same bounds |
| Player write gate | **`CanWritePlayer`** on standing store (dev bucket / Postgres `player_position`) | NEO-116 quest store precedent |
| Faction validation | Stores inject **`IFactionDefinitionRegistry`** — not raw string trust | Startup catalog + AC “unknown faction id” at store boundary |
| Audit row shape | player, faction, delta, resulting standing, source kind, source id, timestamp, row id | Backlog E7M3-03 in-scope bullet |
| Source kind (prototype) | **`quest_completion`** string constant only | Backlog example; E7M3-04/06 wire quest id as source id |
| DI registration | **`AddFactionStandingStores`** extension separate from catalog | NEO-116 `AddPlayerQuestStateStore` precedent |
| HTTP / Godot / gate eval | **Out of scope** | Backlog E7M3-03; NEO-137+ |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Snapshot + audit types, id normalization, reason codes.
- **`IFactionStandingStore`** + in-memory + Postgres implementations.
- **`IReputationDeltaStore`** + in-memory + Postgres implementations.
- **`V009__player_faction_standing.sql`**, **`V010__reputation_delta_audit.sql`** when `ConnectionStrings:NeonSprawl` is set.
- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores`** wired from **`Program.cs`**.
- Unit tests (AAA): default read **0**, read clamp, first standing delta apply, clamp at min/max bounds, unknown faction deny, audit append + query, append-only semantics.
- Postgres persistence integration tests (`RequirePostgresFact`) for both stores.
- `server/README.md` faction standing + reputation delta store sections.
**Out of scope:**
- **`ReputationOperations.TryApplyDelta`** public orchestration (NEO-136 / E7M3-04).
- Quest accept gate evaluation (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot (NEO-142+).
- Telemetry hook comments (NEO-141).
**Client counterpart:** none (infrastructure-only).
## Acceptance criteria checklist
- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests.
- [ ] Unknown faction id returns structured deny at store boundary.
- [ ] `dotnet test` covers store gates.
## Technical approach
### 1. Types (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionStandingIds` | Normalize `playerId` / `factionId`; composite key helper (mirror `QuestProgressIds`) |
| `FactionStandingReasonCodes` | `unknown_faction`, `player_not_writable`, `invalid_delta` |
| `FactionStandingReadOutcome` | `Success`, optional `ReasonCode`, `Standing` (clamped) |
| `FactionStandingMutationOutcome` | `Success`, optional `ReasonCode`, `PreviousStanding`, `NewStanding` |
| `ReputationDeltaSourceKinds` | `QuestCompletion = "quest_completion"` (prototype constant) |
| `ReputationDeltaRow` | Immutable audit row: `Id`, `PlayerId`, `FactionId`, `DeltaAmount`, `ResultingStanding`, `SourceKind`, `SourceId`, `RecordedAt` |
Standing snapshot is keyed **`(playerId, factionId)` → int`**; no separate row type beyond normalized ids + value.
### 2. `IFactionStandingStore`
Inject **`IFactionDefinitionRegistry`** in implementations (not on interface methods).
- **`bool CanWritePlayer(string playerId)`** — dev bucket or Postgres player row exists.
- **`FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)`** — missing row ⇒ **0**; unknown faction ⇒ deny with `unknown_faction`; returned value clamped to faction min/max from registry.
- **`FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)`** — validates faction + writable player; reads current (default 0), adds delta, **clamps** to min/max, persists; first write returns success with before/after. Denies unknown faction / non-writable player / empty ids. **Does not append audit** (NEO-136 orchestrates both stores).
XML remarks: consumed by **`ReputationOperations`** (NEO-136) and gate eval (NEO-137); not HTTP directly.
### 3. `IReputationDeltaStore`
- **`bool TryAppend(ReputationDeltaRow row)`** — append-only; returns `false` on duplicate `Id` or invalid row (empty player/faction/id).
- **`IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)`** — test/audit read; ordered by `RecordedAt` then `Id` ascending.
No update/delete APIs in prototype.
### 4. In-memory implementations
Mirror NEO-116 / NEO-38 patterns:
- **`InMemoryFactionStandingStore`** — `ConcurrentDictionary` keyed by composite key; seeds dev player from `GamePositionOptions`; per-key locks.
- **`InMemoryReputationDeltaStore`** — append-only list/dictionary by row id; secondary index by player for queries; thread-safe.
### 5. Postgres implementations
Mirror NEO-116 quest store:
- **`PostgresPlayerFactionStandingBootstrap`** + **`V009__player_faction_standing.sql`**
- Table `player_faction_standing (player_id FK → player_position, faction_id, standing INT, updated_at)`
- PK `(player_id, faction_id)`
- **`PostgresReputationDeltaBootstrap`** + **`V010__reputation_delta_audit.sql`**
- Table `reputation_delta_audit (id UUID/TEXT PK, player_id FK, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)`
- Index on `(player_id, recorded_at)` for audit queries
- **`PostgresFactionStandingStore`**, **`PostgresReputationDeltaStore`** — `EnsureSchema` on first use; standing store checks `player_position` exists before write.
### 6. DI and `Program.cs`
- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores(configuration)`** — Postgres pair when connection string set; else in-memory pair.
- Register **after** `AddFactionDefinitionCatalog` (stores depend on registry at runtime, not catalog singleton).
- Place near other player persistence (`AddPlayerQuestStateStore`, `AddRewardDeliveryStore`).
- **`InMemoryWebApplicationFactory`**: ensure stores resolve (override only if host tests need isolation — follow existing factory pattern).
### 7. Tests
**In-memory (`InMemoryFactionStandingStoreTests`):**
- Missing row reads **0** for known faction.
- Read clamp when stored value exceeds max (direct internal seed or apply overflow scenario).
- First `TryApplyStandingDelta` +15 from 0 → 15.
- Apply delta that would exceed max clamps to **100**; below min clamps to **-100**.
- Unknown faction id → `unknown_faction` deny, standing unchanged.
- Non-writable player → deny.
**In-memory (`InMemoryReputationDeltaStoreTests`):**
- `TryAppend` success; `GetDeltasForPlayer` returns row.
- Second append with new id succeeds; duplicate id returns `false`.
- Empty player/faction/id → append deny.
**Postgres (`FactionStandingPersistenceIntegrationTests`, `ReputationDeltaPersistenceIntegrationTests`):**
- Write on factory A, read on factory B (`RequirePostgresFact` + harness).
**Host DI smoke:** resolve both stores from `WebApplicationFactory` (optional one-liner in existing host test file).
Manual QA: **none** (server infrastructure).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs` | Player/faction id normalization |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs` | Stable deny codes |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs` | Read result record |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs` | Mutation result record |
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | Source kind constants |
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs` | Audit row type |
| `server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs` | Standing store contract |
| `server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs` | Audit store contract |
| `server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs` | Dev in-memory standing |
| `server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs` | Dev in-memory audit |
| `server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs` | Migration bootstrap |
| `server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs` | Postgres standing |
| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs` | Audit migration bootstrap |
| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs` | Postgres audit |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs` | DI registration |
| `server/db/migrations/V009__player_faction_standing.sql` | Standing table |
| `server/db/migrations/V010__reputation_delta_audit.sql` | Audit table |
| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs` | Standing AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs` | Audit AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs` | Postgres standing |
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs` | Postgres audit |
## Files to modify
| Path | Change |
|------|--------|
| `server/NeonSprawl.Server/Program.cs` | Register `AddFactionStandingStores` after faction catalog |
| `server/README.md` | Faction standing + reputation delta store sections |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Register/override faction stores if host tests require it |
## Tests
| Layer | What |
|-------|------|
| `InMemoryFactionStandingStoreTests` | Default 0, read clamp, apply delta, clamp min/max, unknown faction, player gate |
| `InMemoryReputationDeltaStoreTests` | Append, query, duplicate-id deny, validation |
| `FactionStandingPersistenceIntegrationTests` | Postgres write/read round-trip (`RequirePostgresFact`) |
| `ReputationDeltaPersistenceIntegrationTests` | Postgres append/query round-trip (`RequirePostgresFact`) |
| CI | Existing `dotnet test` + no content pipeline changes |
## Implementation order
1. Types, reason codes, ids, store interfaces.
2. In-memory standing + audit stores.
3. Postgres migrations + bootstrap + Postgres stores.
4. DI extension + `Program.cs`.
5. Unit tests → Postgres integration tests → `dotnet test`.
6. `server/README.md`.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|---------------------|--------|
| Single vs dual migration files | **Two files (V009 standing, V010 audit)** — matches one-table-per-migration precedent (V007 gig, V008 quest) | `adopted` |
| Audit row id format | **UUID string** generated by caller (NEO-136); store rejects empty/duplicate | `adopted` |
| Standing store exposes `TryApplyStandingDelta` vs absolute set | **Delta primitive only** — matches backlog test wording and NEO-136 apply flow | `adopted` |
| Registry on read for unknown faction | **Deny unknown on read and write** — AC “store boundary”; gate eval (NEO-137) always uses catalog factions | `adopted` |
## Client counterpart
None for E7M3-03. Client faction HUD: **NEO-142** / capstone **NEO-143**.
## Blocks
**NEO-136** (`ReputationOperations`) and **NEO-137** (gate eval) depend on these stores landing first.