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

13 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.mdE7M3-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 DoneIFactionDefinitionRegistry, 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.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:

  • InMemoryFactionStandingStoreConcurrentDictionary 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, PostgresReputationDeltaStoreEnsureSchema 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.