Merge pull request #174 from ViPro-Technologies/NEO-135-e7m3-faction-standing-reputation-stores

NEO-135: E7M3-03 faction standing and reputation delta audit stores
pull/177/head
VinPropane 2026-06-15 20:39:15 -04:00 committed by GitHub
commit 786a5b6d68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1587 additions and 2 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET health (faction standing store NEO-135)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-135 registers IFactionStandingStore and IReputationDeltaStore (in-memory or Postgres when configured). No faction standing HTTP API in this story — use this request to confirm the host started after store DI wiring.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: faction-standing
}

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 | | **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production | | **Stage target** | Pre-production |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-03** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-04** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose ## Purpose

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,220 @@
# 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.

View File

@ -0,0 +1,56 @@
# Code review — NEO-135 (E7M3-03)
**Date:** 2026-06-15
**Scope:** Branch `NEO-135-e7m3-faction-standing-reputation-stores` — commits `08ebfce``4fca45b` vs `main`
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-03**: durable per-player **`IFactionStandingStore`** snapshots and append-only **`IReputationDeltaStore`** audit rows, with in-memory fallbacks and Postgres implementations (`V009`, `V010`) when `ConnectionStrings:NeonSprawl` is set. Stores inject **`IFactionDefinitionRegistry`** for fail-closed faction validation, default missing standing to **0** with read/write clamping, and gate mutations (and reads) on the dev player bucket / `player_position` row — mirroring NEO-116 quest-store patterns. Test coverage is solid: in-memory AAA for apply/clamp/deny paths, audit ordering/limit/duplicate-id semantics, Postgres persistence round-trips, and host DI smoke. **`766`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or player-facing behavior. Minor gaps: decomposition tracking docs not updated for E7M3-03, and one plan-listed in-memory read-clamp case is only covered via Postgres integration.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-135-implementation-plan.md` | **Matches** — shipped types, stores, migrations, DI, tests, and README reconcile with plan; AC checklist marked complete. Plan types table lists `invalid_delta` reason code not present in code (reserved for NEO-136 — see nits). |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-03 scope (dual stores, clamp, audit append, no public apply API, no client counterpart) aligns with implementation. Backlog AC checkboxes still unchecked (plan doc is authoritative for this branch). |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-03 stores landed (NEO-135). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-03 / NEO-135. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. |
| `server/README.md` | **Matches** — new faction standing + reputation delta store sections; correctly defers auditable apply to NEO-136. |
| `bruno/neon-sprawl-server/faction-standing/` | **N/A plan item** — health smoke folder added (reasonable host-start verification; not listed in plan). |
## Blocking issues
None.
## Suggestions
1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-03 faction standing + reputation delta stores** landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)). Same pattern as NEO-134 review follow-up.~~ **Done.** Module status + alignment table updated for E7M3-03 / NEO-135.
2. ~~**In-memory read clamp test** — Plan §7 lists “Read clamp when stored value exceeds max” under `InMemoryFactionStandingStoreTests`. `TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax` exists only in Postgres integration tests. Add an in-memory case (e.g. apply to max then manually seed out-of-band via a second apply + direct dictionary seed is not possible — use apply overflow then verify read clamp, or test via Postgres-only path is insufficient for unit layer). Simplest: after clamp-to-max apply, verify read returns **100**; for true out-of-band raw, mirror Postgres test with reflection/internal test hook **or** document that read-clamp defense is integration-tested only. Prefer a dedicated in-memory test that applies deltas to max then asserts read clamp on subsequent reads at boundary (partial coverage) plus keeping Postgres test for stale DB values.~~ **Done.** Added `TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax` using internal `SeedRawStandingForTests`; Postgres integration test retained for stale DB values.
3. ~~**Plan types table drift** — `NEO-135-implementation-plan.md` §1 lists `FactionStandingReasonCodes.invalid_delta`; shipped code only has `unknown_faction` and `player_not_writable`. Add a one-line note in the plan that `invalid_delta` is deferred to NEO-136 `ReputationOperations`, or add the constant now without wiring (avoid dead code — note-only is fine).~~ **Done.** Plan types table notes `invalid_delta` deferred to NEO-136.
## Nits
- ~~Nit: `PostgresReputationDeltaStore.TryAppend` relies on FK to `player_position`; a row for a non-existent player throws rather than returning `false`. Acceptable if NEO-136 always applies standing first; worth a one-line remark in README or interface XML for orchestrators.~~ **Done.** Remark added to `IReputationDeltaStore.TryAppend` XML and `server/README.md`.
- Nit: Host DI smoke (`Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds`) lives in `InMemoryFactionStandingStoreTests.cs` rather than a host-focused test file — fine per plan “optional one-liner.”
- ~~Nit: `FactionStandingPersistenceIntegrationTests` places `Assert.True(outcome.Success)` inside the Act block for the first factory write — minor AAA deviation; assertions could move entirely to Assert phase.~~ **Done.**
- ~~Nit: Empty `factionId` on read/write returns `player_not_writable` rather than `unknown_faction` — consistent with empty-id handling elsewhere, but slightly imprecise reason code.~~ **Done.** Empty faction id now returns `unknown_faction`.
## Verification
Commands run during review (passed):
```bash
dotnet test server/NeonSprawl.Server.Tests
```
Author should confirm PR CI green before merge. With Postgres configured locally, `RequirePostgresFact` integration tests should also pass (`ConnectionStrings__NeonSprawl`). Bruno smoke (`bruno/neon-sprawl-server/faction-standing/`) is appropriate — no manual QA doc required per plan (infrastructure-only).
**Intentional follow-on (not blockers):** `ReputationOperations` orchestration (NEO-136), gate eval reads (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot HUD (NEO-142+).

View File

@ -0,0 +1,122 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
[Collection("Postgres integration")]
public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string GridFactionId = "prototype_faction_grid_operators";
[RequirePostgresFact]
public async Task ApplyDeltaThenGetAcrossNewFactory_ShouldPersistStanding()
{
// Arrange
await ResetFactionStandingTableAsync();
// Act — write through first host
FactionStandingMutationOutcome writeOutcome;
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
writeOutcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15);
}
FactionStandingReadOutcome readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var store = secondScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
readBack = store.TryGetStanding(PlayerId, GridFactionId);
}
// Assert
Assert.True(writeOutcome.Success);
Assert.Equal(15, writeOutcome.NewStanding);
Assert.True(readBack.Success);
Assert.Equal(15, readBack.Standing);
}
[RequirePostgresFact]
public async Task TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax()
{
// Arrange
await ResetFactionStandingTableAsync();
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl")
?? throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
await using (var conn = new NpgsqlConnection(cs))
{
await conn.OpenAsync();
await using var insert = new NpgsqlCommand(
"""
INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at)
VALUES (@pid, @fid, @standing, now());
""",
conn);
insert.Parameters.AddWithValue("pid", PlayerId);
insert.Parameters.AddWithValue("fid", GridFactionId);
insert.Parameters.AddWithValue("standing", 999);
await insert.ExecuteNonQueryAsync();
}
// Act
FactionStandingReadOutcome readBack;
using (var scope = Factory.Services.CreateScope())
{
var store = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
readBack = store.TryGetStanding(PlayerId, GridFactionId);
}
// Assert
Assert.True(readBack.Success);
Assert.Equal(100, readBack.Standing);
}
private async Task ResetFactionStandingTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var standingDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V009__player_faction_standing.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(standingDdlPath))
{
throw new FileNotFoundException("Test DDL for faction standing persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var standingDdl = await File.ReadAllTextAsync(standingDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyStanding = new NpgsqlCommand(standingDdl, conn))
{
await applyStanding.ExecuteNonQueryAsync();
}
}
}

View File

@ -0,0 +1,223 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public sealed class InMemoryFactionStandingStoreTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string GridFactionId = "prototype_faction_grid_operators";
private const string RustFactionId = "prototype_faction_rust_collective";
[Fact]
public void TryGetStanding_ShouldReturnZero_WhenRowMissing()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryGetStanding(PlayerId, GridFactionId);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Equal(0, outcome.Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldReturnFifteen_WhenFirstApplyFromZero()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15);
// Assert
Assert.True(outcome.Success);
Assert.Equal(0, outcome.PreviousStanding);
Assert.Equal(15, outcome.NewStanding);
var readBack = store.TryGetStanding(PlayerId, GridFactionId);
Assert.True(readBack.Success);
Assert.Equal(15, readBack.Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldClampToMax_WhenDeltaExceedsBand()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 50);
// Assert
Assert.True(outcome.Success);
Assert.Equal(95, outcome.PreviousStanding);
Assert.Equal(100, outcome.NewStanding);
Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldClampToMin_WhenDeltaBelowBand()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success);
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -50);
// Assert
Assert.True(outcome.Success);
Assert.Equal(-95, outcome.PreviousStanding);
Assert.Equal(-100, outcome.NewStanding);
Assert.Equal(-100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax()
{
// Arrange
var store = CreateStore();
store.SeedRawStandingForTests(PlayerId, GridFactionId, 999);
// Act
var outcome = store.TryGetStanding(PlayerId, GridFactionId);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Equal(100, outcome.Standing);
}
[Fact]
public void TryGetStanding_ShouldReturnUnknownFaction_WhenFactionIdEmpty()
{
// Arrange
var store = CreateStore();
// Act
var read = store.TryGetStanding(PlayerId, " ");
var write = store.TryApplyStandingDelta(PlayerId, "", 5);
// Assert
Assert.False(read.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
Assert.False(write.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
}
[Fact]
public void TryGetStanding_ShouldDenyUnknownFaction_WithStructuredReason()
{
// Arrange
var store = CreateStore();
// Act
var read = store.TryGetStanding(PlayerId, "not_a_real_faction");
var write = store.TryApplyStandingDelta(PlayerId, "not_a_real_faction", 5);
// Assert
Assert.False(read.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
Assert.Equal(0, read.Standing);
Assert.False(write.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldApplyToClampedEffectiveStanding_WhenRawStoredValueExceedsMax()
{
// Arrange
var store = CreateStore();
store.SeedRawStandingForTests(PlayerId, GridFactionId, 999);
Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -15);
// Assert
Assert.True(outcome.Success);
Assert.Equal(100, outcome.PreviousStanding);
Assert.Equal(85, outcome.NewStanding);
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
{
// Arrange
var store = CreateStore();
// Act
var read = store.TryGetStanding(UnknownPlayerId, "not_a_real_faction");
var write = store.TryApplyStandingDelta(UnknownPlayerId, "not_a_real_faction", 5);
// Assert
Assert.False(read.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
Assert.False(write.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
}
[Fact]
public void TryApplyStandingDelta_ShouldDenyNonWritablePlayer()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryApplyStandingDelta(UnknownPlayerId, GridFactionId, 10);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
}
[Fact]
public void TryGetStanding_ShouldDenyNonWritablePlayer()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryGetStanding(UnknownPlayerId, GridFactionId);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
}
[Fact]
public void TryApplyStandingDelta_ShouldTrackSeparateFactionsPerPlayer()
{
// Arrange
var store = CreateStore();
// Act
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 15).Success);
Assert.True(store.TryApplyStandingDelta(PlayerId, RustFactionId, 10).Success);
// Assert
Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Equal(10, store.TryGetStanding(PlayerId, RustFactionId).Standing);
}
[Fact]
public async Task Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
var read = standingStore.TryGetStanding(PlayerId, GridFactionId);
// Assert
Assert.NotNull(auditStore);
Assert.True(read.Success);
Assert.Equal(0, read.Standing);
}
private static InMemoryFactionStandingStore CreateStore()
{
var registry = CreatePrototypeRegistry();
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private static FactionDefinitionRegistry CreatePrototypeRegistry()
{
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
[RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);
}
}

View File

@ -0,0 +1,117 @@
using NeonSprawl.Server.Game.Factions;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public sealed class InMemoryReputationDeltaStoreTests
{
private const string PlayerId = "dev-local-1";
private const string GridFactionId = "prototype_faction_grid_operators";
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TryAppend_ShouldReturnTrueAndQueryRow_WhenFirstAppend()
{
// Arrange
var store = CreateStore();
var row = CreateRow("delta-001", 15, 15);
// Act
var appended = store.TryAppend(row);
var rows = store.GetDeltasForPlayer(PlayerId);
// Assert
Assert.True(appended);
var read = Assert.Single(rows);
Assert.Equal(row.Id, read.Id);
Assert.Equal(15, read.DeltaAmount);
Assert.Equal(15, read.ResultingStanding);
}
[Fact]
public void TryAppend_ShouldReturnFalse_WhenDuplicateId()
{
// Arrange
var store = CreateStore();
var row = CreateRow("delta-dup", 10, 10);
Assert.True(store.TryAppend(row));
// Act
var duplicate = store.TryAppend(row with { DeltaAmount = 99, ResultingStanding = 99 });
// Assert
Assert.False(duplicate);
var read = Assert.Single(store.GetDeltasForPlayer(PlayerId));
Assert.Equal(10, read.DeltaAmount);
}
[Fact]
public void TryAppend_ShouldReturnTrueTwice_WhenDistinctIds()
{
// Arrange
var store = CreateStore();
// Act
var first = store.TryAppend(CreateRow("delta-a", 15, 15));
var second = store.TryAppend(CreateRow("delta-b", 10, 25));
// Assert
Assert.True(first);
Assert.True(second);
Assert.Equal(2, store.GetDeltasForPlayer(PlayerId).Count);
}
[Fact]
public void TryAppend_ShouldReturnFalse_WhenRowInvalid()
{
// Arrange
var store = CreateStore();
var emptyId = CreateRow("", 5, 5);
var emptyPlayer = CreateRow("delta-x", 5, 5) with { PlayerId = " " };
var emptyFaction = CreateRow("delta-y", 5, 5) with { FactionId = "" };
// Act
var noId = store.TryAppend(emptyId);
var noPlayer = store.TryAppend(emptyPlayer);
var noFaction = store.TryAppend(emptyFaction);
// Assert
Assert.False(noId);
Assert.False(noPlayer);
Assert.False(noFaction);
Assert.Empty(store.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void GetDeltasForPlayer_ShouldOrderByRecordedAtThenId()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryAppend(CreateRow("delta-z", 1, 1) with { RecordedAt = RecordedAt.AddMinutes(2) }));
Assert.True(store.TryAppend(CreateRow("delta-a", 2, 3) with { RecordedAt = RecordedAt }));
Assert.True(store.TryAppend(CreateRow("delta-m", 3, 6) with { RecordedAt = RecordedAt }));
// Act
var rows = store.GetDeltasForPlayer(PlayerId);
// Assert
Assert.Equal(["delta-a", "delta-m", "delta-z"], rows.Select(static r => r.Id).ToArray());
}
[Fact]
public void GetDeltasForPlayer_ShouldRespectLimit_WhenProvided()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryAppend(CreateRow("delta-1", 1, 1)));
Assert.True(store.TryAppend(CreateRow("delta-2", 1, 2)));
// Act
var rows = store.GetDeltasForPlayer(PlayerId, limit: 1);
// Assert
Assert.Single(rows);
Assert.Equal("delta-1", rows[0].Id);
}
private static InMemoryReputationDeltaStore CreateStore() => new();
private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) =>
new(
id,
PlayerId,
GridFactionId,
delta,
resulting,
ReputationDeltaSourceKinds.QuestCompletion,
"prototype_quest_operator_chain",
RecordedAt);
}

View File

@ -0,0 +1,97 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
[Collection("Postgres integration")]
public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string GridFactionId = "prototype_faction_grid_operators";
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task AppendThenGetAcrossNewFactory_ShouldPersistAuditRow()
{
// Arrange
await ResetReputationDeltaTableAsync();
var row = new ReputationDeltaRow(
"delta-persist-001",
PlayerId,
GridFactionId,
15,
15,
ReputationDeltaSourceKinds.QuestCompletion,
"prototype_quest_operator_chain",
RecordedAt);
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
Assert.True(store.TryAppend(row));
}
IReadOnlyList<ReputationDeltaRow> readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var store = secondScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
readBack = store.GetDeltasForPlayer(PlayerId);
}
// Assert
var persisted = Assert.Single(readBack);
Assert.Equal(row.Id, persisted.Id);
Assert.Equal(15, persisted.DeltaAmount);
Assert.Equal(15, persisted.ResultingStanding);
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, persisted.SourceKind);
}
private async Task ResetReputationDeltaTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var auditDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V010__reputation_delta_audit.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(auditDdlPath))
{
throw new FileNotFoundException("Test DDL for reputation delta persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var auditDdl = await File.ReadAllTextAsync(auditDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyAudit = new NpgsqlCommand(auditDdl, conn))
{
await applyAudit.ExecuteNonQueryAsync();
}
}
}

View File

@ -89,6 +89,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerGigProgressionStore) || d.ServiceType == typeof(IPlayerGigProgressionStore) ||
d.ServiceType == typeof(IPlayerQuestStateStore) || d.ServiceType == typeof(IPlayerQuestStateStore) ||
d.ServiceType == typeof(IFactionStandingStore) ||
d.ServiceType == typeof(IReputationDeltaStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) || d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IResourceNodeInstanceStore) || d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerPerkStateStore) ||
@ -111,6 +113,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>(); services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>(); services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>(); services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>(); services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>(); services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();

View File

@ -0,0 +1,41 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Id normalization for faction standing stores (NEO-135).</summary>
public static class FactionStandingIds
{
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizePlayerId(string? playerId)
{
var trimmed = playerId?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
return trimmed.ToLowerInvariant();
}
/// <summary>Trim; empty when input is null/whitespace. Faction ids stay case-sensitive (catalog ids are lowercase snake).</summary>
public static string NormalizeFactionId(string? factionId)
{
var trimmed = factionId?.Trim();
return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed;
}
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="factionId"/>.</summary>
public static string MakeStandingKey(string? playerId, string? factionId)
{
var player = NormalizePlayerId(playerId);
var faction = NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return string.Empty;
}
return $"{player}\0{faction}";
}
/// <summary>Clamps <paramref name="standing"/> to the faction definition band.</summary>
public static int ClampStanding(int standing, FactionDefRow definition) =>
Math.Clamp(standing, definition.MinStanding, definition.MaxStanding);
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Result of <see cref="IFactionStandingStore.TryApplyStandingDelta"/> (NEO-135).</summary>
public readonly record struct FactionStandingMutationOutcome(
bool Success,
string? ReasonCode,
int PreviousStanding,
int NewStanding);

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Result of <see cref="IFactionStandingStore.TryGetStanding"/> (NEO-135).</summary>
public readonly record struct FactionStandingReadOutcome(
bool Success,
string? ReasonCode,
int Standing);

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Stable deny reason codes for faction standing store operations (NEO-135).</summary>
public static class FactionStandingReasonCodes
{
public const string UnknownFaction = "unknown_faction";
public const string PlayerNotWritable = "player_not_writable";
}

View File

@ -0,0 +1,25 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Registers faction standing + reputation delta audit persistence (NEO-135).</summary>
public static class FactionStandingServiceCollectionExtensions
{
/// <summary>PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set; otherwise in-memory fallback.</summary>
public static IServiceCollection AddFactionStandingStores(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IFactionStandingStore, PostgresFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, PostgresReputationDeltaStore>();
}
else
{
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
}
return services;
}
}

View File

@ -0,0 +1,23 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Persisted per-player faction standing keyed by <c>(playerId, factionId)</c> (NEO-135).
/// Missing row ⇒ neutral <c>0</c> at read. Consumed by <c>ReputationOperations</c> (NEO-136) and gate eval (NEO-137).
/// </summary>
public interface IFactionStandingStore
{
/// <summary>Whether mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
bool CanWritePlayer(string playerId);
/// <summary>
/// Reads standing for one faction. Missing row ⇒ <c>0</c> clamped to faction min/max.
/// Unknown faction id ⇒ structured deny with <see cref="FactionStandingReasonCodes.UnknownFaction"/>.
/// </summary>
FactionStandingReadOutcome TryGetStanding(string playerId, string factionId);
/// <summary>
/// Applies a signed delta, clamps resulting standing to faction min/max, and persists.
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
/// </summary>
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Append-only reputation delta audit log (NEO-135).</summary>
public interface IReputationDeltaStore
{
/// <summary>
/// First append with a given row <c>Id</c> returns <c>true</c>; duplicate ids return <c>false</c>.
/// Postgres: row references <c>player_position</c> — orchestrators (NEO-136) should apply standing before audit append.
/// </summary>
bool TryAppend(ReputationDeltaRow row);
/// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary>
IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null);
}

View File

@ -0,0 +1,124 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135).</summary>
public sealed class InMemoryFactionStandingStore(
IOptions<GamePositionOptions> options,
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
{
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
private readonly ConcurrentDictionary<string, int> standingByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
{
var id = FactionStandingIds.NormalizePlayerId(o.DevPlayerId);
if (id.Length == 0)
{
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
}
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
}
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
return player.Length > 0 && knownPlayers.Contains(player);
}
/// <inheritdoc />
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
if (!CanWritePlayer(player))
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
var raw = standingByKey.GetValueOrDefault(key, 0);
return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition));
}
/// <inheritdoc />
public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
if (!CanWritePlayer(player))
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var raw = standingByKey.GetValueOrDefault(key, 0);
var previousStanding = FactionStandingIds.ClampStanding(raw, definition);
var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition);
standingByKey[key] = newStanding;
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
}
}
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
new(false, reasonCode, 0);
private static FactionStandingMutationOutcome DenyMutation(string reasonCode) =>
new(false, reasonCode, 0, 0);
/// <summary>Test-only seed of raw standing before read-clamp (NEO-135 unit tests).</summary>
internal void SeedRawStandingForTests(string playerId, string factionId, int rawStanding)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0 || !CanWritePlayer(player))
{
return;
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
standingByKey[key] = rawStanding;
}
}
}

View File

@ -0,0 +1,69 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Thread-safe in-memory append-only reputation delta audit log (NEO-135).</summary>
public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
{
private readonly ConcurrentDictionary<string, ReputationDeltaRow> rowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryAppend(ReputationDeltaRow row)
{
if (!IsValidRow(row))
{
return false;
}
var normalized = row with
{
Id = row.Id.Trim(),
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
SourceKind = row.SourceKind.Trim(),
SourceId = row.SourceId.Trim(),
};
lock (idLocks.GetOrAdd(normalized.Id, _ => new object()))
{
if (rowsById.ContainsKey(normalized.Id))
{
return false;
}
rowsById[normalized.Id] = normalized;
return true;
}
}
/// <inheritdoc />
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return [];
}
IEnumerable<ReputationDeltaRow> query = rowsById.Values
.Where(r => string.Equals(r.PlayerId, player, StringComparison.Ordinal))
.OrderBy(static r => r.RecordedAt)
.ThenBy(static r => r.Id, StringComparer.Ordinal);
if (limit is > 0)
{
query = query.Take(limit.Value);
}
return query.ToArray();
}
private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
row.SourceKind.Trim().Length > 0 &&
row.SourceId.Trim().Length > 0;
}

View File

@ -0,0 +1,154 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135).</summary>
public sealed class PostgresFactionStandingStore(
Npgsql.NpgsqlDataSource dataSource,
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
{
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return false;
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
return PlayerExists(conn, player);
}
/// <inheritdoc />
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT standing
FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid
LIMIT 1;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("fid", faction);
var scalar = cmd.ExecuteScalar();
var raw = scalar is null or DBNull
? 0
: scalar is int xi
? xi
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition));
}
/// <inheritdoc />
public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, player, tx))
{
tx.Rollback();
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
var raw = 0;
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT standing
FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("fid", faction);
var scalar = sel.ExecuteScalar();
raw = scalar is null or DBNull
? 0
: scalar is int xi
? xi
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
}
var previousStanding = FactionStandingIds.ClampStanding(raw, definition);
var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition);
using var upsert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at)
VALUES (@pid, @fid, @standing, now())
ON CONFLICT (player_id, faction_id)
DO UPDATE SET standing = EXCLUDED.standing, updated_at = now();
""",
conn,
tx);
upsert.Parameters.AddWithValue("pid", player);
upsert.Parameters.AddWithValue("fid", faction);
upsert.Parameters.AddWithValue("standing", newStanding);
upsert.ExecuteNonQuery();
tx.Commit();
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
new(false, reasonCode, 0);
private static FactionStandingMutationOutcome DenyMutation(string reasonCode) =>
new(false, reasonCode, 0, 0);
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Applies NEO-135 faction standing table DDL once per process.</summary>
public static class PostgresPlayerFactionStandingBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V009__player_faction_standing.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-135 faction standing DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Applies NEO-135 reputation delta audit table DDL once per process.</summary>
public static class PostgresReputationDeltaBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V010__reputation_delta_audit.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-135 reputation delta DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,100 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
{
/// <inheritdoc />
public bool TryAppend(ReputationDeltaRow row)
{
if (!IsValidRow(row))
{
return false;
}
var normalized = row with
{
Id = row.Id.Trim(),
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
SourceKind = row.SourceKind.Trim(),
SourceId = row.SourceId.Trim(),
};
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO reputation_delta_audit (
id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)
VALUES (@id, @pid, @fid, @delta, @result, @kind, @source_id, @recorded)
ON CONFLICT (id) DO NOTHING;
""",
conn);
cmd.Parameters.AddWithValue("id", normalized.Id);
cmd.Parameters.AddWithValue("pid", normalized.PlayerId);
cmd.Parameters.AddWithValue("fid", normalized.FactionId);
cmd.Parameters.AddWithValue("delta", normalized.DeltaAmount);
cmd.Parameters.AddWithValue("result", normalized.ResultingStanding);
cmd.Parameters.AddWithValue("kind", normalized.SourceKind);
cmd.Parameters.AddWithValue("source_id", normalized.SourceId);
cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt);
return cmd.ExecuteNonQuery() == 1;
}
/// <inheritdoc />
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return [];
}
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
var sql = limit is > 0
? """
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
FROM reputation_delta_audit
WHERE player_id = @pid
ORDER BY recorded_at ASC, id ASC
LIMIT @lim;
"""
: """
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
FROM reputation_delta_audit
WHERE player_id = @pid
ORDER BY recorded_at ASC, id ASC;
""";
using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("pid", player);
if (limit is > 0)
{
cmd.Parameters.AddWithValue("lim", limit.Value);
}
var rows = new List<ReputationDeltaRow>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(new ReputationDeltaRow(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetInt32(3),
reader.GetInt32(4),
reader.GetString(5),
reader.GetString(6),
reader.GetFieldValue<DateTimeOffset>(7)));
}
return rows;
}
private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
row.SourceKind.Trim().Length > 0 &&
row.SourceId.Trim().Length > 0;
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Append-only reputation delta audit row (NEO-135).</summary>
public sealed record ReputationDeltaRow(
string Id,
string PlayerId,
string FactionId,
int DeltaAmount,
int ResultingStanding,
string SourceKind,
string SourceId,
DateTimeOffset RecordedAt);

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Stable source-kind values for <see cref="ReputationDeltaRow"/> (NEO-135).</summary>
public static class ReputationDeltaSourceKinds
{
public const string QuestCompletion = "quest_completion";
}

View File

@ -34,6 +34,7 @@ builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration);
builder.Services.AddRewardDeliveryStore(); builder.Services.AddRewardDeliveryStore();
builder.Services.AddFactionStandingStores(builder.Configuration);
builder.Services.AddThreatStateStore(); builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore(); builder.Services.AddPlayerCombatHealthStore();

View File

@ -63,6 +63,25 @@ On startup the host loads every **`*_factions.json`** under the factions directo
On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code. On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code.
## Faction standing store (NEO-135)
Per-player faction reputation snapshots live in **`IFactionStandingStore`**, keyed by **`(playerId, factionId)`**. A missing row reads as neutral **`0`**, clamped to the faction definition **`minStanding`…`maxStanding`**. Unknown faction ids return structured deny with reason code **`unknown_faction`** at the store boundary. Game code should call **`ReputationOperations`** (NEO-136) for auditable apply — not the standing store alone — so source attribution and audit rows stay consistent.
**Standing store methods:**
- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`).
- **`TryGetStanding`** — read one faction; missing row ⇒ **`0`** (clamped).
- **`TryApplyStandingDelta`** — internal primitive: add signed delta, clamp result, persist. Does **not** append audit rows.
Append-only **`ReputationDelta`** audit rows live in **`IReputationDeltaStore`**:
- **`TryAppend`** — first row id returns `true`; duplicate ids return `false`.
- **`GetDeltasForPlayer`** — ordered by **`recordedAt`** then **`id`** (prototype test/audit read).
**Postgres orchestration:** audit rows FK to **`player_position`** — append after standing apply succeeds (NEO-136 **`ReputationOperations`**); a missing player row causes insert failure rather than a structured `false` from **`TryAppend`**.
**Storage:** in-memory singletons when **`ConnectionStrings:NeonSprawl`** is unset (standing store seeds configured dev player only). When Postgres is configured, **`PostgresFactionStandingStore`** persists to **`player_faction_standing`** ([`V009__player_faction_standing.sql`](../db/migrations/V009__player_faction_standing.sql)) and **`PostgresReputationDeltaStore`** appends to **`reputation_delta_audit`** ([`V010__reputation_delta_audit.sql`](../db/migrations/V010__reputation_delta_audit.sql)). Plan: [NEO-135 implementation plan](../../docs/plans/NEO-135-implementation-plan.md).
## Resource-node catalog (`content/resource-nodes`, NEO-58) ## Resource-node catalog (`content/resource-nodes`, NEO-58)
On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.

View File

@ -0,0 +1,10 @@
-- NEO-135: per-player faction standing snapshots.
CREATE TABLE IF NOT EXISTS player_faction_standing (
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
faction_id TEXT NOT NULL,
standing INTEGER NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, faction_id)
);
COMMENT ON TABLE player_faction_standing IS 'Persisted faction standing per player (NEO-135); missing row means neutral 0 at read.';

View File

@ -0,0 +1,17 @@
-- NEO-135: append-only reputation delta audit log.
CREATE TABLE IF NOT EXISTS reputation_delta_audit (
id TEXT NOT NULL PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
faction_id TEXT NOT NULL,
delta_amount INTEGER NOT NULL,
resulting_standing INTEGER NOT NULL,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_reputation_delta_audit_player_recorded
ON reputation_delta_audit (player_id, recorded_at, id);
COMMENT ON TABLE reputation_delta_audit IS 'Append-only faction reputation delta audit (NEO-135).';