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
VinPropane 2026-06-15 20:23:57 -04:00
parent 08ebfcebcf
commit 4fca45bf7c
27 changed files with 1217 additions and 3 deletions

View File

@ -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");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: faction-standing
}

View File

@ -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

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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();
}
}
}

View File

@ -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>();

View File

@ -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);
}

View File

@ -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);

View File

@ -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);

View File

@ -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";
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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";
}

View File

@ -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();

View File

@ -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.

View File

@ -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.';

View File

@ -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).';