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.
pull/187/head
VinPropane 2026-06-24 20:27:32 -04:00
parent 82c45941ae
commit ae641a4dd0
28 changed files with 1924 additions and 3 deletions

View File

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

View File

@ -0,0 +1,3 @@
meta {
name: contract-stores
}

View File

@ -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). **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 ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

View File

@ -59,9 +59,17 @@ Durable per-player **`ContractInstance`** rows (active/completed) and append-onl
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] At most one **`active`** instance per player (prototype policy). - [x] At most one **`active`** instance per player (prototype policy).
- [ ] Completed instances immutable; outcome append-only. - [x] Completed instances immutable; outcome append-only.
- [ ] `dotnet test` covers store gates. - [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 ## Technical approach

View File

@ -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<IContractInstanceStore>();
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<IContractInstanceStore>()
.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<IContractInstanceStore>();
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<IContractInstanceStore>();
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<IOptions<GamePositionOptions>>().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();
}
}
}

View File

@ -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<IContractInstanceStore>();
var outcomeStore = firstScope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
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<IContractOutcomeStore>();
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<IOptions<GamePositionOptions>>().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();
}
}
}

View File

@ -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<IContractInstanceStore>();
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
// Act
var created = instanceStore.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out var snapshot);
// Assert
Assert.IsType<InMemoryContractInstanceStore>(instanceStore);
Assert.IsType<InMemoryContractOutcomeStore>(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);
}
}

View File

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

View File

@ -96,6 +96,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(IContractInstanceStore) ||
d.ServiceType == typeof(IContractOutcomeStore) ||
d.ServiceType == typeof(IFactionStandingStore) || d.ServiceType == typeof(IFactionStandingStore) ||
d.ServiceType == typeof(IReputationDeltaStore) || d.ServiceType == typeof(IReputationDeltaStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) || d.ServiceType == typeof(IPlayerInventoryStore) ||
@ -120,6 +122,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<IContractInstanceStore, InMemoryContractInstanceStore>();
services.AddSingleton<IContractOutcomeStore, InMemoryContractOutcomeStore>();
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>(); services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>(); services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>(); services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();

View File

@ -0,0 +1,43 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Id normalization for contract instance stores (NEO-146).</summary>
public static class ContractInstanceIds
{
/// <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 + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeContractInstanceId(string? contractInstanceId) => NormalizePlayerId(contractInstanceId);
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeTemplateId(string? templateId) => NormalizePlayerId(templateId);
/// <summary>Trim; empty when input is null/whitespace (seed bucket casing preserved).</summary>
public static string NormalizeSeedBucket(string? seedBucket)
{
var trimmed = seedBucket?.Trim();
return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed;
}
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="contractInstanceId"/>.</summary>
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}";
}
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Stable deny reason codes for contract instance operations (NEO-146; consumed by E7M4-04+ orchestrators).</summary>
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";
}

View File

@ -0,0 +1,25 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Registers contract instance + outcome persistence (NEO-146).</summary>
public static class ContractInstanceServiceCollectionExtensions
{
/// <summary>PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set; otherwise in-memory fallback.</summary>
public static IServiceCollection AddContractInstanceStores(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IContractInstanceStore, PostgresContractInstanceStore>();
services.AddSingleton<IContractOutcomeStore, PostgresContractOutcomeStore>();
}
else
{
services.AddSingleton<IContractInstanceStore, InMemoryContractInstanceStore>();
services.AddSingleton<IContractOutcomeStore, InMemoryContractOutcomeStore>();
}
return services;
}
}

View File

@ -0,0 +1,26 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Immutable per-player contract instance snapshot (NEO-146).</summary>
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;
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Contract instance lifecycle status (NEO-146).</summary>
public enum ContractInstanceStatus
{
Active,
Completed,
Expired,
}

View File

@ -0,0 +1,18 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Id normalization and idempotency keys for contract outcome audit rows (NEO-146).</summary>
public static class ContractOutcomeIds
{
/// <summary>Stable idempotency key for contract completion delivery.</summary>
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}";
}
}

View File

@ -0,0 +1,14 @@
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Append-only contract completion outcome audit row (NEO-146).</summary>
public sealed record ContractOutcomeRow(
string Id,
string ContractInstanceId,
string PlayerId,
string IdempotencyKey,
IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
IReadOnlyList<RewardReputationGrantApplied> GrantedReputation,
DateTimeOffset RecordedAt);

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Persisted per-player contract instances (NEO-146).
/// Contract generator/completion orchestrators (NEO-147, NEO-149) consume this interface — not HTTP directly.
/// </summary>
public interface IContractInstanceStore
{
/// <summary>True when mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
bool CanWritePlayer(string playerId);
/// <summary>Missing row ⇒ <c>false</c>.</summary>
bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot);
/// <summary>At most one active row per player; missing active ⇒ <c>false</c>.</summary>
bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot);
/// <summary>
/// Creates an active instance. Returns <c>false</c> when player not writable, ids invalid, duplicate instance id,
/// or player already has an active contract.
/// </summary>
bool TryCreateActive(
string playerId,
string contractInstanceId,
string templateId,
string seedBucket,
DateTimeOffset issuedAt,
out ContractInstanceState snapshot);
/// <summary>First completion returns <c>true</c>; replays return <c>false</c> without changing <see cref="ContractInstanceState.CompletedAt"/>.</summary>
bool TryMarkComplete(
string playerId,
string contractInstanceId,
DateTimeOffset completedAt,
out ContractInstanceState snapshot);
/// <summary>Dev fixture only — removes one instance row; idempotent when absent.</summary>
bool TryClearInstance(string playerId, string contractInstanceId);
}

View File

@ -0,0 +1,16 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Append-only contract completion outcome audit log (NEO-146).</summary>
public interface IContractOutcomeStore
{
/// <summary>First append with a given row <c>Id</c> or <c>IdempotencyKey</c> returns <c>true</c>; duplicates return <c>false</c>.</summary>
bool TryAppend(ContractOutcomeRow row);
bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row);
/// <summary>Audit rows for one instance ordered by <see cref="ContractOutcomeRow.RecordedAt"/> then <c>Id</c> ascending.</summary>
IReadOnlyList<ContractOutcomeRow> GetOutcomesForInstance(string contractInstanceId, int? limit = null);
/// <summary>Dev fixture only — removes outcome rows for one instance; idempotent when none match.</summary>
bool TryClearForInstance(string contractInstanceId);
}

View File

@ -0,0 +1,250 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Thread-safe in-memory contract instances; seeds the configured dev player (NEO-146).</summary>
public sealed class InMemoryContractInstanceStore(IOptions<GamePositionOptions> 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<string> knownPlayers = CreateKnownPlayers(options.Value);
private readonly ConcurrentDictionary<string, InstanceRow> byInstanceId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> activeInstanceByPlayer = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> instanceLocks = new(StringComparer.Ordinal);
private static HashSet<string> 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<string>(StringComparer.OrdinalIgnoreCase) { id };
}
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = ContractInstanceIds.NormalizePlayerId(playerId);
return player.Length > 0 && knownPlayers.Contains(player);
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
}

View File

@ -0,0 +1,133 @@
using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Thread-safe in-memory append-only contract outcome audit log (NEO-146).</summary>
public sealed class InMemoryContractOutcomeStore : IContractOutcomeStore
{
private readonly ConcurrentDictionary<string, ContractOutcomeRow> rowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> rowIdByIdempotencyKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public IReadOnlyList<ContractOutcomeRow> GetOutcomesForInstance(string contractInstanceId, int? limit = null)
{
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (instanceId.Length == 0)
{
return [];
}
IEnumerable<ContractOutcomeRow> 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();
}
/// <inheritdoc />
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;
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Applies NEO-146 contract instance table DDL once per process.</summary>
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);
}
}
}

View File

@ -0,0 +1,375 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>PostgreSQL-backed contract instances keyed by normalized contract instance id (NEO-146).</summary>
public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSource) : IContractInstanceStore
{
/// <inheritdoc />
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);
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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<DateTimeOffset>(5),
reader.IsDBNull(6) ? null : reader.GetFieldValue<DateTimeOffset>(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;
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Applies NEO-146 contract outcome table DDL once per process.</summary>
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);
}
}
}

View File

@ -0,0 +1,203 @@
using System.Text.Json;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>PostgreSQL-backed append-only contract outcome audit log (NEO-146).</summary>
public sealed class PostgresContractOutcomeStore(Npgsql.NpgsqlDataSource dataSource) : IContractOutcomeStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public IReadOnlyList<ContractOutcomeRow> 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<ContractOutcomeRow>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(ReadRow(reader));
}
return rows;
}
/// <inheritdoc />
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<string>(4)),
DeserializeSkillXp(reader.GetFieldValue<string>(5)),
DeserializeReputation(reader.GetFieldValue<string>(6)),
reader.GetFieldValue<DateTimeOffset>(7));
private static string SerializeGrants<T>(IReadOnlyList<T> grants) =>
JsonSerializer.Serialize(grants, JsonOptions);
private static IReadOnlyList<RewardItemGrantApplied> DeserializeItems(string json)
{
var parsed = JsonSerializer.Deserialize<RewardItemGrantApplied[]>(json, JsonOptions);
return parsed ?? [];
}
private static IReadOnlyList<RewardSkillXpGrantApplied> DeserializeSkillXp(string json)
{
var parsed = JsonSerializer.Deserialize<RewardSkillXpGrantApplied[]>(json, JsonOptions);
return parsed ?? [];
}
private static IReadOnlyList<RewardReputationGrantApplied> DeserializeReputation(string json)
{
var parsed = JsonSerializer.Deserialize<RewardReputationGrantApplied[]>(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;
}
}

View File

@ -34,6 +34,7 @@ builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddContractTemplateCatalog(builder.Configuration); builder.Services.AddContractTemplateCatalog(builder.Configuration);
builder.Services.AddContractInstanceStores(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.AddFactionStandingStores(builder.Configuration);

View File

@ -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. 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) ## 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/`. **`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/`.

View File

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

View File

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