From 4fca45bf7c9a72b59c40b702c5cb4f43bc8e7e80 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 20:23:57 -0400 Subject: [PATCH] NEO-135: add faction standing and reputation delta audit stores Per-player IFactionStandingStore with clamped reads/writes and fail-closed unknown-faction deny; append-only IReputationDeltaStore; in-memory plus Postgres V009/V010 when configured. Tests, Bruno health smoke, README. --- ...alth after faction standing store load.bru | 25 +++ .../faction-standing/folder.bru | 3 + docs/plans/NEO-135-implementation-plan.md | 14 +- ...tionStandingPersistenceIntegrationTests.cs | 121 +++++++++++++ .../InMemoryFactionStandingStoreTests.cs | 163 ++++++++++++++++++ .../InMemoryReputationDeltaStoreTests.cs | 117 +++++++++++++ ...utationDeltaPersistenceIntegrationTests.cs | 97 +++++++++++ .../InMemoryWebApplicationFactory.cs | 4 + .../Game/Factions/FactionStandingIds.cs | 41 +++++ .../FactionStandingMutationOutcome.cs | 8 + .../Factions/FactionStandingReadOutcome.cs | 7 + .../Factions/FactionStandingReasonCodes.cs | 9 + ...tionStandingServiceCollectionExtensions.cs | 25 +++ .../Game/Factions/IFactionStandingStore.cs | 23 +++ .../Game/Factions/IReputationDeltaStore.cs | 11 ++ .../Factions/InMemoryFactionStandingStore.cs | 97 +++++++++++ .../Factions/InMemoryReputationDeltaStore.cs | 69 ++++++++ .../Factions/PostgresFactionStandingStore.cs | 144 ++++++++++++++++ .../PostgresPlayerFactionStandingBootstrap.cs | 39 +++++ .../PostgresReputationDeltaBootstrap.cs | 39 +++++ .../Factions/PostgresReputationDeltaStore.cs | 100 +++++++++++ .../Game/Factions/ReputationDeltaRow.cs | 12 ++ .../Factions/ReputationDeltaSourceKinds.cs | 7 + server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 17 ++ .../V009__player_faction_standing.sql | 10 ++ .../V010__reputation_delta_audit.sql | 17 ++ 27 files changed, 1217 insertions(+), 3 deletions(-) create mode 100644 bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru create mode 100644 bruno/neon-sprawl-server/faction-standing/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs create mode 100644 server/db/migrations/V009__player_faction_standing.sql create mode 100644 server/db/migrations/V010__reputation_delta_audit.sql diff --git a/bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru b/bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru new file mode 100644 index 0000000..43ca726 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/Health after faction standing store load.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/faction-standing/folder.bru b/bruno/neon-sprawl-server/faction-standing/folder.bru new file mode 100644 index 0000000..99cdae0 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-standing/folder.bru @@ -0,0 +1,3 @@ +meta { + name: faction-standing +} diff --git a/docs/plans/NEO-135-implementation-plan.md b/docs/plans/NEO-135-implementation-plan.md index 5af8525..0838a1c 100644 --- a/docs/plans/NEO-135-implementation-plan.md +++ b/docs/plans/NEO-135-implementation-plan.md @@ -54,9 +54,17 @@ Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationD ## Acceptance criteria checklist -- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests. -- [ ] Unknown faction id returns structured deny at store boundary. -- [ ] `dotnet test` covers store gates. +- [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 (`766` tests green). +- **Docs:** `server/README.md` faction standing + reputation delta store sections. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs new file mode 100644 index 0000000..54bd174 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs @@ -0,0 +1,121 @@ +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 + using (var firstScope = Factory.Services.CreateScope()) + { + var store = firstScope.ServiceProvider.GetRequiredService(); + var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15); + Assert.True(outcome.Success); + Assert.Equal(15, outcome.NewStanding); + } + + FactionStandingReadOutcome readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var secondScope = secondFactory.Services.CreateScope(); + var store = secondScope.ServiceProvider.GetRequiredService(); + readBack = store.TryGetStanding(PlayerId, GridFactionId); + } + + // Assert + 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(); + 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>().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(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs new file mode 100644 index 0000000..6cc5cf7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs @@ -0,0 +1,163 @@ +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_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_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(); + var auditStore = factory.Services.GetRequiredService(); + 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(StringComparer.Ordinal) + { + [GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId], + [RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId], + }; + var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new FactionDefinitionRegistry(catalog); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs new file mode 100644 index 0000000..0ba538c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs @@ -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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs new file mode 100644 index 0000000..50e512f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs @@ -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(); + Assert.True(store.TryAppend(row)); + } + + IReadOnlyList readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var secondScope = secondFactory.Services.CreateScope(); + var store = secondScope.ServiceProvider.GetRequiredService(); + 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>().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(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 2d2043e..48925a7 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -89,6 +89,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs new file mode 100644 index 0000000..f721da8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs @@ -0,0 +1,41 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Id normalization for faction standing stores (NEO-135). +public static class FactionStandingIds +{ + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizePlayerId(string? playerId) + { + var trimmed = playerId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } + + /// Trim; empty when input is null/whitespace. Faction ids stay case-sensitive (catalog ids are lowercase snake). + public static string NormalizeFactionId(string? factionId) + { + var trimmed = factionId?.Trim(); + return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed; + } + + /// Composite store key for + . + 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}"; + } + + /// Clamps to the faction definition band. + public static int ClampStanding(int standing, FactionDefRow definition) => + Math.Clamp(standing, definition.MinStanding, definition.MaxStanding); +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs new file mode 100644 index 0000000..5281378 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-135). +public readonly record struct FactionStandingMutationOutcome( + bool Success, + string? ReasonCode, + int PreviousStanding, + int NewStanding); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs new file mode 100644 index 0000000..78bb64d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Result of (NEO-135). +public readonly record struct FactionStandingReadOutcome( + bool Success, + string? ReasonCode, + int Standing); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs new file mode 100644 index 0000000..d843526 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable deny reason codes for faction standing store operations (NEO-135). +public static class FactionStandingReasonCodes +{ + public const string UnknownFaction = "unknown_faction"; + + public const string PlayerNotWritable = "player_not_writable"; +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd38d01 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Factions; + +/// Registers faction standing + reputation delta audit persistence (NEO-135). +public static class FactionStandingServiceCollectionExtensions +{ + /// PostgreSQL when ConnectionStrings:NeonSprawl is set; otherwise in-memory fallback. + public static IServiceCollection AddFactionStandingStores(this IServiceCollection services, IConfiguration configuration) + { + var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs new file mode 100644 index 0000000..2d5086c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Persisted per-player faction standing keyed by (playerId, factionId) (NEO-135). +/// Missing row ⇒ neutral 0 at read. Consumed by ReputationOperations (NEO-136) and gate eval (NEO-137). +/// +public interface IFactionStandingStore +{ + /// Whether mutations for are allowed (dev bucket or Postgres player_position row). + bool CanWritePlayer(string playerId); + + /// + /// Reads standing for one faction. Missing row ⇒ 0 clamped to faction min/max. + /// Unknown faction id ⇒ structured deny with . + /// + FactionStandingReadOutcome TryGetStanding(string playerId, string factionId); + + /// + /// Applies a signed delta, clamps resulting standing to faction min/max, and persists. + /// Does not append audit rows — is orchestrated by NEO-136. + /// + FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount); +} diff --git a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs new file mode 100644 index 0000000..89462f2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Append-only reputation delta audit log (NEO-135). +public interface IReputationDeltaStore +{ + /// First append with a given row Id returns true; duplicate ids return false. + bool TryAppend(ReputationDeltaRow row); + + /// Audit rows for one player ordered by then Id ascending. + IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null); +} diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs new file mode 100644 index 0000000..243cc21 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Factions; + +/// Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135). +public sealed class InMemoryFactionStandingStore( + IOptions options, + IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore +{ + private readonly HashSet knownPlayers = CreateKnownPlayers(options.Value); + + private readonly ConcurrentDictionary standingByKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + private static HashSet 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(StringComparer.OrdinalIgnoreCase) { id }; + } + + /// + public bool CanWritePlayer(string playerId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + return player.Length > 0 && knownPlayers.Contains(player); + } + + /// + public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (!CanWritePlayer(player)) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) + { + return DenyRead(FactionStandingReasonCodes.UnknownFaction); + } + + var key = FactionStandingIds.MakeStandingKey(player, faction); + var raw = standingByKey.GetValueOrDefault(key, 0); + return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition)); + } + + /// + public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (!CanWritePlayer(player)) + { + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) + { + return DenyMutation(FactionStandingReasonCodes.UnknownFaction); + } + + 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(raw + 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); +} diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs new file mode 100644 index 0000000..fcfff4f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Factions; + +/// Thread-safe in-memory append-only reputation delta audit log (NEO-135). +public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore +{ + private readonly ConcurrentDictionary rowsById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + 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; + } + } + + /// + public IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + if (player.Length == 0) + { + return []; + } + + IEnumerable 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; +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs new file mode 100644 index 0000000..97cf1fd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs @@ -0,0 +1,144 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135). +public sealed class PostgresFactionStandingStore( + Npgsql.NpgsqlDataSource dataSource, + IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore +{ + /// + 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); + } + + /// + public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); + } + + 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)); + } + + /// + public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); + } + + 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(raw + 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); +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs b/server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs new file mode 100644 index 0000000..72414ce --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Applies NEO-135 faction standing table DDL once per process. +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); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs new file mode 100644 index 0000000..30439e7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Applies NEO-135 reputation delta audit table DDL once per process. +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); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs new file mode 100644 index 0000000..14bdd8f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs @@ -0,0 +1,100 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// PostgreSQL-backed append-only reputation delta audit log (NEO-135). +public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore +{ + /// + 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; + } + + /// + public IReadOnlyList 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(); + 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(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; +} diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs new file mode 100644 index 0000000..a6d42a2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Append-only reputation delta audit row (NEO-135). +public sealed record ReputationDeltaRow( + string Id, + string PlayerId, + string FactionId, + int DeltaAmount, + int ResultingStanding, + string SourceKind, + string SourceId, + DateTimeOffset RecordedAt); diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs new file mode 100644 index 0000000..873a50d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Stable source-kind values for (NEO-135). +public static class ReputationDeltaSourceKinds +{ + public const string QuestCompletion = "quest_completion"; +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 1c28fd3..f400be5 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); diff --git a/server/README.md b/server/README.md index e25af33..eb550e6 100644 --- a/server/README.md +++ b/server/README.md @@ -63,6 +63,23 @@ 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). + +**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. diff --git a/server/db/migrations/V009__player_faction_standing.sql b/server/db/migrations/V009__player_faction_standing.sql new file mode 100644 index 0000000..1f09911 --- /dev/null +++ b/server/db/migrations/V009__player_faction_standing.sql @@ -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.'; diff --git a/server/db/migrations/V010__reputation_delta_audit.sql b/server/db/migrations/V010__reputation_delta_audit.sql new file mode 100644 index 0000000..cc8f06d --- /dev/null +++ b/server/db/migrations/V010__reputation_delta_audit.sql @@ -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).';