From ae641a4dd0d004def60d9291baea982e96ee24db Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 24 Jun 2026 20:27:32 -0400 Subject: [PATCH] NEO-146: add contract instance and outcome stores with Postgres split Durable per-player contract instances (one active) and append-only outcome audit rows, mirroring NEO-116/135 store patterns with V011/V012 migrations, AAA tests, README docs, and Bruno health smoke. --- ...th after contract instance stores load.bru | 25 ++ .../contract-stores/folder.bru | 3 + .../modules/E7_M4_ContractMissionGenerator.md | 2 + docs/plans/NEO-146-implementation-plan.md | 14 +- ...ractInstancePersistenceIntegrationTests.cs | 132 ++++++ ...tractOutcomePersistenceIntegrationTests.cs | 120 ++++++ .../InMemoryContractInstanceStoreTests.cs | 231 +++++++++++ .../InMemoryContractOutcomeStoreTests.cs | 87 ++++ .../InMemoryWebApplicationFactory.cs | 4 + .../Game/Contracts/ContractInstanceIds.cs | 43 ++ .../Contracts/ContractInstanceReasonCodes.cs | 15 + ...ractInstanceServiceCollectionExtensions.cs | 25 ++ .../Game/Contracts/ContractInstanceState.cs | 26 ++ .../Game/Contracts/ContractInstanceStatus.cs | 9 + .../Game/Contracts/ContractOutcomeIds.cs | 18 + .../Game/Contracts/ContractOutcomeRow.cs | 14 + .../Game/Contracts/IContractInstanceStore.cs | 39 ++ .../Game/Contracts/IContractOutcomeStore.cs | 16 + .../InMemoryContractInstanceStore.cs | 250 ++++++++++++ .../Contracts/InMemoryContractOutcomeStore.cs | 133 +++++++ .../PostgresContractInstanceBootstrap.cs | 39 ++ .../PostgresContractInstanceStore.cs | 375 ++++++++++++++++++ .../PostgresContractOutcomeBootstrap.cs | 39 ++ .../Contracts/PostgresContractOutcomeStore.cs | 203 ++++++++++ server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 30 ++ .../db/migrations/V011__contract_instance.sql | 17 + .../db/migrations/V012__contract_outcome.sql | 17 + 28 files changed, 1924 insertions(+), 3 deletions(-) create mode 100644 bruno/neon-sprawl-server/contract-stores/Health after contract instance stores load.bru create mode 100644 bruno/neon-sprawl-server/contract-stores/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractOutcomeStoreTests.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractInstanceReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractInstanceServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractInstanceState.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractInstanceStatus.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractOutcomeIds.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractOutcomeRow.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/IContractOutcomeStore.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStore.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeStore.cs create mode 100644 server/db/migrations/V011__contract_instance.sql create mode 100644 server/db/migrations/V012__contract_outcome.sql diff --git a/bruno/neon-sprawl-server/contract-stores/Health after contract instance stores load.bru b/bruno/neon-sprawl-server/contract-stores/Health after contract instance stores load.bru new file mode 100644 index 0000000..54f94c5 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-stores/Health after contract instance stores load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (contract instance stores NEO-146) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-146 registers IContractInstanceStore and IContractOutcomeStore (in-memory or Postgres when configured). No contract HTTP API in this story — use this request to confirm the host started after store DI wiring. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/contract-stores/folder.bru b/bruno/neon-sprawl-server/contract-stores/folder.bru new file mode 100644 index 0000000..550f2d4 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-stores/folder.bru @@ -0,0 +1,3 @@ +meta { + name: contract-stores +} diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index aafbd40..61e8ff4 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -55,6 +55,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Server load (NEO-145):** host fail-fast load of `content/contracts/*_contract_templates.json` via `ContractTemplateCatalogLoader` + **`IContractTemplateRegistry`** — same E7M4 gates as CI at startup; [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). +**Runtime stores (NEO-146):** per-player **`IContractInstanceStore`** (one active instance) + append-only **`IContractOutcomeStore`**; [server README — Contract instance store](../../../server/README.md#contract-instance-store-neo-146). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/plans/NEO-146-implementation-plan.md b/docs/plans/NEO-146-implementation-plan.md index 40caa28..8bf5213 100644 --- a/docs/plans/NEO-146-implementation-plan.md +++ b/docs/plans/NEO-146-implementation-plan.md @@ -59,9 +59,17 @@ Durable per-player **`ContractInstance`** rows (active/completed) and append-onl ## Acceptance criteria checklist -- [ ] At most one **`active`** instance per player (prototype policy). -- [ ] Completed instances immutable; outcome append-only. -- [ ] `dotnet test` covers store gates. +- [x] At most one **`active`** instance per player (prototype policy). +- [x] Completed instances immutable; outcome append-only. +- [x] `dotnet test` covers store gates. + +## Implementation reconciliation (shipped) + +- **Types:** `ContractInstanceStatus`, `ContractInstanceState`, `ContractInstanceIds`, `ContractInstanceReasonCodes`, `ContractOutcomeRow`, `ContractOutcomeIds`. +- **Stores:** `IContractInstanceStore` / `IContractOutcomeStore`; in-memory + Postgres (`V011`, `V012`) when connection string set; partial unique index + app-level one-active enforcement. +- **DI:** `AddContractInstanceStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`. +- **Tests:** `InMemoryContractInstanceStoreTests`, `InMemoryContractOutcomeStoreTests`, Postgres persistence integration tests; host DI smoke (`862` tests green). +- **Docs:** `server/README.md` contract instance + outcome store sections. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs new file mode 100644 index 0000000..4fa4f9e --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +[Collection("Postgres integration")] +public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + private const string PlayerId = "dev-local-1"; + private const string InstanceId = "prototype_contract_instance_pg_001"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string SeedBucket = "2026-06-22"; + private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 14, 0, 0, TimeSpan.Zero); + + [RequirePostgresFact] + public async Task TryCreateActive_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently() + { + // Arrange + await ResetContractInstanceTableAsync(); + const int concurrentCalls = 8; + + // Act + var results = await Task.WhenAll( + Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() => + { + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + return store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out ContractInstanceState _); + }))); + + // Assert + Assert.Equal(1, results.Count(static r => r)); + Assert.Equal(concurrentCalls - 1, results.Count(static r => !r)); + Assert.True( + Factory.Services.CreateScope().ServiceProvider + .GetRequiredService() + .TryGet(PlayerId, InstanceId, out var snapshot)); + Assert.Equal(ContractInstanceStatus.Active, snapshot.Status); + Assert.Equal(TemplateId, snapshot.TemplateId); + } + + [RequirePostgresFact] + public async Task CreateComplete_ShouldPersistAcrossNewFactory() + { + // Arrange + await ResetContractInstanceTableAsync(); + + // Act — write through first host + using (var firstScope = Factory.Services.CreateScope()) + { + var store = firstScope.ServiceProvider.GetRequiredService(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _)); + } + + ContractInstanceState readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var secondScope = secondFactory.Services.CreateScope(); + var store = secondScope.ServiceProvider.GetRequiredService(); + readBack = store.TryGet(PlayerId, InstanceId, out var snapshot) + ? snapshot + : null!; + } + + // Assert + Assert.NotNull(readBack); + Assert.Equal(ContractInstanceStatus.Completed, readBack.Status); + Assert.Equal(SeedBucket, readBack.SeedBucket); + Assert.Equal(CompletedAt, readBack.CompletedAt); + } + + private async Task ResetContractInstanceTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; + + var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + var instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql"); + if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath)) + { + throw new FileNotFoundException("Test DDL for contract instance persistence not found."); + } + + var positionDdl = await File.ReadAllTextAsync(positionDdlPath); + var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath); + 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 applyInstance = new NpgsqlCommand(instanceDdl, conn)) + { + await applyInstance.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceIntegrationTests.cs new file mode 100644 index 0000000..3fd1bae --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceIntegrationTests.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +[Collection("Postgres integration")] +public sealed class ContractOutcomePersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + private const string PlayerId = "dev-local-1"; + private const string InstanceId = "prototype_contract_instance_pg_outcome_001"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string SeedBucket = "2026-06-22"; + private const string OutcomeRowId = "outcome-row-pg-001"; + private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset RecordedAt = new(2026, 6, 22, 14, 0, 0, TimeSpan.Zero); + + [RequirePostgresFact] + public async Task AppendOutcome_ShouldPersistAcrossNewFactory() + { + // Arrange + await ResetContractTablesAsync(); + var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId); + var outcomeRow = new ContractOutcomeRow( + OutcomeRowId, + InstanceId, + PlayerId, + idempotencyKey, + [new RewardItemGrantApplied("scrap_metal_bulk", 5)], + [new RewardSkillXpGrantApplied("salvage", 15)], + [], + RecordedAt); + + // Act — write through first host + using (var firstScope = Factory.Services.CreateScope()) + { + var instanceStore = firstScope.ServiceProvider.GetRequiredService(); + var outcomeStore = firstScope.ServiceProvider.GetRequiredService(); + Assert.True(instanceStore.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + Assert.True(outcomeStore.TryAppend(outcomeRow)); + } + + ContractOutcomeRow readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var secondScope = secondFactory.Services.CreateScope(); + var outcomeStore = secondScope.ServiceProvider.GetRequiredService(); + readBack = outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var row) + ? row + : null!; + } + + // Assert + Assert.NotNull(readBack); + Assert.Equal(OutcomeRowId, readBack.Id); + Assert.Equal(InstanceId, readBack.ContractInstanceId); + Assert.Equal(5, readBack.GrantedItems[0].Quantity); + Assert.Equal(15, readBack.GrantedSkillXp[0].Amount); + } + + private async Task ResetContractTablesAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; + + var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + var instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql"); + var outcomeDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V012__contract_outcome.sql"); + if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath) || !File.Exists(outcomeDdlPath)) + { + throw new FileNotFoundException("Test DDL for contract outcome persistence not found."); + } + + var positionDdl = await File.ReadAllTextAsync(positionDdlPath); + var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath); + var outcomeDdl = await File.ReadAllTextAsync(outcomeDdlPath); + 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 applyInstance = new NpgsqlCommand(instanceDdl, conn)) + { + await applyInstance.ExecuteNonQueryAsync(); + } + + await using (var applyOutcome = new NpgsqlCommand(outcomeDdl, conn)) + { + await applyOutcome.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs new file mode 100644 index 0000000..eaae754 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs @@ -0,0 +1,231 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class InMemoryContractInstanceStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string UnknownPlayerId = "unknown-player-xyz"; + private const string InstanceId = "prototype_contract_instance_001"; + private const string SecondInstanceId = "prototype_contract_instance_002"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string SeedBucket = "2026-06-22"; + private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryGet_ShouldReturnFalse_WhenRowMissing() + { + // Arrange + var store = CreateStore(); + // Act + var found = store.TryGet(PlayerId, InstanceId, out _); + // Assert + Assert.False(found); + } + + [Fact] + public void TryCreateActive_ShouldPersistAllFields() + { + // Arrange + var store = CreateStore(); + // Act + var created = store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out var snapshot); + // Assert + Assert.True(created); + Assert.Equal(InstanceId, snapshot.ContractInstanceId); + Assert.Equal(TemplateId, snapshot.TemplateId); + Assert.Equal(PlayerId, snapshot.PlayerId); + Assert.Equal(ContractInstanceStatus.Active, snapshot.Status); + Assert.Equal(SeedBucket, snapshot.SeedBucket); + Assert.Equal(IssuedAt, snapshot.IssuedAt); + Assert.Null(snapshot.CompletedAt); + } + + [Fact] + public void TryCreateActive_ShouldDenySecondActiveForSamePlayer() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + // Act + var second = store.TryCreateActive( + PlayerId, + SecondInstanceId, + TemplateId, + SeedBucket, + IssuedAt.AddHours(1), + out var snapshot); + // Assert + Assert.False(second); + Assert.Equal(InstanceId, snapshot.ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, snapshot.Status); + Assert.True(store.TryGetActiveForPlayer(PlayerId, out var active)); + Assert.Equal(InstanceId, active.ContractInstanceId); + } + + [Fact] + public void TryMarkComplete_ShouldSetCompletedStatusAndTimestamp() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + // Act + var completed = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out var snapshot); + // Assert + Assert.True(completed); + Assert.Equal(ContractInstanceStatus.Completed, snapshot.Status); + Assert.Equal(CompletedAt, snapshot.CompletedAt); + Assert.False(store.TryGetActiveForPlayer(PlayerId, out _)); + } + + [Fact] + public void TryMarkComplete_ShouldBeIdempotent() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + // Act + var first = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out var firstSnapshot); + var second = store.TryMarkComplete( + PlayerId, + InstanceId, + CompletedAt.AddHours(1), + out var secondSnapshot); + // Assert + Assert.True(first); + Assert.False(second); + Assert.Equal(ContractInstanceStatus.Completed, secondSnapshot.Status); + Assert.Equal(CompletedAt, secondSnapshot.CompletedAt); + Assert.Equal(firstSnapshot.TemplateId, secondSnapshot.TemplateId); + } + + [Fact] + public void TryCreateActive_ShouldAllowNewInstanceAfterComplete() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _)); + // Act + var reissued = store.TryCreateActive( + PlayerId, + SecondInstanceId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(1), + out var snapshot); + // Assert + Assert.True(reissued); + Assert.Equal(SecondInstanceId, snapshot.ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, snapshot.Status); + } + + [Fact] + public void TryCreateActive_ShouldReturnFalse_ForUnknownPlayer() + { + // Arrange + var store = CreateStore(); + // Act + var created = store.TryCreateActive( + UnknownPlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _); + // Assert + Assert.False(created); + } + + [Theory] + [InlineData("", InstanceId, TemplateId, SeedBucket)] + [InlineData(" ", InstanceId, TemplateId, SeedBucket)] + [InlineData(PlayerId, "", TemplateId, SeedBucket)] + [InlineData(PlayerId, " ", TemplateId, SeedBucket)] + [InlineData(PlayerId, InstanceId, "", SeedBucket)] + [InlineData(PlayerId, InstanceId, TemplateId, "")] + public void TryCreateActive_ShouldReturnFalse_WhenRequiredIdsEmpty( + string playerId, + string instanceId, + string templateId, + string seedBucket) + { + // Arrange + var store = CreateStore(); + // Act + var created = store.TryCreateActive( + playerId, + instanceId, + templateId, + seedBucket, + IssuedAt, + out _); + // Assert + Assert.False(created); + } + + [Fact] + public async Task Host_ShouldResolveContractInstanceStoresFromDi() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var scope = factory.Services.CreateScope(); + var instanceStore = scope.ServiceProvider.GetRequiredService(); + var outcomeStore = scope.ServiceProvider.GetRequiredService(); + // Act + var created = instanceStore.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out var snapshot); + // Assert + Assert.IsType(instanceStore); + Assert.IsType(outcomeStore); + Assert.True(created); + Assert.Equal(TemplateId, snapshot.TemplateId); + } + + private static InMemoryContractInstanceStore CreateStore() + { + var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); + return new InMemoryContractInstanceStore(options); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractOutcomeStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractOutcomeStoreTests.cs new file mode 100644 index 0000000..73f0787 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractOutcomeStoreTests.cs @@ -0,0 +1,87 @@ +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Rewards; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class InMemoryContractOutcomeStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string InstanceId = "prototype_contract_instance_001"; + private const string OutcomeRowId = "outcome-row-001"; + private static readonly DateTimeOffset RecordedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryAppend_ShouldPersistAndQueryByInstanceAndIdempotencyKey() + { + // Arrange + var store = new InMemoryContractOutcomeStore(); + var row = CreatePrototypeOutcome(); + // Act + var appended = store.TryAppend(row); + var foundByKey = store.TryGetByIdempotencyKey(row.IdempotencyKey, out var byKey); + var listed = store.GetOutcomesForInstance(InstanceId); + // Assert + Assert.True(appended); + Assert.True(foundByKey); + Assert.Equal(row.Id, byKey.Id); + Assert.Single(listed); + Assert.Equal("scrap_metal_bulk", listed[0].GrantedItems[0].ItemId); + Assert.Equal(5, listed[0].GrantedItems[0].Quantity); + Assert.Equal("salvage", listed[0].GrantedSkillXp[0].SkillId); + Assert.Equal(15, listed[0].GrantedSkillXp[0].Amount); + } + + [Fact] + public void TryAppend_ShouldDenyDuplicateRowId() + { + // Arrange + var store = new InMemoryContractOutcomeStore(); + var row = CreatePrototypeOutcome(); + Assert.True(store.TryAppend(row)); + // Act + var duplicateId = store.TryAppend(row with { IdempotencyKey = "other-key" }); + // Assert + Assert.False(duplicateId); + } + + [Fact] + public void TryAppend_ShouldDenyDuplicateIdempotencyKey() + { + // Arrange + var store = new InMemoryContractOutcomeStore(); + var row = CreatePrototypeOutcome(); + Assert.True(store.TryAppend(row)); + // Act + var duplicateKey = store.TryAppend(row with { Id = "outcome-row-002" }); + // Assert + Assert.False(duplicateKey); + } + + [Fact] + public void TryAppend_ShouldFailClosed_WhenRequiredFieldsEmpty() + { + // Arrange + var store = new InMemoryContractOutcomeStore(); + var invalidRow = CreatePrototypeOutcome() with { PlayerId = " " }; + // Act + var appended = store.TryAppend(invalidRow); + // Assert + Assert.False(appended); + Assert.Empty(store.GetOutcomesForInstance(InstanceId)); + } + + private static ContractOutcomeRow CreatePrototypeOutcome() + { + var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId); + return new ContractOutcomeRow( + OutcomeRowId, + InstanceId, + PlayerId, + idempotencyKey, + [new RewardItemGrantApplied("scrap_metal_bulk", 5)], + [new RewardSkillXpGrantApplied("salvage", 15)], + [], + RecordedAt); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index cd8fb67..e81dc80 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -96,6 +96,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs new file mode 100644 index 0000000..3317557 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs @@ -0,0 +1,43 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Id normalization for contract instance stores (NEO-146). +public static class ContractInstanceIds +{ + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizePlayerId(string? playerId) + { + var trimmed = playerId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } + + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeContractInstanceId(string? contractInstanceId) => NormalizePlayerId(contractInstanceId); + + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeTemplateId(string? templateId) => NormalizePlayerId(templateId); + + /// Trim; empty when input is null/whitespace (seed bucket casing preserved). + public static string NormalizeSeedBucket(string? seedBucket) + { + var trimmed = seedBucket?.Trim(); + return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed; + } + + /// Composite store key for + . + public static string MakeInstanceKey(string? playerId, string? contractInstanceId) + { + var p = NormalizePlayerId(playerId); + var i = NormalizeContractInstanceId(contractInstanceId); + if (p.Length == 0 || i.Length == 0) + { + return string.Empty; + } + + return $"{p}\0{i}"; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceReasonCodes.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceReasonCodes.cs new file mode 100644 index 0000000..899c686 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceReasonCodes.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Stable deny reason codes for contract instance operations (NEO-146; consumed by E7M4-04+ orchestrators). +public static class ContractInstanceReasonCodes +{ + public const string PlayerNotWritable = "player_not_writable"; + + public const string ActiveContractExists = "active_contract_exists"; + + public const string InstanceNotFound = "instance_not_found"; + + public const string InstanceNotActive = "instance_not_active"; + + public const string InvalidIds = "invalid_ids"; +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceServiceCollectionExtensions.cs new file mode 100644 index 0000000..a835f34 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Registers contract instance + outcome persistence (NEO-146). +public static class ContractInstanceServiceCollectionExtensions +{ + /// PostgreSQL when ConnectionStrings:NeonSprawl is set; otherwise in-memory fallback. + public static IServiceCollection AddContractInstanceStores(this IServiceCollection services, IConfiguration configuration) + { + var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceState.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceState.cs new file mode 100644 index 0000000..848b177 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceState.cs @@ -0,0 +1,26 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Immutable per-player contract instance snapshot (NEO-146). +public sealed class ContractInstanceState( + string contractInstanceId, + string templateId, + string playerId, + ContractInstanceStatus status, + string seedBucket, + DateTimeOffset issuedAt, + DateTimeOffset? completedAt) +{ + public string ContractInstanceId { get; } = contractInstanceId; + + public string TemplateId { get; } = templateId; + + public string PlayerId { get; } = playerId; + + public ContractInstanceStatus Status { get; } = status; + + public string SeedBucket { get; } = seedBucket; + + public DateTimeOffset IssuedAt { get; } = issuedAt; + + public DateTimeOffset? CompletedAt { get; } = completedAt; +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceStatus.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceStatus.cs new file mode 100644 index 0000000..f3e42e6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceStatus.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Contract instance lifecycle status (NEO-146). +public enum ContractInstanceStatus +{ + Active, + Completed, + Expired, +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractOutcomeIds.cs b/server/NeonSprawl.Server/Game/Contracts/ContractOutcomeIds.cs new file mode 100644 index 0000000..7f958c7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractOutcomeIds.cs @@ -0,0 +1,18 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Id normalization and idempotency keys for contract outcome audit rows (NEO-146). +public static class ContractOutcomeIds +{ + /// Stable idempotency key for contract completion delivery. + public static string MakeIdempotencyKey(string? playerId, string? contractInstanceId) + { + var p = ContractInstanceIds.NormalizePlayerId(playerId); + var i = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (p.Length == 0 || i.Length == 0) + { + return string.Empty; + } + + return $"{p}:contract_complete:{i}"; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractOutcomeRow.cs b/server/NeonSprawl.Server/Game/Contracts/ContractOutcomeRow.cs new file mode 100644 index 0000000..155cf22 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractOutcomeRow.cs @@ -0,0 +1,14 @@ +using NeonSprawl.Server.Game.Rewards; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Append-only contract completion outcome audit row (NEO-146). +public sealed record ContractOutcomeRow( + string Id, + string ContractInstanceId, + string PlayerId, + string IdempotencyKey, + IReadOnlyList GrantedItems, + IReadOnlyList GrantedSkillXp, + IReadOnlyList GrantedReputation, + DateTimeOffset RecordedAt); diff --git a/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs new file mode 100644 index 0000000..6bb48b6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Persisted per-player contract instances (NEO-146). +/// Contract generator/completion orchestrators (NEO-147, NEO-149) consume this interface — not HTTP directly. +/// +public interface IContractInstanceStore +{ + /// True when mutations for are allowed (dev bucket or Postgres player_position row). + bool CanWritePlayer(string playerId); + + /// Missing row ⇒ false. + bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot); + + /// At most one active row per player; missing active ⇒ false. + bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot); + + /// + /// Creates an active instance. Returns false when player not writable, ids invalid, duplicate instance id, + /// or player already has an active contract. + /// + bool TryCreateActive( + string playerId, + string contractInstanceId, + string templateId, + string seedBucket, + DateTimeOffset issuedAt, + out ContractInstanceState snapshot); + + /// First completion returns true; replays return false without changing . + bool TryMarkComplete( + string playerId, + string contractInstanceId, + DateTimeOffset completedAt, + out ContractInstanceState snapshot); + + /// Dev fixture only — removes one instance row; idempotent when absent. + bool TryClearInstance(string playerId, string contractInstanceId); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/IContractOutcomeStore.cs b/server/NeonSprawl.Server/Game/Contracts/IContractOutcomeStore.cs new file mode 100644 index 0000000..afbeb0a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/IContractOutcomeStore.cs @@ -0,0 +1,16 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Append-only contract completion outcome audit log (NEO-146). +public interface IContractOutcomeStore +{ + /// First append with a given row Id or IdempotencyKey returns true; duplicates return false. + bool TryAppend(ContractOutcomeRow row); + + bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row); + + /// Audit rows for one instance ordered by then Id ascending. + IReadOnlyList GetOutcomesForInstance(string contractInstanceId, int? limit = null); + + /// Dev fixture only — removes outcome rows for one instance; idempotent when none match. + bool TryClearForInstance(string contractInstanceId); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs new file mode 100644 index 0000000..8b90fb2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs @@ -0,0 +1,250 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Thread-safe in-memory contract instances; seeds the configured dev player (NEO-146). +public sealed class InMemoryContractInstanceStore(IOptions options) : IContractInstanceStore +{ + private sealed class InstanceRow( + string contractInstanceId, + string templateId, + string playerId, + ContractInstanceStatus status, + string seedBucket, + DateTimeOffset issuedAt, + DateTimeOffset? completedAt) + { + public string ContractInstanceId { get; } = contractInstanceId; + + public string TemplateId { get; } = templateId; + + public string PlayerId { get; } = playerId; + + public ContractInstanceStatus Status { get; private set; } = status; + + public string SeedBucket { get; } = seedBucket; + + public DateTimeOffset IssuedAt { get; } = issuedAt; + + public DateTimeOffset? CompletedAt { get; private set; } = completedAt; + + public ContractInstanceState ToSnapshot() => + new(ContractInstanceId, TemplateId, PlayerId, Status, SeedBucket, IssuedAt, CompletedAt); + + public void MarkCompleted(DateTimeOffset completedAt) + { + Status = ContractInstanceStatus.Completed; + CompletedAt = completedAt; + } + } + + private readonly HashSet knownPlayers = CreateKnownPlayers(options.Value); + + private readonly ConcurrentDictionary byInstanceId = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary activeInstanceByPlayer = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary instanceLocks = new(StringComparer.Ordinal); + + private static HashSet CreateKnownPlayers(GamePositionOptions o) + { + var id = ContractInstanceIds.NormalizePlayerId(o.DevPlayerId); + if (id.Length == 0) + { + throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); + } + + return new HashSet(StringComparer.OrdinalIgnoreCase) { id }; + } + + /// + public bool CanWritePlayer(string playerId) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + return player.Length > 0 && knownPlayers.Contains(player); + } + + /// + public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (player.Length == 0 || instanceId.Length == 0 || !knownPlayers.Contains(player)) + { + return false; + } + + lock (instanceLocks.GetOrAdd(instanceId, _ => new object())) + { + if (!byInstanceId.TryGetValue(instanceId, out var row) || + !string.Equals(row.PlayerId, player, StringComparison.Ordinal)) + { + return false; + } + + snapshot = row.ToSnapshot(); + return true; + } + } + + /// + public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0 || !knownPlayers.Contains(player)) + { + return false; + } + + if (!activeInstanceByPlayer.TryGetValue(player, out var instanceId)) + { + return false; + } + + lock (instanceLocks.GetOrAdd(instanceId, _ => new object())) + { + if (!byInstanceId.TryGetValue(instanceId, out var row) || + row.Status != ContractInstanceStatus.Active || + !string.Equals(row.PlayerId, player, StringComparison.Ordinal)) + { + activeInstanceByPlayer.TryRemove(player, out _); + return false; + } + + snapshot = row.ToSnapshot(); + return true; + } + } + + /// + public bool TryCreateActive( + string playerId, + string contractInstanceId, + string templateId, + string seedBucket, + DateTimeOffset issuedAt, + out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(templateId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(seedBucket); + if (player.Length == 0 || + instanceId.Length == 0 || + normalizedTemplateId.Length == 0 || + normalizedSeedBucket.Length == 0 || + !knownPlayers.Contains(player)) + { + return false; + } + + lock (instanceLocks.GetOrAdd(instanceId, _ => new object())) + { + if (activeInstanceByPlayer.TryGetValue(player, out var existingActiveId)) + { + if (byInstanceId.TryGetValue(existingActiveId, out var existingActive) && + existingActive.Status == ContractInstanceStatus.Active) + { + snapshot = existingActive.ToSnapshot(); + return false; + } + + activeInstanceByPlayer.TryRemove(player, out _); + } + + if (byInstanceId.TryGetValue(instanceId, out var existing)) + { + snapshot = existing.ToSnapshot(); + return false; + } + + var row = new InstanceRow( + instanceId, + normalizedTemplateId, + player, + ContractInstanceStatus.Active, + normalizedSeedBucket, + issuedAt, + null); + byInstanceId[instanceId] = row; + activeInstanceByPlayer[player] = instanceId; + snapshot = row.ToSnapshot(); + return true; + } + } + + /// + public bool TryMarkComplete( + string playerId, + string contractInstanceId, + DateTimeOffset completedAt, + out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (player.Length == 0 || instanceId.Length == 0 || !knownPlayers.Contains(player)) + { + return false; + } + + lock (instanceLocks.GetOrAdd(instanceId, _ => new object())) + { + if (!byInstanceId.TryGetValue(instanceId, out var row) || + !string.Equals(row.PlayerId, player, StringComparison.Ordinal)) + { + return false; + } + + if (row.Status == ContractInstanceStatus.Completed) + { + snapshot = row.ToSnapshot(); + return false; + } + + if (row.Status != ContractInstanceStatus.Active) + { + snapshot = row.ToSnapshot(); + return false; + } + + row.MarkCompleted(completedAt); + activeInstanceByPlayer.TryRemove(player, out _); + snapshot = row.ToSnapshot(); + return true; + } + } + + /// + public bool TryClearInstance(string playerId, string contractInstanceId) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (player.Length == 0 || instanceId.Length == 0 || !knownPlayers.Contains(player)) + { + return false; + } + + lock (instanceLocks.GetOrAdd(instanceId, _ => new object())) + { + if (!byInstanceId.TryGetValue(instanceId, out var row) || + !string.Equals(row.PlayerId, player, StringComparison.Ordinal)) + { + return false; + } + + _ = byInstanceId.TryRemove(instanceId, out _); + if (row.Status == ContractInstanceStatus.Active) + { + activeInstanceByPlayer.TryRemove(player, out _); + } + + return true; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStore.cs b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStore.cs new file mode 100644 index 0000000..842e2f1 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStore.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using NeonSprawl.Server.Game.Rewards; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Thread-safe in-memory append-only contract outcome audit log (NEO-146). +public sealed class InMemoryContractOutcomeStore : IContractOutcomeStore +{ + private readonly ConcurrentDictionary rowsById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary rowIdByIdempotencyKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public bool TryAppend(ContractOutcomeRow row) + { + if (!TryNormalizeRow(row, out var normalized)) + { + return false; + } + + lock (idLocks.GetOrAdd(normalized.Id, _ => new object())) + { + if (rowsById.ContainsKey(normalized.Id) || + rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey)) + { + return false; + } + + rowsById[normalized.Id] = normalized; + rowIdByIdempotencyKey[normalized.IdempotencyKey] = normalized.Id; + return true; + } + } + + /// + public bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row) + { + row = null!; + var key = idempotencyKey?.Trim(); + if (string.IsNullOrEmpty(key)) + { + return false; + } + + if (!rowIdByIdempotencyKey.TryGetValue(key, out var rowId) || + !rowsById.TryGetValue(rowId, out var stored)) + { + return false; + } + + row = stored; + return true; + } + + /// + public IReadOnlyList GetOutcomesForInstance(string contractInstanceId, int? limit = null) + { + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (instanceId.Length == 0) + { + return []; + } + + IEnumerable query = rowsById.Values + .Where(r => string.Equals(r.ContractInstanceId, instanceId, 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(); + } + + /// + public bool TryClearForInstance(string contractInstanceId) + { + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (instanceId.Length == 0) + { + return false; + } + + var idsToRemove = rowsById.Values + .Where(row => string.Equals(row.ContractInstanceId, instanceId, StringComparison.Ordinal)) + .Select(static row => row.Id) + .ToArray(); + + foreach (var id in idsToRemove) + { + if (rowsById.TryRemove(id, out var removed)) + { + rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _); + } + + idLocks.TryRemove(id, out _); + } + + return true; + } + + private static bool TryNormalizeRow(ContractOutcomeRow row, out ContractOutcomeRow normalized) + { + normalized = null!; + var id = row.Id.Trim(); + var playerId = ContractInstanceIds.NormalizePlayerId(row.PlayerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(row.ContractInstanceId); + var idempotencyKey = row.IdempotencyKey.Trim(); + if (id.Length == 0 || + playerId.Length == 0 || + instanceId.Length == 0 || + idempotencyKey.Length == 0) + { + return false; + } + + normalized = row with + { + Id = id, + PlayerId = playerId, + ContractInstanceId = instanceId, + IdempotencyKey = idempotencyKey, + GrantedItems = row.GrantedItems.ToArray(), + GrantedSkillXp = row.GrantedSkillXp.ToArray(), + GrantedReputation = row.GrantedReputation.ToArray(), + }; + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceBootstrap.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceBootstrap.cs new file mode 100644 index 0000000..c2e7de6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Applies NEO-146 contract instance table DDL once per process. +public static class PostgresContractInstanceBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V011__contract_instance.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-146 contract instance DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs new file mode 100644 index 0000000..99945fc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -0,0 +1,375 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// PostgreSQL-backed contract instances keyed by normalized contract instance id (NEO-146). +public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSource) : IContractInstanceStore +{ + /// + public bool CanWritePlayer(string playerId) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0) + { + return false; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + return PlayerExists(conn, player); + } + + /// + public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (player.Length == 0 || instanceId.Length == 0) + { + return false; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + if (!PlayerExists(conn, player)) + { + return false; + } + + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE contract_instance_id = @id AND player_id = @pid + LIMIT 1; + """, + conn); + cmd.Parameters.AddWithValue("id", instanceId); + cmd.Parameters.AddWithValue("pid", player); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + return false; + } + + snapshot = ReadSnapshot(reader); + return true; + } + + /// + public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0) + { + return false; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + if (!PlayerExists(conn, player)) + { + return false; + } + + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE player_id = @pid AND status = 'active' + LIMIT 1; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + return false; + } + + snapshot = ReadSnapshot(reader); + return snapshot.Status == ContractInstanceStatus.Active; + } + + /// + public bool TryCreateActive( + string playerId, + string contractInstanceId, + string templateId, + string seedBucket, + DateTimeOffset issuedAt, + out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(templateId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(seedBucket); + if (player.Length == 0 || + instanceId.Length == 0 || + normalizedTemplateId.Length == 0 || + normalizedSeedBucket.Length == 0) + { + return false; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, player, tx)) + { + tx.Rollback(); + return false; + } + + ContractInstanceState? activeSnapshot = null; + using (var activeSel = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE player_id = @pid AND status = 'active' + LIMIT 1 + FOR UPDATE; + """, + conn, + tx)) + { + activeSel.Parameters.AddWithValue("pid", player); + using var reader = activeSel.ExecuteReader(); + if (reader.Read()) + { + activeSnapshot = ReadSnapshot(reader); + } + } + + if (activeSnapshot is not null) + { + snapshot = activeSnapshot; + tx.Rollback(); + return false; + } + + using var insert = new Npgsql.NpgsqlCommand( + """ + INSERT INTO contract_instance ( + contract_instance_id, player_id, template_id, status, seed_bucket, issued_at, updated_at) + VALUES (@id, @pid, @tid, 'active', @seed, @issued, now()) + ON CONFLICT (contract_instance_id) DO NOTHING; + """, + conn, + tx); + insert.Parameters.AddWithValue("id", instanceId); + insert.Parameters.AddWithValue("pid", player); + insert.Parameters.AddWithValue("tid", normalizedTemplateId); + insert.Parameters.AddWithValue("seed", normalizedSeedBucket); + insert.Parameters.AddWithValue("issued", issuedAt); + try + { + if (insert.ExecuteNonQuery() > 0) + { + tx.Commit(); + snapshot = new ContractInstanceState( + instanceId, + normalizedTemplateId, + player, + ContractInstanceStatus.Active, + normalizedSeedBucket, + issuedAt, + null); + return true; + } + } + catch (Npgsql.PostgresException ex) when (ex.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation) + { + tx.Rollback(); + return TryReadDenySnapshot(player, instanceId, out snapshot); + } + + using (var existingSel = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE contract_instance_id = @id + LIMIT 1; + """, + conn, + tx)) + { + existingSel.Parameters.AddWithValue("id", instanceId); + using var reader = existingSel.ExecuteReader(); + if (reader.Read()) + { + snapshot = ReadSnapshot(reader); + } + } + + tx.Rollback(); + return false; + } + + private bool TryReadDenySnapshot(string player, string instanceId, out ContractInstanceState snapshot) + { + if (TryGet(player, instanceId, out snapshot)) + { + return false; + } + + if (TryGetActiveForPlayer(player, out snapshot)) + { + return false; + } + + snapshot = null!; + return false; + } + + /// + public bool TryMarkComplete( + string playerId, + string contractInstanceId, + DateTimeOffset completedAt, + out ContractInstanceState snapshot) + { + snapshot = null!; + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (player.Length == 0 || instanceId.Length == 0) + { + return false; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, player, tx)) + { + tx.Rollback(); + return false; + } + + ContractInstanceState current; + using (var sel = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE contract_instance_id = @id AND player_id = @pid + LIMIT 1 + FOR UPDATE; + """, + conn, + tx)) + { + sel.Parameters.AddWithValue("id", instanceId); + sel.Parameters.AddWithValue("pid", player); + using var reader = sel.ExecuteReader(); + if (!reader.Read()) + { + tx.Rollback(); + return false; + } + + current = ReadSnapshot(reader); + } + + if (current.Status == ContractInstanceStatus.Completed) + { + snapshot = current; + tx.Rollback(); + return false; + } + + if (current.Status != ContractInstanceStatus.Active) + { + snapshot = current; + tx.Rollback(); + return false; + } + + using (var update = new Npgsql.NpgsqlCommand( + """ + UPDATE contract_instance + SET status = 'completed', + completed_at = @completed, + updated_at = now() + WHERE contract_instance_id = @id AND player_id = @pid; + """, + conn, + tx)) + { + update.Parameters.AddWithValue("id", instanceId); + update.Parameters.AddWithValue("pid", player); + update.Parameters.AddWithValue("completed", completedAt); + update.ExecuteNonQuery(); + } + + tx.Commit(); + snapshot = new ContractInstanceState( + current.ContractInstanceId, + current.TemplateId, + current.PlayerId, + ContractInstanceStatus.Completed, + current.SeedBucket, + current.IssuedAt, + completedAt); + return true; + } + + /// + public bool TryClearInstance(string playerId, string contractInstanceId) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (player.Length == 0 || instanceId.Length == 0 || !CanWritePlayer(player)) + { + return false; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + DELETE FROM contract_instance + WHERE contract_instance_id = @id AND player_id = @pid; + """, + conn); + cmd.Parameters.AddWithValue("id", instanceId); + cmd.Parameters.AddWithValue("pid", player); + cmd.ExecuteNonQuery(); + return true; + } + + private static ContractInstanceState ReadSnapshot(Npgsql.NpgsqlDataReader reader) => + new( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + ParseStatus(reader.GetString(3)), + reader.GetString(4), + reader.GetFieldValue(5), + reader.IsDBNull(6) ? null : reader.GetFieldValue(6)); + + private static ContractInstanceStatus ParseStatus(string raw) => + raw switch + { + "active" => ContractInstanceStatus.Active, + "completed" => ContractInstanceStatus.Completed, + "expired" => ContractInstanceStatus.Expired, + _ => throw new InvalidOperationException($"Unknown contract instance status '{raw}'."), + }; + + private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized) => + PlayerExists(conn, playerIdNormalized, null); + + private static bool PlayerExists( + Npgsql.NpgsqlConnection conn, + string playerIdNormalized, + Npgsql.NpgsqlTransaction? tx) + { + 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeBootstrap.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeBootstrap.cs new file mode 100644 index 0000000..a85e846 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Applies NEO-146 contract outcome table DDL once per process. +public static class PostgresContractOutcomeBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V012__contract_outcome.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-146 contract outcome DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeStore.cs new file mode 100644 index 0000000..222c05c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeStore.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using NeonSprawl.Server.Game.Rewards; + +namespace NeonSprawl.Server.Game.Contracts; + +/// PostgreSQL-backed append-only contract outcome audit log (NEO-146). +public sealed class PostgresContractOutcomeStore(Npgsql.NpgsqlDataSource dataSource) : IContractOutcomeStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + public bool TryAppend(ContractOutcomeRow row) + { + if (!TryNormalizeRow(row, out var normalized)) + { + return false; + } + + PostgresContractOutcomeBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + INSERT INTO contract_outcome ( + id, contract_instance_id, player_id, idempotency_key, + granted_items, granted_skill_xp, granted_reputation, recorded_at) + SELECT @id, @instance_id, @pid, @key, @items::jsonb, @skill_xp::jsonb, @rep::jsonb, @recorded + WHERE NOT EXISTS ( + SELECT 1 FROM contract_outcome + WHERE id = @id OR idempotency_key = @key); + """, + conn); + cmd.Parameters.AddWithValue("id", normalized.Id); + cmd.Parameters.AddWithValue("instance_id", normalized.ContractInstanceId); + cmd.Parameters.AddWithValue("pid", normalized.PlayerId); + cmd.Parameters.AddWithValue("key", normalized.IdempotencyKey); + cmd.Parameters.AddWithValue("items", SerializeGrants(normalized.GrantedItems)); + cmd.Parameters.AddWithValue("skill_xp", SerializeGrants(normalized.GrantedSkillXp)); + cmd.Parameters.AddWithValue("rep", SerializeGrants(normalized.GrantedReputation)); + cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt); + return cmd.ExecuteNonQuery() == 1; + } + + /// + public bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row) + { + row = null!; + var key = idempotencyKey?.Trim(); + if (string.IsNullOrEmpty(key)) + { + return false; + } + + PostgresContractOutcomeBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT id, contract_instance_id, player_id, idempotency_key, + granted_items, granted_skill_xp, granted_reputation, recorded_at + FROM contract_outcome + WHERE idempotency_key = @key + LIMIT 1; + """, + conn); + cmd.Parameters.AddWithValue("key", key); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + return false; + } + + row = ReadRow(reader); + return true; + } + + /// + public IReadOnlyList GetOutcomesForInstance(string contractInstanceId, int? limit = null) + { + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (instanceId.Length == 0) + { + return []; + } + + PostgresContractOutcomeBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + var sql = limit is > 0 + ? """ + SELECT id, contract_instance_id, player_id, idempotency_key, + granted_items, granted_skill_xp, granted_reputation, recorded_at + FROM contract_outcome + WHERE contract_instance_id = @instance_id + ORDER BY recorded_at ASC, id ASC + LIMIT @lim; + """ + : """ + SELECT id, contract_instance_id, player_id, idempotency_key, + granted_items, granted_skill_xp, granted_reputation, recorded_at + FROM contract_outcome + WHERE contract_instance_id = @instance_id + ORDER BY recorded_at ASC, id ASC; + """; + using var cmd = new Npgsql.NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("instance_id", instanceId); + if (limit is > 0) + { + cmd.Parameters.AddWithValue("lim", limit.Value); + } + + var rows = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(ReadRow(reader)); + } + + return rows; + } + + /// + public bool TryClearForInstance(string contractInstanceId) + { + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + if (instanceId.Length == 0) + { + return false; + } + + PostgresContractOutcomeBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + DELETE FROM contract_outcome + WHERE contract_instance_id = @instance_id; + """, + conn); + cmd.Parameters.AddWithValue("instance_id", instanceId); + cmd.ExecuteNonQuery(); + return true; + } + + private static ContractOutcomeRow ReadRow(Npgsql.NpgsqlDataReader reader) => + new( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + DeserializeItems(reader.GetFieldValue(4)), + DeserializeSkillXp(reader.GetFieldValue(5)), + DeserializeReputation(reader.GetFieldValue(6)), + reader.GetFieldValue(7)); + + private static string SerializeGrants(IReadOnlyList grants) => + JsonSerializer.Serialize(grants, JsonOptions); + + private static IReadOnlyList DeserializeItems(string json) + { + var parsed = JsonSerializer.Deserialize(json, JsonOptions); + return parsed ?? []; + } + + private static IReadOnlyList DeserializeSkillXp(string json) + { + var parsed = JsonSerializer.Deserialize(json, JsonOptions); + return parsed ?? []; + } + + private static IReadOnlyList DeserializeReputation(string json) + { + var parsed = JsonSerializer.Deserialize(json, JsonOptions); + return parsed ?? []; + } + + private static bool TryNormalizeRow(ContractOutcomeRow row, out ContractOutcomeRow normalized) + { + normalized = null!; + var id = row.Id.Trim(); + var playerId = ContractInstanceIds.NormalizePlayerId(row.PlayerId); + var instanceId = ContractInstanceIds.NormalizeContractInstanceId(row.ContractInstanceId); + var idempotencyKey = row.IdempotencyKey.Trim(); + if (id.Length == 0 || + playerId.Length == 0 || + instanceId.Length == 0 || + idempotencyKey.Length == 0) + { + return false; + } + + normalized = row with + { + Id = id, + PlayerId = playerId, + ContractInstanceId = instanceId, + IdempotencyKey = idempotencyKey, + GrantedItems = row.GrantedItems.ToArray(), + GrantedSkillXp = row.GrantedSkillXp.ToArray(), + GrantedReputation = row.GrantedReputation.ToArray(), + }; + return true; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 0f6a688..c37a3f8 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddContractTemplateCatalog(builder.Configuration); +builder.Services.AddContractInstanceStores(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddRewardDeliveryStore(); builder.Services.AddFactionStandingStores(builder.Configuration); diff --git a/server/README.md b/server/README.md index f608cd1..c4e6282 100644 --- a/server/README.md +++ b/server/README.md @@ -269,6 +269,36 @@ Bundle- and gate-related schemas resolve as siblings under **`{parent of contrac On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code. +## Contract instance store (NEO-146) + +Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed by **`contractInstanceId`** with at most **one active** row per player (prototype policy). Missing row ⇒ no instance; **`TryCreateActive`** creates an **`active`** row with template id, seed bucket, and issue timestamp. **`ContractGeneratorOperations`** (NEO-147) and **`ContractCompletionOperations`** (NEO-149) should call the store through orchestrators — not HTTP directly. + +**Store interface methods:** + +- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`). +- **`TryGet`** — read one instance by player + instance id; false when absent. +- **`TryGetActiveForPlayer`** — read the player's single active instance, if any. +- **`TryCreateActive`** — first active for player returns `true`; denies when another active exists, duplicate instance id, or player not writable. +- **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**. +- **`TryClearInstance`** — dev fixture only; removes one instance row. + +Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation against **`IContractTemplateRegistry`** is deferred to NEO-147. + +**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresContractInstanceStore`** persists to **`contract_instance`** ([`V011__contract_instance.sql`](../db/migrations/V011__contract_instance.sql)) with a partial unique index enforcing one active row per player. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md). + +## Contract outcome store (NEO-146) + +Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**. + +**Store interface methods:** + +- **`TryAppend`** — first row id or idempotency key returns `true`; duplicates return `false`. +- **`TryGetByIdempotencyKey`** — read one outcome for delivery replay / audit tests. +- **`GetOutcomesForInstance`** — list outcomes for one contract instance id (ordered by recorded time). +- **`TryClearForInstance`** — dev fixture only. + +**Storage:** in-memory when Postgres is unset; **`PostgresContractOutcomeStore`** appends to **`contract_outcome`** ([`V012__contract_outcome.sql`](../db/migrations/V012__contract_outcome.sql)) with JSONB grant snapshots when configured. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md). + ## Quest definitions (NEO-115) **`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`. diff --git a/server/db/migrations/V011__contract_instance.sql b/server/db/migrations/V011__contract_instance.sql new file mode 100644 index 0000000..b00c03e --- /dev/null +++ b/server/db/migrations/V011__contract_instance.sql @@ -0,0 +1,17 @@ +-- NEO-146: per-player contract instance rows (prototype E7.M4 Slice 4). +CREATE TABLE IF NOT EXISTS contract_instance ( + contract_instance_id TEXT NOT NULL PRIMARY KEY, + player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE, + template_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'expired')), + seed_bucket TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_instance_one_active_per_player + ON contract_instance (player_id) + WHERE status = 'active'; + +COMMENT ON TABLE contract_instance IS 'Persisted contract instances per player (NEO-146); at most one active row per player.'; diff --git a/server/db/migrations/V012__contract_outcome.sql b/server/db/migrations/V012__contract_outcome.sql new file mode 100644 index 0000000..b3db53e --- /dev/null +++ b/server/db/migrations/V012__contract_outcome.sql @@ -0,0 +1,17 @@ +-- NEO-146: append-only contract completion outcome audit log. +CREATE TABLE IF NOT EXISTS contract_outcome ( + id TEXT NOT NULL PRIMARY KEY, + contract_instance_id TEXT NOT NULL REFERENCES contract_instance(contract_instance_id) ON DELETE CASCADE, + player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL UNIQUE, + granted_items JSONB NOT NULL DEFAULT '[]'::jsonb, + granted_skill_xp JSONB NOT NULL DEFAULT '[]'::jsonb, + granted_reputation JSONB NOT NULL DEFAULT '[]'::jsonb, + recorded_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_contract_outcome_instance_recorded + ON contract_outcome (contract_instance_id, recorded_at, id); + +COMMENT ON TABLE contract_outcome IS 'Append-only contract completion outcome audit (NEO-146).';