14 KiB
NEO-135 — E7M3-03: Faction standing store + reputation delta audit store
Linear: NEO-135
Branch: NEO-135-e7m3-faction-standing-reputation-stores
Backlog: E7M3-pre-production-backlog.md — E7M3-03
Module: E7_M3_FactionReputationLedger.md
Pattern: NEO-116-implementation-plan.md (in-memory + Postgres when configured); NEO-126 (append-only audit/event store shape)
Precursor: NEO-134 Done — IFactionDefinitionRegistry, fail-fast faction catalog (landed on main)
Blocks: NEO-136 (ReputationOperations), NEO-137 (gate eval reads standing), 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, 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.sqlwhenConnectionStrings:NeonSprawlis set.FactionStandingServiceCollectionExtensions.AddFactionStandingStoreswired fromProgram.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.mdfaction standing + reputation delta store sections.
Out of scope:
ReputationOperations.TryApplyDeltapublic 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 testcovers 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:
AddFactionStandingStoresinProgram.cs; in-memory override inInMemoryWebApplicationFactory. - Tests:
InMemoryFactionStandingStoreTests,InMemoryReputationDeltaStoreTests, Postgres persistence integration tests; host DI smoke (766tests green). - Docs:
server/README.mdfaction 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 |
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 withunknown_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; returnsfalseon duplicateIdor invalid row (empty player/faction/id).IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)— test/audit read; ordered byRecordedAtthenIdascending.
No update/delete APIs in prototype.
4. In-memory implementations
Mirror NEO-116 / NEO-38 patterns:
InMemoryFactionStandingStore—ConcurrentDictionarykeyed by composite key; seeds dev player fromGamePositionOptions; 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)
- Table
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
- Table
PostgresFactionStandingStore,PostgresReputationDeltaStore—EnsureSchemaon first use; standing store checksplayer_positionexists 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_factiondeny, standing unchanged. - Non-writable player → deny.
In-memory (InMemoryReputationDeltaStoreTests):
TryAppendsuccess;GetDeltasForPlayerreturns 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
- Types, reason codes, ids, store interfaces.
- In-memory standing + audit stores.
- Postgres migrations + bootstrap + Postgres stores.
- DI extension +
Program.cs. - Unit tests → Postgres integration tests →
dotnet test. 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.