221 lines
14 KiB
Markdown
221 lines
14 KiB
Markdown
# 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
|
|
|
|
- [x] Standing readable per player+faction; deltas append-only and queryable for audit tests.
|
|
- [x] Unknown faction id returns structured deny at store boundary.
|
|
- [x] `dotnet test` covers store gates.
|
|
|
|
## Implementation reconciliation (shipped)
|
|
|
|
- **Types:** `FactionStandingIds`, reason codes, read/mutation outcomes, `ReputationDeltaRow`, `ReputationDeltaSourceKinds`.
|
|
- **Stores:** `IFactionStandingStore` / `IReputationDeltaStore`; in-memory + Postgres (`V009`, `V010`) when connection string set.
|
|
- **DI:** `AddFactionStandingStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
|
|
- **Tests:** `InMemoryFactionStandingStoreTests`, `InMemoryReputationDeltaStoreTests`, Postgres persistence integration tests; host DI smoke (`768` tests green).
|
|
- **Docs:** `server/README.md` faction standing + reputation delta store sections.
|
|
|
|
## 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` deferred to NEO-136 `ReputationOperations`) |
|
|
| `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.
|