From 08ebfcebcf3a8ae515fd981ed5179fa5bca78389 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 20:20:48 -0400 Subject: [PATCH] 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). --- docs/plans/NEO-135-implementation-plan.md | 212 ++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/plans/NEO-135-implementation-plan.md diff --git a/docs/plans/NEO-135-implementation-plan.md b/docs/plans/NEO-135-implementation-plan.md new file mode 100644 index 0000000..5af8525 --- /dev/null +++ b/docs/plans/NEO-135-implementation-plan.md @@ -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 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.