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.pull/174/head
parent
08ebfcebcf
commit
4fca45bf7c
|
|
@ -0,0 +1,25 @@
|
||||||
|
meta {
|
||||||
|
name: GET health (faction standing store NEO-135)
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/health
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-135 registers IFactionStandingStore and IReputationDeltaStore (in-memory or Postgres when configured). No faction standing HTTP API in this story — use this request to confirm the host started after store DI wiring.
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("status 200", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service identity", function () {
|
||||||
|
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
meta {
|
||||||
|
name: faction-standing
|
||||||
|
}
|
||||||
|
|
@ -54,9 +54,17 @@ Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationD
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests.
|
- [x] Standing readable per player+faction; deltas append-only and queryable for audit tests.
|
||||||
- [ ] Unknown faction id returns structured deny at store boundary.
|
- [x] Unknown faction id returns structured deny at store boundary.
|
||||||
- [ ] `dotnet test` covers store gates.
|
- [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
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<IFactionStandingStore>();
|
||||||
|
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<IFactionStandingStore>();
|
||||||
|
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<IFactionStandingStore>();
|
||||||
|
readBack = store.TryGetStanding(PlayerId, GridFactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(readBack.Success);
|
||||||
|
Assert.Equal(100, readBack.Standing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetFactionStandingTableAsync()
|
||||||
|
{
|
||||||
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Factory.Services;
|
||||||
|
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||||
|
|
||||||
|
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||||
|
var standingDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V009__player_faction_standing.sql");
|
||||||
|
if (!File.Exists(positionDdlPath) || !File.Exists(standingDdlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Test DDL for faction standing persistence not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||||
|
var standingDdl = await File.ReadAllTextAsync(standingDdlPath);
|
||||||
|
await using var conn = new NpgsqlConnection(cs);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||||
|
{
|
||||||
|
await applyPosition.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||||
|
{
|
||||||
|
await truncate.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||||
|
|
||||||
|
await using (var applyStanding = new NpgsqlCommand(standingDdl, conn))
|
||||||
|
{
|
||||||
|
await applyStanding.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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<IFactionStandingStore>();
|
||||||
|
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
|
||||||
|
var read = standingStore.TryGetStanding(PlayerId, GridFactionId);
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(auditStore);
|
||||||
|
Assert.True(read.Success);
|
||||||
|
Assert.Equal(0, read.Standing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryFactionStandingStore CreateStore()
|
||||||
|
{
|
||||||
|
var registry = CreatePrototypeRegistry();
|
||||||
|
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||||
|
return new InMemoryFactionStandingStore(options, registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FactionDefinitionRegistry CreatePrototypeRegistry()
|
||||||
|
{
|
||||||
|
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
|
||||||
|
[RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId],
|
||||||
|
};
|
||||||
|
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||||
|
return new FactionDefinitionRegistry(catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||||
|
|
||||||
|
public sealed class InMemoryReputationDeltaStoreTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||||
|
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryAppend_ShouldReturnTrueAndQueryRow_WhenFirstAppend()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
var row = CreateRow("delta-001", 15, 15);
|
||||||
|
// Act
|
||||||
|
var appended = store.TryAppend(row);
|
||||||
|
var rows = store.GetDeltasForPlayer(PlayerId);
|
||||||
|
// Assert
|
||||||
|
Assert.True(appended);
|
||||||
|
var read = Assert.Single(rows);
|
||||||
|
Assert.Equal(row.Id, read.Id);
|
||||||
|
Assert.Equal(15, read.DeltaAmount);
|
||||||
|
Assert.Equal(15, read.ResultingStanding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryAppend_ShouldReturnFalse_WhenDuplicateId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
var row = CreateRow("delta-dup", 10, 10);
|
||||||
|
Assert.True(store.TryAppend(row));
|
||||||
|
// Act
|
||||||
|
var duplicate = store.TryAppend(row with { DeltaAmount = 99, ResultingStanding = 99 });
|
||||||
|
// Assert
|
||||||
|
Assert.False(duplicate);
|
||||||
|
var read = Assert.Single(store.GetDeltasForPlayer(PlayerId));
|
||||||
|
Assert.Equal(10, read.DeltaAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryAppend_ShouldReturnTrueTwice_WhenDistinctIds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
// Act
|
||||||
|
var first = store.TryAppend(CreateRow("delta-a", 15, 15));
|
||||||
|
var second = store.TryAppend(CreateRow("delta-b", 10, 25));
|
||||||
|
// Assert
|
||||||
|
Assert.True(first);
|
||||||
|
Assert.True(second);
|
||||||
|
Assert.Equal(2, store.GetDeltasForPlayer(PlayerId).Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryAppend_ShouldReturnFalse_WhenRowInvalid()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
var emptyId = CreateRow("", 5, 5);
|
||||||
|
var emptyPlayer = CreateRow("delta-x", 5, 5) with { PlayerId = " " };
|
||||||
|
var emptyFaction = CreateRow("delta-y", 5, 5) with { FactionId = "" };
|
||||||
|
// Act
|
||||||
|
var noId = store.TryAppend(emptyId);
|
||||||
|
var noPlayer = store.TryAppend(emptyPlayer);
|
||||||
|
var noFaction = store.TryAppend(emptyFaction);
|
||||||
|
// Assert
|
||||||
|
Assert.False(noId);
|
||||||
|
Assert.False(noPlayer);
|
||||||
|
Assert.False(noFaction);
|
||||||
|
Assert.Empty(store.GetDeltasForPlayer(PlayerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDeltasForPlayer_ShouldOrderByRecordedAtThenId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
Assert.True(store.TryAppend(CreateRow("delta-z", 1, 1) with { RecordedAt = RecordedAt.AddMinutes(2) }));
|
||||||
|
Assert.True(store.TryAppend(CreateRow("delta-a", 2, 3) with { RecordedAt = RecordedAt }));
|
||||||
|
Assert.True(store.TryAppend(CreateRow("delta-m", 3, 6) with { RecordedAt = RecordedAt }));
|
||||||
|
// Act
|
||||||
|
var rows = store.GetDeltasForPlayer(PlayerId);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(["delta-a", "delta-m", "delta-z"], rows.Select(static r => r.Id).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDeltasForPlayer_ShouldRespectLimit_WhenProvided()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
Assert.True(store.TryAppend(CreateRow("delta-1", 1, 1)));
|
||||||
|
Assert.True(store.TryAppend(CreateRow("delta-2", 1, 2)));
|
||||||
|
// Act
|
||||||
|
var rows = store.GetDeltasForPlayer(PlayerId, limit: 1);
|
||||||
|
// Assert
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.Equal("delta-1", rows[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryReputationDeltaStore CreateStore() => new();
|
||||||
|
|
||||||
|
private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) =>
|
||||||
|
new(
|
||||||
|
id,
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
delta,
|
||||||
|
resulting,
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
"prototype_quest_operator_chain",
|
||||||
|
RecordedAt);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||||
|
using Npgsql;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||||
|
|
||||||
|
[Collection("Postgres integration")]
|
||||||
|
public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||||
|
{
|
||||||
|
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||||
|
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string GridFactionId = "prototype_faction_grid_operators";
|
||||||
|
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[RequirePostgresFact]
|
||||||
|
public async Task AppendThenGetAcrossNewFactory_ShouldPersistAuditRow()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await ResetReputationDeltaTableAsync();
|
||||||
|
var row = new ReputationDeltaRow(
|
||||||
|
"delta-persist-001",
|
||||||
|
PlayerId,
|
||||||
|
GridFactionId,
|
||||||
|
15,
|
||||||
|
15,
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
"prototype_quest_operator_chain",
|
||||||
|
RecordedAt);
|
||||||
|
|
||||||
|
// Act — write through first host
|
||||||
|
using (var firstScope = Factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var store = firstScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
|
||||||
|
Assert.True(store.TryAppend(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<ReputationDeltaRow> readBack;
|
||||||
|
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||||
|
{
|
||||||
|
using var secondScope = secondFactory.Services.CreateScope();
|
||||||
|
var store = secondScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
|
||||||
|
readBack = store.GetDeltasForPlayer(PlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var persisted = Assert.Single(readBack);
|
||||||
|
Assert.Equal(row.Id, persisted.Id);
|
||||||
|
Assert.Equal(15, persisted.DeltaAmount);
|
||||||
|
Assert.Equal(15, persisted.ResultingStanding);
|
||||||
|
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, persisted.SourceKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetReputationDeltaTableAsync()
|
||||||
|
{
|
||||||
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Factory.Services;
|
||||||
|
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||||
|
|
||||||
|
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||||
|
var auditDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V010__reputation_delta_audit.sql");
|
||||||
|
if (!File.Exists(positionDdlPath) || !File.Exists(auditDdlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Test DDL for reputation delta persistence not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||||
|
var auditDdl = await File.ReadAllTextAsync(auditDdlPath);
|
||||||
|
await using var conn = new NpgsqlConnection(cs);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||||
|
{
|
||||||
|
await applyPosition.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||||
|
{
|
||||||
|
await truncate.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||||
|
|
||||||
|
await using (var applyAudit = new NpgsqlCommand(auditDdl, conn))
|
||||||
|
{
|
||||||
|
await applyAudit.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
||||||
|
d.ServiceType == typeof(IFactionStandingStore) ||
|
||||||
|
d.ServiceType == typeof(IReputationDeltaStore) ||
|
||||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||||
|
|
@ -111,6 +113,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||||
|
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
|
||||||
|
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
|
||||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Id normalization for faction standing stores (NEO-135).</summary>
|
||||||
|
public static class FactionStandingIds
|
||||||
|
{
|
||||||
|
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||||
|
public static string NormalizePlayerId(string? playerId)
|
||||||
|
{
|
||||||
|
var trimmed = playerId?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Trim; empty when input is null/whitespace. Faction ids stay case-sensitive (catalog ids are lowercase snake).</summary>
|
||||||
|
public static string NormalizeFactionId(string? factionId)
|
||||||
|
{
|
||||||
|
var trimmed = factionId?.Trim();
|
||||||
|
return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="factionId"/>.</summary>
|
||||||
|
public static string MakeStandingKey(string? playerId, string? factionId)
|
||||||
|
{
|
||||||
|
var player = NormalizePlayerId(playerId);
|
||||||
|
var faction = NormalizeFactionId(factionId);
|
||||||
|
if (player.Length == 0 || faction.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{player}\0{faction}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clamps <paramref name="standing"/> to the faction definition band.</summary>
|
||||||
|
public static int ClampStanding(int standing, FactionDefRow definition) =>
|
||||||
|
Math.Clamp(standing, definition.MinStanding, definition.MaxStanding);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="IFactionStandingStore.TryApplyStandingDelta"/> (NEO-135).</summary>
|
||||||
|
public readonly record struct FactionStandingMutationOutcome(
|
||||||
|
bool Success,
|
||||||
|
string? ReasonCode,
|
||||||
|
int PreviousStanding,
|
||||||
|
int NewStanding);
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="IFactionStandingStore.TryGetStanding"/> (NEO-135).</summary>
|
||||||
|
public readonly record struct FactionStandingReadOutcome(
|
||||||
|
bool Success,
|
||||||
|
string? ReasonCode,
|
||||||
|
int Standing);
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Stable deny reason codes for faction standing store operations (NEO-135).</summary>
|
||||||
|
public static class FactionStandingReasonCodes
|
||||||
|
{
|
||||||
|
public const string UnknownFaction = "unknown_faction";
|
||||||
|
|
||||||
|
public const string PlayerNotWritable = "player_not_writable";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Registers faction standing + reputation delta audit persistence (NEO-135).</summary>
|
||||||
|
public static class FactionStandingServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set; otherwise in-memory fallback.</summary>
|
||||||
|
public static IServiceCollection AddFactionStandingStores(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
services.AddSingleton<IFactionStandingStore, PostgresFactionStandingStore>();
|
||||||
|
services.AddSingleton<IReputationDeltaStore, PostgresReputationDeltaStore>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
|
||||||
|
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted per-player faction standing keyed by <c>(playerId, factionId)</c> (NEO-135).
|
||||||
|
/// Missing row ⇒ neutral <c>0</c> at read. Consumed by <c>ReputationOperations</c> (NEO-136) and gate eval (NEO-137).
|
||||||
|
/// </summary>
|
||||||
|
public interface IFactionStandingStore
|
||||||
|
{
|
||||||
|
/// <summary>Whether mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
|
||||||
|
bool CanWritePlayer(string playerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads standing for one faction. Missing row ⇒ <c>0</c> clamped to faction min/max.
|
||||||
|
/// Unknown faction id ⇒ structured deny with <see cref="FactionStandingReasonCodes.UnknownFaction"/>.
|
||||||
|
/// </summary>
|
||||||
|
FactionStandingReadOutcome TryGetStanding(string playerId, string factionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a signed delta, clamps resulting standing to faction min/max, and persists.
|
||||||
|
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
|
||||||
|
/// </summary>
|
||||||
|
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Append-only reputation delta audit log (NEO-135).</summary>
|
||||||
|
public interface IReputationDeltaStore
|
||||||
|
{
|
||||||
|
/// <summary>First append with a given row <c>Id</c> returns <c>true</c>; duplicate ids return <c>false</c>.</summary>
|
||||||
|
bool TryAppend(ReputationDeltaRow row);
|
||||||
|
|
||||||
|
/// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary>
|
||||||
|
IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135).</summary>
|
||||||
|
public sealed class InMemoryFactionStandingStore(
|
||||||
|
IOptions<GamePositionOptions> options,
|
||||||
|
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
|
||||||
|
{
|
||||||
|
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, int> standingByKey = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
|
||||||
|
{
|
||||||
|
var id = FactionStandingIds.NormalizePlayerId(o.DevPlayerId);
|
||||||
|
if (id.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanWritePlayer(string playerId)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
return player.Length > 0 && knownPlayers.Contains(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||||
|
if (player.Length == 0 || 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Thread-safe in-memory append-only reputation delta audit log (NEO-135).</summary>
|
||||||
|
public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ReputationDeltaRow> rowsById = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryAppend(ReputationDeltaRow row)
|
||||||
|
{
|
||||||
|
if (!IsValidRow(row))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = row with
|
||||||
|
{
|
||||||
|
Id = row.Id.Trim(),
|
||||||
|
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
|
||||||
|
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
|
||||||
|
SourceKind = row.SourceKind.Trim(),
|
||||||
|
SourceId = row.SourceId.Trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
lock (idLocks.GetOrAdd(normalized.Id, _ => new object()))
|
||||||
|
{
|
||||||
|
if (rowsById.ContainsKey(normalized.Id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsById[normalized.Id] = normalized;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
if (player.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<ReputationDeltaRow> query = rowsById.Values
|
||||||
|
.Where(r => string.Equals(r.PlayerId, player, StringComparison.Ordinal))
|
||||||
|
.OrderBy(static r => r.RecordedAt)
|
||||||
|
.ThenBy(static r => r.Id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (limit is > 0)
|
||||||
|
{
|
||||||
|
query = query.Take(limit.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidRow(ReputationDeltaRow row) =>
|
||||||
|
row.Id.Trim().Length > 0 &&
|
||||||
|
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
|
||||||
|
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
|
||||||
|
row.SourceKind.Trim().Length > 0 &&
|
||||||
|
row.SourceId.Trim().Length > 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135).</summary>
|
||||||
|
public sealed class PostgresFactionStandingStore(
|
||||||
|
Npgsql.NpgsqlDataSource dataSource,
|
||||||
|
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanWritePlayer(string playerId)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
if (player.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
return PlayerExists(conn, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||||
|
if (player.Length == 0 || 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Applies NEO-135 faction standing table DDL once per process.</summary>
|
||||||
|
public static class PostgresPlayerFactionStandingBootstrap
|
||||||
|
{
|
||||||
|
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V009__player_faction_standing.sql");
|
||||||
|
|
||||||
|
private static readonly object SchemaGate = new();
|
||||||
|
|
||||||
|
private static int _schemaReady;
|
||||||
|
|
||||||
|
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SchemaGate)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||||
|
if (!File.Exists(ddlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"NEO-135 faction standing DDL not found at '{ddlPath}'.", ddlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddl = File.ReadAllText(ddlPath);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
Volatile.Write(ref _schemaReady, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Applies NEO-135 reputation delta audit table DDL once per process.</summary>
|
||||||
|
public static class PostgresReputationDeltaBootstrap
|
||||||
|
{
|
||||||
|
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V010__reputation_delta_audit.sql");
|
||||||
|
|
||||||
|
private static readonly object SchemaGate = new();
|
||||||
|
|
||||||
|
private static int _schemaReady;
|
||||||
|
|
||||||
|
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SchemaGate)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||||
|
if (!File.Exists(ddlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"NEO-135 reputation delta DDL not found at '{ddlPath}'.", ddlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddl = File.ReadAllText(ddlPath);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
Volatile.Write(ref _schemaReady, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
|
||||||
|
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryAppend(ReputationDeltaRow row)
|
||||||
|
{
|
||||||
|
if (!IsValidRow(row))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = row with
|
||||||
|
{
|
||||||
|
Id = row.Id.Trim(),
|
||||||
|
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
|
||||||
|
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
|
||||||
|
SourceKind = row.SourceKind.Trim(),
|
||||||
|
SourceId = row.SourceId.Trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO reputation_delta_audit (
|
||||||
|
id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)
|
||||||
|
VALUES (@id, @pid, @fid, @delta, @result, @kind, @source_id, @recorded)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
""",
|
||||||
|
conn);
|
||||||
|
cmd.Parameters.AddWithValue("id", normalized.Id);
|
||||||
|
cmd.Parameters.AddWithValue("pid", normalized.PlayerId);
|
||||||
|
cmd.Parameters.AddWithValue("fid", normalized.FactionId);
|
||||||
|
cmd.Parameters.AddWithValue("delta", normalized.DeltaAmount);
|
||||||
|
cmd.Parameters.AddWithValue("result", normalized.ResultingStanding);
|
||||||
|
cmd.Parameters.AddWithValue("kind", normalized.SourceKind);
|
||||||
|
cmd.Parameters.AddWithValue("source_id", normalized.SourceId);
|
||||||
|
cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt);
|
||||||
|
return cmd.ExecuteNonQuery() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
if (player.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
var sql = limit is > 0
|
||||||
|
? """
|
||||||
|
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
|
||||||
|
FROM reputation_delta_audit
|
||||||
|
WHERE player_id = @pid
|
||||||
|
ORDER BY recorded_at ASC, id ASC
|
||||||
|
LIMIT @lim;
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
|
||||||
|
FROM reputation_delta_audit
|
||||||
|
WHERE player_id = @pid
|
||||||
|
ORDER BY recorded_at ASC, id ASC;
|
||||||
|
""";
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
|
||||||
|
cmd.Parameters.AddWithValue("pid", player);
|
||||||
|
if (limit is > 0)
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("lim", limit.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<ReputationDeltaRow>();
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
rows.Add(new ReputationDeltaRow(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetInt32(3),
|
||||||
|
reader.GetInt32(4),
|
||||||
|
reader.GetString(5),
|
||||||
|
reader.GetString(6),
|
||||||
|
reader.GetFieldValue<DateTimeOffset>(7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidRow(ReputationDeltaRow row) =>
|
||||||
|
row.Id.Trim().Length > 0 &&
|
||||||
|
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
|
||||||
|
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
|
||||||
|
row.SourceKind.Trim().Length > 0 &&
|
||||||
|
row.SourceId.Trim().Length > 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Append-only reputation delta audit row (NEO-135).</summary>
|
||||||
|
public sealed record ReputationDeltaRow(
|
||||||
|
string Id,
|
||||||
|
string PlayerId,
|
||||||
|
string FactionId,
|
||||||
|
int DeltaAmount,
|
||||||
|
int ResultingStanding,
|
||||||
|
string SourceKind,
|
||||||
|
string SourceId,
|
||||||
|
DateTimeOffset RecordedAt);
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
|
/// <summary>Stable source-kind values for <see cref="ReputationDeltaRow"/> (NEO-135).</summary>
|
||||||
|
public static class ReputationDeltaSourceKinds
|
||||||
|
{
|
||||||
|
public const string QuestCompletion = "quest_completion";
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
||||||
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
||||||
builder.Services.AddRewardDeliveryStore();
|
builder.Services.AddRewardDeliveryStore();
|
||||||
|
builder.Services.AddFactionStandingStores(builder.Configuration);
|
||||||
builder.Services.AddThreatStateStore();
|
builder.Services.AddThreatStateStore();
|
||||||
builder.Services.AddNpcRuntimeStateStore();
|
builder.Services.AddNpcRuntimeStateStore();
|
||||||
builder.Services.AddPlayerCombatHealthStore();
|
builder.Services.AddPlayerCombatHealthStore();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- NEO-135: per-player faction standing snapshots.
|
||||||
|
CREATE TABLE IF NOT EXISTS player_faction_standing (
|
||||||
|
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
|
||||||
|
faction_id TEXT NOT NULL,
|
||||||
|
standing INTEGER NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (player_id, faction_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE player_faction_standing IS 'Persisted faction standing per player (NEO-135); missing row means neutral 0 at read.';
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- NEO-135: append-only reputation delta audit log.
|
||||||
|
CREATE TABLE IF NOT EXISTS reputation_delta_audit (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
|
||||||
|
faction_id TEXT NOT NULL,
|
||||||
|
delta_amount INTEGER NOT NULL,
|
||||||
|
resulting_standing INTEGER NOT NULL,
|
||||||
|
source_kind TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reputation_delta_audit_player_recorded
|
||||||
|
ON reputation_delta_audit (player_id, recorded_at, id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE reputation_delta_audit IS 'Append-only faction reputation delta audit (NEO-135).';
|
||||||
Loading…
Reference in New Issue