NEO-136: add ReputationOperations auditable standing apply
parent
e9abc8bf38
commit
8aae9625e7
|
|
@ -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<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,86 @@
|
|||
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 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);
|
||||
}
|
||||
Loading…
Reference in New Issue