From 8aae9625e709df3a8f28248bb704d0f4b1d42b59 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 20:48:42 -0400 Subject: [PATCH] NEO-136: add ReputationOperations auditable standing apply --- .../Factions/ReputationOperationsTests.cs | 328 ++++++++++++++++++ .../Game/Factions/ReputationApplyOutcome.cs | 9 + .../Factions/ReputationApplyReasonCodes.cs | 13 + .../Game/Factions/ReputationOperations.cs | 86 +++++ 4 files changed, 436 insertions(+) create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs new file mode 100644 index 0000000..590a979 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs @@ -0,0 +1,328 @@ +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); + var rows = auditStore.GetDeltasForPlayer(PlayerId); + // 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 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(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(-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_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(); + var auditStore = factory.Services.GetRequiredService(); + // 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(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; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs b/server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs new file mode 100644 index 0000000..50c2f3a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-136). +public readonly record struct ReputationApplyOutcome( + bool Success, + string? ReasonCode, + int PreviousStanding, + int NewStanding, + ReputationDeltaRow? AuditRow); diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs b/server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs new file mode 100644 index 0000000..4ccd60a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable deny reason codes for (NEO-136). +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"; +} diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs b/server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs new file mode 100644 index 0000000..2365afc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs @@ -0,0 +1,86 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Orchestrates auditable faction standing apply: standing mutation + append-only audit row (NEO-136). +/// Quest reward wiring: (NEO-138). +/// NEO-141 telemetry hook site: successful apply path in . +/// +public static class ReputationOperations +{ + /// + /// Applies a signed standing delta for one player+faction, clamps to faction bounds, and appends one audit row. + /// Callers must not invoke alone for game apply. + /// + 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 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(playerId, factionId, deltaAmount); + if (!mutation.Success) + { + return new ReputationApplyOutcome( + false, + mutation.ReasonCode, + mutation.PreviousStanding, + mutation.NewStanding, + null); + } + + var row = new ReputationDeltaRow( + normalizedDeltaId, + playerId, + factionId, + deltaAmount, + 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); + } + + _ = standingStore.TryApplyStandingDelta(playerId, factionId, -deltaAmount); + return new ReputationApplyOutcome( + false, + ReputationApplyReasonCodes.AuditAppendFailed, + mutation.PreviousStanding, + mutation.PreviousStanding, + null); + } + + private static ReputationApplyOutcome Deny(string reasonCode) => + new(false, reasonCode, 0, 0, null); +}