Merge pull request #175 from ViPro-Technologies/NEO-136-e7m3-reputation-operations-apply-delta
NEO-136: E7M3-04 ReputationOperations auditable standing applypull/177/head
commit
25aba88e78
|
|
@ -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; **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**. |
|
| **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`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-05** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) → **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
|
|
@ -0,0 +1,159 @@
|
||||||
|
# NEO-136 — E7M3-04: ReputationOperations (apply ReputationDelta)
|
||||||
|
|
||||||
|
**Linear:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)
|
||||||
|
**Branch:** `NEO-136-e7m3-reputation-operations-apply-delta`
|
||||||
|
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-04**
|
||||||
|
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
|
||||||
|
**Pattern:** [NEO-105](NEO-105-implementation-plan.md) / `EncounterCompletionOperations` — static ops + reason codes + compensating rollback; [NEO-127](NEO-127-implementation-plan.md) — orchestrate store writes in order
|
||||||
|
**Precursor:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` (**landed on `main`**)
|
||||||
|
**Blocks:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) (reward bundle rep wiring), [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation + append-only audit so game code never calls **`IFactionStandingStore.TryApplyStandingDelta`** alone. Apply clamps to faction min/max, records one **`ReputationDelta`** row on success, and returns structured outcomes.
|
||||||
|
|
||||||
|
## Kickoff clarifications
|
||||||
|
|
||||||
|
**No clarifications needed.** Linear AC, [E7M3-04 backlog scope](E7M3-pre-production-backlog.md#e7m3-04--reputationoperations-apply-reputationdelta), NEO-135 handoff notes, and NEO-62/NEO-105/NEO-127 precedents settle:
|
||||||
|
|
||||||
|
| Topic | Decision | Evidence |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| Orchestration shape | **Static `ReputationOperations`** with injected stores + `TimeProvider` | `CraftOperations`, `RewardRouterOperations`, `EncounterCompletionOperations` precedent |
|
||||||
|
| Faction validation | **Delegate to `IFactionStandingStore`** (already fail-closed via `IFactionDefinitionRegistry`) | Backlog “validates faction id via registry”; store implements that at boundary — no duplicate registry inject |
|
||||||
|
| `invalid_delta` | **Deny `deltaAmount == 0`** with reason code **`invalid_delta`** at ops layer before store | NEO-135 deferred constant to NEO-136; `SkillProgressionGrantOperations` denies `amount <= 0` |
|
||||||
|
| Audit row id | **Caller supplies non-empty `deltaId`** (UUID string) | NEO-135 adopted decision table: “UUID string generated by caller (NEO-136)” |
|
||||||
|
| Source attribution | **Require non-empty `sourceKind` + `sourceId`**; prototype constant `quest_completion` from `ReputationDeltaSourceKinds` | NEO-135 audit row shape; NEO-138 passes quest id as `sourceId` |
|
||||||
|
| Commit order | **Standing apply first, audit append second** | `IReputationDeltaStore` XML + `server/README.md` Postgres FK remark |
|
||||||
|
| Audit append failure | **Compensating standing rollback** — inverse delta via `TryApplyStandingDelta` when `TryAppend` returns `false` | NEO-62/NEO-69/NEO-105 compensating rollback precedent; avoid standing without audit |
|
||||||
|
| Result type | **New `ReputationApplyOutcome`** (success, reason, before/after standing, optional persisted audit row) | Store outcomes lack ops-level codes (`invalid_delta`, `audit_append_failed`) |
|
||||||
|
| HTTP / quest wiring / Godot | **Out of scope** | Backlog E7M3-04; NEO-138 wires `RewardRouterOperations` |
|
||||||
|
| Bruno / manual QA | **None** — server engine only | Backlog client counterpart: none |
|
||||||
|
|
||||||
|
## Scope and out-of-scope
|
||||||
|
|
||||||
|
**In scope (from Linear + backlog):**
|
||||||
|
|
||||||
|
- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`.
|
||||||
|
- **`ReputationApplyOutcome`**, **`ReputationApplyReasonCodes`** (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough `unknown_faction`, `player_not_writable` from standing store).
|
||||||
|
- Add **`invalid_delta`** to **`FactionStandingReasonCodes`** only if store layer needs it later — **prefer ops-only `ReputationApplyReasonCodes`** to keep store contract unchanged.
|
||||||
|
- Unit tests (AAA): happy path (+15 from 0, audit row queryable), clamp at min/max via ops, unknown faction deny, `deltaAmount == 0` deny, duplicate `deltaId` audit deny with standing unchanged (rollback), empty `deltaId` / source deny.
|
||||||
|
- Host DI smoke: resolve stores + one ops apply through factory services (extend existing faction store host test or dedicated ops test).
|
||||||
|
- `server/README.md` **`ReputationOperations`** section (callers must use ops, not standing store alone).
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- Quest bundle wiring via **`RewardRouterOperations`** (NEO-138 / E7M3-06).
|
||||||
|
- Quest accept gate evaluation (NEO-137).
|
||||||
|
- HTTP GET standing (NEO-139), telemetry hook comments (NEO-141), Godot (NEO-142+).
|
||||||
|
- Postgres-specific integration test for ops (store persistence already covered by NEO-135; ops tests use in-memory stores).
|
||||||
|
|
||||||
|
**Client counterpart:** none (server engine).
|
||||||
|
|
||||||
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
|
- [x] Delta apply updates standing once and records audit row.
|
||||||
|
- [x] Standing never exceeds faction min/max after apply.
|
||||||
|
- [x] `dotnet test` covers ops happy path, clamp, unknown faction deny, and invalid-delta deny.
|
||||||
|
|
||||||
|
## Implementation reconciliation (shipped)
|
||||||
|
|
||||||
|
- **Operations:** `ReputationOperations.TryApplyDelta` — pre-flight validation, standing apply, audit append, compensating rollback on append failure.
|
||||||
|
- **Types:** `ReputationApplyOutcome`, `ReputationApplyReasonCodes` (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`).
|
||||||
|
- **Tests:** `ReputationOperationsTests` — 11 AAA cases including host DI smoke and clamped audit-append rollback (`781` tests green).
|
||||||
|
- **Docs:** `server/README.md` ReputationOperations section; E7.M3 module + alignment register updated.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
### 1. Types (`Game/Factions/`)
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| `ReputationApplyReasonCodes` | `invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough store codes |
|
||||||
|
| `ReputationApplyOutcome` | `Success`, `ReasonCode`, `PreviousStanding`, `NewStanding`, optional `AuditRow` (`ReputationDeltaRow`) |
|
||||||
|
|
||||||
|
### 2. `ReputationOperations.TryApplyDelta`
|
||||||
|
|
||||||
|
Signature (static, mirror other ops):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static ReputationApplyOutcome TryApplyDelta(
|
||||||
|
string playerId,
|
||||||
|
string factionId,
|
||||||
|
int deltaAmount,
|
||||||
|
string deltaId,
|
||||||
|
string sourceKind,
|
||||||
|
string sourceId,
|
||||||
|
IFactionStandingStore standingStore,
|
||||||
|
IReputationDeltaStore auditStore,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Normalize ids via **`FactionStandingIds`**; use normalized values for store calls and audit row.
|
||||||
|
2. **Pre-flight denies** (no store mutation):
|
||||||
|
- `deltaAmount == 0` → `invalid_delta`
|
||||||
|
- empty `deltaId` → `invalid_delta_id`
|
||||||
|
- empty `sourceKind` or `sourceId` → `invalid_source`
|
||||||
|
3. **`standingStore.TryApplyStandingDelta(playerId, factionId, deltaAmount)`** — on deny, return outcome with store **`ReasonCode`** and zeroed standings.
|
||||||
|
4. Build **`ReputationDeltaRow`** with caller `deltaId`, **applied** delta (`newStanding - previousStanding`), `NewStanding` from mutation outcome, normalized ids, **`timeProvider.GetUtcNow()`**.
|
||||||
|
5. **`auditStore.TryAppend(row)`** — on `true`, return success with audit row.
|
||||||
|
6. On append `false`: **compensating rollback** — `standingStore.TryApplyStandingDelta` with **`-appliedDelta`** where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding` (skip when zero); return deny with **`audit_append_failed`**.
|
||||||
|
|
||||||
|
XML remarks: consumed by **`RewardRouterOperations`** (NEO-138) and future admin tools; not HTTP directly. NEO-141 telemetry hook site on successful apply (comment-only in NEO-141).
|
||||||
|
|
||||||
|
### 3. Tests
|
||||||
|
|
||||||
|
**`ReputationOperationsTests`** (in-memory stores + prototype faction registry helper mirroring `InMemoryFactionStandingStoreTests`):
|
||||||
|
|
||||||
|
| Test | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| `TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath` | +15 from 0; audit row fields; `GetDeltasForPlayer` |
|
||||||
|
| `TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand` | Standing 95 + 50 → 100; audit `ResultingStanding` 100 |
|
||||||
|
| `TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand` | Standing -95 + (-50) → -100 |
|
||||||
|
| `TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason` | No standing change; no audit rows |
|
||||||
|
| `TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero` | `invalid_delta` |
|
||||||
|
| `TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty` | `invalid_delta_id` |
|
||||||
|
| `TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty` | `invalid_source` |
|
||||||
|
| `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` | Pre-seeded duplicate id at standing 95; +50 clamped apply rolls back to 95 |
|
||||||
|
| `TryApplyDelta_ShouldDenyNonWritablePlayer` | `player_not_writable` passthrough |
|
||||||
|
|
||||||
|
**Host DI smoke:** one test resolving stores from `InMemoryWebApplicationFactory` and applying via `ReputationOperations` (can live in same test file).
|
||||||
|
|
||||||
|
Manual QA: **none** (infrastructure-only).
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs` | Ops-level stable deny codes including `invalid_delta` |
|
||||||
|
| `server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs` | Structured apply result with optional audit row |
|
||||||
|
| `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs` | `TryApplyDelta` orchestration + compensating rollback |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | AAA unit + host DI smoke tests |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/README.md` | Document `ReputationOperations.TryApplyDelta`, commit order, rollback policy, and “do not call standing store alone for game apply” |
|
||||||
|
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-04 in progress / landed when shipped |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-136 plan + shipped ops when complete |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| File | Action | Coverage |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | **Add** | Happy path, clamp min/max, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, host DI smoke |
|
||||||
|
|
||||||
|
No new Postgres integration tests — NEO-135 already covers store persistence; ops layer is store-agnostic.
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
| Question / risk | Agent recommendation | Status |
|
||||||
|
|-----------------|----------------------|--------|
|
||||||
|
| Standing rollback fails after audit append deny | Document as prototype invariant violation (same as NEO-62 depletion edge); no cross-store transaction | `deferred` |
|
||||||
|
| Negative `deltaAmount` for future rep loss | **Allow** signed delta at ops layer; content grants are positive only | `adopted` |
|
||||||
|
| Caller-generated `deltaId` vs auto UUID | **Caller supplies id** per NEO-135; NEO-138 can derive from delivery key + faction | `adopted` |
|
||||||
|
| Duplicate `deltaId` on retry after rollback | Append fails → rollback → caller may retry with **new** id; quest idempotency at delivery store prevents double call in NEO-138 | `adopted` |
|
||||||
|
|
||||||
|
**NEO-138** (`RewardRouterOperations` rep grants) and **NEO-141** (telemetry comments) depend on this orchestration landing first.
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Code review — NEO-136 (E7M3-04)
|
||||||
|
|
||||||
|
**Date:** 2026-06-15
|
||||||
|
**Scope:** Branch `NEO-136-e7m3-reputation-operations-apply-delta` — commits `e9abc8b` … `536f635` vs `main`
|
||||||
|
**Follow-up:** Review findings addressed in follow-up commit (applied-delta rollback, ops id normalization, AAA nit).
|
||||||
|
|
||||||
|
**Base:** `main`
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approve with nits**
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This branch lands **E7M3-04**: server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation via **`IFactionStandingStore`**, append-only audit via **`IReputationDeltaStore`**, and compensating standing rollback when audit append fails. Supporting types **`ReputationApplyOutcome`** and **`ReputationApplyReasonCodes`** cover ops-level pre-flight denies and store passthrough. Ten AAA unit tests plus host DI smoke exercise happy path, clamp, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, and factory resolution. **`780`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or Godot wiring (NEO-138+). One non-blocking correctness gap: compensating rollback uses `-deltaAmount` rather than the **actual applied delta** when clamping occurred, which can leave wrong standing on rare audit-append failure after a clamped apply.
|
||||||
|
|
||||||
|
## Documentation checked
|
||||||
|
|
||||||
|
| Path | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `docs/plans/NEO-136-implementation-plan.md` | **Matches** — shipped ops, types, tests, README, module/alignment updates reconcile with plan; AC checklist marked complete. Plan flow step 1 cites explicit `FactionStandingIds` normalization at ops layer; implementation delegates player/faction normalization to stores (see Suggestions). |
|
||||||
|
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-04 scope (auditable apply orchestration, no client counterpart, no HTTP) aligns with implementation. |
|
||||||
|
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-04 `ReputationOperations` landed (NEO-136). |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-04 / NEO-136. |
|
||||||
|
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. |
|
||||||
|
| `server/README.md` | **Matches** — new ReputationOperations section documents commit order, rollback policy, pre-flight denies, and “do not call standing store alone.” |
|
||||||
|
|
||||||
|
## Blocking issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
1. ~~**Compensating rollback delta when clamping** — On audit append failure, rollback uses `-deltaAmount`:~~ **Done.** Rollback now uses `-appliedDelta` where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding`; audit row records applied delta. Test `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` added.
|
||||||
|
|
||||||
|
2. ~~**Ops-layer id normalization (plan alignment)** — Plan §2 step 1 says normalize via **`FactionStandingIds`** before store calls.~~ **Done.** `TryApplyDelta` normalizes player/faction ids at the ops boundary; audit row built from normalized values.
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- ~~Nit: **`TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath`** — `auditStore.GetDeltasForPlayer(PlayerId)` runs in the **Act** phase; per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), verification reads belong in **Assert**.~~ **Done.** `GetDeltasForPlayer` moved to Assert.
|
||||||
|
- Nit: **`ReputationApplyOutcome`** on store-boundary deny returns standings from the mutation outcome (good); pre-flight **`Deny()`** zeroes standings — callers must not treat `0` as “neutral read” on `invalid_delta` / `invalid_source` denies (documented implicitly by reason codes).
|
||||||
|
- Nit: Telemetry hook comment for NEO-141 is correctly placed on the success path only — no action needed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run during review (passed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ReputationOperationsTests"
|
||||||
|
dotnet test NeonSprawl.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
Author should confirm PR CI green before merge. No manual QA doc required (server engine only per plan). **Intentional follow-on:** reward bundle rep wiring (NEO-138), quest accept gate eval (NEO-137), HTTP GET standing (NEO-139), Godot HUD (NEO-142+).
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||||
|
|
||||||
|
public sealed class ReputationOperationsTests
|
||||||
|
{
|
||||||
|
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";
|
||||||
|
private const string QuestSourceId = "prototype_quest_operator_chain";
|
||||||
|
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
var timeProvider = new FakeTimeProvider(RecordedAt);
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
15,
|
||||||
|
"delta-001",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
timeProvider);
|
||||||
|
// Assert
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Null(outcome.ReasonCode);
|
||||||
|
Assert.Equal(0, outcome.PreviousStanding);
|
||||||
|
Assert.Equal(15, outcome.NewStanding);
|
||||||
|
Assert.NotNull(outcome.AuditRow);
|
||||||
|
Assert.Equal("delta-001", outcome.AuditRow!.Id);
|
||||||
|
Assert.Equal(15, outcome.AuditRow.DeltaAmount);
|
||||||
|
Assert.Equal(15, outcome.AuditRow.ResultingStanding);
|
||||||
|
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, outcome.AuditRow.SourceKind);
|
||||||
|
Assert.Equal(QuestSourceId, outcome.AuditRow.SourceId);
|
||||||
|
Assert.Equal(RecordedAt, outcome.AuditRow.RecordedAt);
|
||||||
|
var rows = auditStore.GetDeltasForPlayer(PlayerId);
|
||||||
|
var read = Assert.Single(rows);
|
||||||
|
Assert.Equal(outcome.AuditRow, read);
|
||||||
|
Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
50,
|
||||||
|
"delta-max",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Equal(95, outcome.PreviousStanding);
|
||||||
|
Assert.Equal(100, outcome.NewStanding);
|
||||||
|
Assert.Equal(5, outcome.AuditRow!.DeltaAmount);
|
||||||
|
Assert.Equal(100, outcome.AuditRow.ResultingStanding);
|
||||||
|
Assert.Equal(100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success);
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
-50,
|
||||||
|
"delta-min",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Equal(-95, outcome.PreviousStanding);
|
||||||
|
Assert.Equal(-100, outcome.NewStanding);
|
||||||
|
Assert.Equal(-5, outcome.AuditRow!.DeltaAmount);
|
||||||
|
Assert.Equal(-100, outcome.AuditRow.ResultingStanding);
|
||||||
|
Assert.Equal(-100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
"not_a_real_faction",
|
||||||
|
10,
|
||||||
|
"delta-unknown",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
0,
|
||||||
|
"delta-zero",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(ReputationApplyReasonCodes.InvalidDelta, outcome.ReasonCode);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
10,
|
||||||
|
" ",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(ReputationApplyReasonCodes.InvalidDeltaId, outcome.ReasonCode);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
// Act
|
||||||
|
var emptyKind = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
10,
|
||||||
|
"delta-source-kind",
|
||||||
|
" ",
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
var emptyId = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
RustFactionId,
|
||||||
|
10,
|
||||||
|
"delta-source-id",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
"",
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(emptyKind.Success);
|
||||||
|
Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyKind.ReasonCode);
|
||||||
|
Assert.False(emptyId.Success);
|
||||||
|
Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyId.ReasonCode);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, RustFactionId).Standing);
|
||||||
|
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldRollbackStanding_WhenAuditAppendFails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
Assert.True(auditStore.TryAppend(CreateAuditRow("delta-conflict", 1, 1)));
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
15,
|
||||||
|
"delta-conflict",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode);
|
||||||
|
Assert.Equal(0, outcome.PreviousStanding);
|
||||||
|
Assert.Equal(0, outcome.NewStanding);
|
||||||
|
Assert.Null(outcome.AuditRow);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
|
||||||
|
Assert.True(auditStore.TryAppend(CreateAuditRow("delta-clamp-conflict", 1, 1)));
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
50,
|
||||||
|
"delta-clamp-conflict",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode);
|
||||||
|
Assert.Equal(95, outcome.PreviousStanding);
|
||||||
|
Assert.Equal(95, outcome.NewStanding);
|
||||||
|
Assert.Null(outcome.AuditRow);
|
||||||
|
Assert.Equal(95, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryApplyDelta_ShouldDenyNonWritablePlayer()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
var auditStore = CreateAuditStore();
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
UnknownPlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
10,
|
||||||
|
"delta-player",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
|
||||||
|
Assert.Empty(auditStore.GetDeltasForPlayer(UnknownPlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Host_ShouldApplyReputationDeltaViaOps_WhenStartupSucceeds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
_ = await client.GetAsync("/health");
|
||||||
|
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||||
|
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
|
||||||
|
// Act
|
||||||
|
var outcome = ReputationOperations.TryApplyDelta(
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
15,
|
||||||
|
"delta-host",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
// Assert
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryFactionStandingStore CreateStandingStore()
|
||||||
|
{
|
||||||
|
var registry = CreatePrototypeRegistry();
|
||||||
|
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||||
|
return new InMemoryFactionStandingStore(options, registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryReputationDeltaStore CreateAuditStore() => new();
|
||||||
|
|
||||||
|
private static ReputationDeltaRow CreateAuditRow(string id, int delta, int resulting) =>
|
||||||
|
new(
|
||||||
|
id,
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
delta,
|
||||||
|
resulting,
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
QuestSourceId,
|
||||||
|
RecordedAt);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="ReputationOperations.TryApplyDelta"/> (NEO-136).</summary>
|
||||||
|
public readonly record struct ReputationApplyOutcome(
|
||||||
|
bool Success,
|
||||||
|
string? ReasonCode,
|
||||||
|
int PreviousStanding,
|
||||||
|
int NewStanding,
|
||||||
|
ReputationDeltaRow? AuditRow);
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Stable deny reason codes for <see cref="ReputationOperations.TryApplyDelta"/> (NEO-136).</summary>
|
||||||
|
public static class ReputationApplyReasonCodes
|
||||||
|
{
|
||||||
|
public const string InvalidDelta = "invalid_delta";
|
||||||
|
|
||||||
|
public const string InvalidDeltaId = "invalid_delta_id";
|
||||||
|
|
||||||
|
public const string InvalidSource = "invalid_source";
|
||||||
|
|
||||||
|
public const string AuditAppendFailed = "audit_append_failed";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates auditable faction standing apply: standing mutation + append-only audit row (NEO-136).
|
||||||
|
/// Quest reward wiring: <see cref="Rewards.RewardRouterOperations"/> (NEO-138).
|
||||||
|
/// NEO-141 telemetry hook site: successful apply path in <see cref="TryApplyDelta"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReputationOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a signed standing delta for one player+faction, clamps to faction bounds, and appends one audit row.
|
||||||
|
/// Callers must not invoke <see cref="IFactionStandingStore.TryApplyStandingDelta"/> alone for game apply.
|
||||||
|
/// </summary>
|
||||||
|
public static ReputationApplyOutcome TryApplyDelta(
|
||||||
|
string playerId,
|
||||||
|
string factionId,
|
||||||
|
int deltaAmount,
|
||||||
|
string deltaId,
|
||||||
|
string sourceKind,
|
||||||
|
string sourceId,
|
||||||
|
IFactionStandingStore standingStore,
|
||||||
|
IReputationDeltaStore auditStore,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
if (deltaAmount == 0)
|
||||||
|
{
|
||||||
|
return Deny(ReputationApplyReasonCodes.InvalidDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
var normalizedFactionId = FactionStandingIds.NormalizeFactionId(factionId);
|
||||||
|
var normalizedDeltaId = deltaId.Trim();
|
||||||
|
if (normalizedDeltaId.Length == 0)
|
||||||
|
{
|
||||||
|
return Deny(ReputationApplyReasonCodes.InvalidDeltaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSourceKind = sourceKind.Trim();
|
||||||
|
var normalizedSourceId = sourceId.Trim();
|
||||||
|
if (normalizedSourceKind.Length == 0 || normalizedSourceId.Length == 0)
|
||||||
|
{
|
||||||
|
return Deny(ReputationApplyReasonCodes.InvalidSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutation = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, deltaAmount);
|
||||||
|
if (!mutation.Success)
|
||||||
|
{
|
||||||
|
return new ReputationApplyOutcome(
|
||||||
|
false,
|
||||||
|
mutation.ReasonCode,
|
||||||
|
mutation.PreviousStanding,
|
||||||
|
mutation.NewStanding,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var appliedDelta = mutation.NewStanding - mutation.PreviousStanding;
|
||||||
|
var row = new ReputationDeltaRow(
|
||||||
|
normalizedDeltaId,
|
||||||
|
normalizedPlayerId,
|
||||||
|
normalizedFactionId,
|
||||||
|
appliedDelta,
|
||||||
|
mutation.NewStanding,
|
||||||
|
normalizedSourceKind,
|
||||||
|
normalizedSourceId,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
if (auditStore.TryAppend(row))
|
||||||
|
{
|
||||||
|
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` ---
|
||||||
|
return new ReputationApplyOutcome(
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
mutation.PreviousStanding,
|
||||||
|
mutation.NewStanding,
|
||||||
|
row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedDelta != 0)
|
||||||
|
{
|
||||||
|
_ = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, -appliedDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReputationApplyOutcome(
|
||||||
|
false,
|
||||||
|
ReputationApplyReasonCodes.AuditAppendFailed,
|
||||||
|
mutation.PreviousStanding,
|
||||||
|
mutation.PreviousStanding,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReputationApplyOutcome Deny(string reasonCode) =>
|
||||||
|
new(false, reasonCode, 0, 0, null);
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,20 @@ Append-only **`ReputationDelta`** audit rows live in **`IReputationDeltaStore`**
|
||||||
|
|
||||||
**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).
|
**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).
|
||||||
|
|
||||||
|
## ReputationOperations (NEO-136)
|
||||||
|
|
||||||
|
Game code applies standing changes through **`ReputationOperations.TryApplyDelta`** — not **`IFactionStandingStore.TryApplyStandingDelta`** alone — so every mutation records source attribution and an append-only audit row.
|
||||||
|
|
||||||
|
**`TryApplyDelta`** parameters: `playerId`, `factionId`, signed `deltaAmount`, caller-supplied `deltaId` (UUID string), `sourceKind`, `sourceId`, plus injected **`IFactionStandingStore`**, **`IReputationDeltaStore`**, and **`TimeProvider`**.
|
||||||
|
|
||||||
|
**Commit order:** standing apply first, audit append second. Audit row **`deltaAmount`** records the **applied** change (`newStanding - previousStanding`), not the requested delta when clamping occurs. On audit append failure (`duplicate` id or invalid row), the operation **compensating-rollback** standing via the inverse applied delta and returns **`audit_append_failed`**.
|
||||||
|
|
||||||
|
**Pre-flight denies (no store mutation):** `deltaAmount == 0` → **`invalid_delta`**; empty `deltaId` → **`invalid_delta_id`**; empty `sourceKind` or `sourceId` → **`invalid_source`**. Store boundary denies (**`unknown_faction`**, **`player_not_writable`**) pass through unchanged.
|
||||||
|
|
||||||
|
**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring lands in NEO-138 (`RewardRouterOperations`).
|
||||||
|
|
||||||
|
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue