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
parent
82c45941ae
commit
ae641a4dd0
|
|
@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
meta {
|
||||||
|
name: contract-stores
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Contract instance lifecycle status (NEO-146).</summary>
|
||||||
|
public enum ContractInstanceStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Completed,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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/`.
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
@ -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).';
|
||||||
Loading…
Reference in New Issue