Merge pull request #174 from ViPro-Technologies/NEO-135-e7m3-faction-standing-reputation-stores
NEO-135: E7M3-03 faction standing and reputation delta audit storespull/177/head
commit
786a5b6d68
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: faction-standing
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M3 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **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) |
|
||||
|
||||
## Purpose
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
@ -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+).
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
||||
d.ServiceType == typeof(IFactionStandingStore) ||
|
||||
d.ServiceType == typeof(IReputationDeltaStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
|
|
@ -111,6 +113,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
|
||||
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
|||
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
||||
builder.Services.AddRewardDeliveryStore();
|
||||
builder.Services.AddFactionStandingStores(builder.Configuration);
|
||||
builder.Services.AddThreatStateStore();
|
||||
builder.Services.AddNpcRuntimeStateStore();
|
||||
builder.Services.AddPlayerCombatHealthStore();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## 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)
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
@ -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).';
|
||||
Loading…
Reference in New Issue