Merge pull request #187 from ViPro-Technologies/NEO-146-e7m4-03-contract-instance-outcome-stores
NEO-146: E7M4-03 contract instance and outcome storespull/189/head
commit
29bc2423b5
|
|
@ -54,7 +54,73 @@ if (string.IsNullOrEmpty(key))
|
|||
- **File-scoped namespaces** (`namespace X;`) for new single-namespace files when the SDK/version allows.
|
||||
- **Pattern matching / nullability:** prefer modern C# features where they simplify code; honor **nullable reference type** annotations when the project enables them.
|
||||
|
||||
## Members
|
||||
## Collection expressions (IDE0305)
|
||||
|
||||
- Prefer **collection spread** (`[.. source]`) over **`.ToArray()`** / **`.ToList()`** when materializing a sequence into a new array or list for return values, record `with` copies, or local snapshots.
|
||||
- Give an **explicit target type** when the compiler cannot infer one (CS9176), e.g. `string[] ids = [.. query.Select(static x => x.Id)];`.
|
||||
- In **assertions**, avoid passing a bare spread directly to APIs with span overloads (e.g. `Assert.Equal`) — assign to a typed local first:
|
||||
|
||||
```csharp
|
||||
// Prefer
|
||||
return [.. query];
|
||||
GrantedItems = [.. row.GrantedItems],
|
||||
|
||||
// When type inference fails
|
||||
string[] expectedOrder = [.. registry.GetDefinitionsInIdOrder().Select(static d => d.Id)];
|
||||
string[] actualOrder = [.. body.Quests.Select(static row => row.QuestId)];
|
||||
Assert.Equal(expectedOrder, actualOrder);
|
||||
|
||||
// Avoid (ambiguous Assert overloads; unnecessary allocation helper)
|
||||
Assert.Equal(expectedOrder, [.. body.Quests.Select(static row => row.QuestId)]);
|
||||
return query.ToArray();
|
||||
```
|
||||
|
||||
## `using` / `await using` (IDE0063)
|
||||
|
||||
- When a **`using` or `await using` block contains exactly one statement**, prefer the declaration form without braces: `await using var conn = …;` then the single statement on following lines **only if** it is still logically one resource scope (typical for `NpgsqlConnection` + one command in integration-test reset helpers).
|
||||
- **Keep braced `using (var …) { … }`** when the block has **multiple statements** (Arrange + Act + Assert inside one scope, seed loops, or any sequence that shares locals).
|
||||
- **Do not flatten** multi-statement blocks into `using var` at method scope when inner scopes reuse names like `store`, `registry`, or `scope` — that causes **CS0136** name clashes. Use distinct names (`seedStore`, `actScope`, `readStore`) or keep the braced block.
|
||||
|
||||
## Local `const` (RCS1118)
|
||||
|
||||
- When a local is initialized from a **compile-time constant** (string literal, numeric literal, or `public const` field), declare it **`const`** instead of **`var`**.
|
||||
|
||||
```csharp
|
||||
const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
```
|
||||
|
||||
## Inline declarations (IDE0018)
|
||||
|
||||
- Prefer **inline initialization** over declare-then-assign when the analyzer suggests it, e.g. `var outcome = Operation(...)` and `TryGet(..., out var snapshot)` instead of separate upfront declarations.
|
||||
|
||||
```csharp
|
||||
// Prefer — single disposable, single follow-on statement
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Prefer — multiple statements in one scope
|
||||
using (var firstScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var store = firstScope.ServiceProvider.GetRequiredService<IExampleStore>();
|
||||
Assert.True(store.TryAppend(row));
|
||||
}
|
||||
|
||||
// Avoid — flattens to method scope and collides with a later `var store`
|
||||
using var firstScope = Factory.Services.CreateScope();
|
||||
var store = firstScope.ServiceProvider.GetRequiredService<IExampleStore>();
|
||||
Assert.True(store.TryAppend(row));
|
||||
// … later …
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IExampleStore>(); // CS0136
|
||||
```
|
||||
|
||||
## Postgres schema gates (IDE0330)
|
||||
|
||||
- In `*Bootstrap.cs` types, use **`System.Threading.Lock`** (not `object`) for static schema-initialization gates: `private static readonly Lock SchemaGate = new();`.
|
||||
|
||||
## Concrete return types (CA1859)
|
||||
|
||||
- When a private helper always returns a materialized array, prefer the **concrete array type** as the return type (e.g. `RewardItemGrantApplied[]`) instead of `IReadOnlyList<T>` if callers only need the array and analyzers suggest it.
|
||||
|
||||
|
||||
- Prefer **expression-bodied** members only when they stay one clear idea.
|
||||
- **LINQ:** favor readability over micro-chains; break complex queries across lines.
|
||||
|
|
@ -67,6 +133,16 @@ if (string.IsNullOrEmpty(key))
|
|||
## Documentation
|
||||
|
||||
- Use **`///` XML doc comments** on public APIs (types and members) when behavior or contracts are not obvious.
|
||||
- **Primary-constructor records:** include a **`<summary>`** on the type. Prefer **`<see cref="PropertyName"/>`** in the summary for positional parameters. Do **not** use **`<param>`** on **`record struct`** primary constructors — Roslynator reports false-positive **RCS1263** ([roslynator#1730](https://github.com/dotnet/roslynator/issues/1730)). **`record class`** / **`class`** primary constructors may use `<param>` when the analyzer accepts them.
|
||||
|
||||
## Null checks (IDE0270)
|
||||
|
||||
- Prefer **`?? throw`** over `if (x is null) { throw … }` when assigning a non-null local from a nullable expression.
|
||||
|
||||
```csharp
|
||||
var root = FindDockerComposeRoot()
|
||||
?? throw new InvalidOperationException("…");
|
||||
```
|
||||
|
||||
## Test project layout (`*.Tests`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ meta {
|
|||
docs {
|
||||
NEO-108: fresh player row before engagement — inactive with empty defeatedTargetIds.
|
||||
Pre-request uses combat-targets dev fixture (also clears encounter progress for dev-local-1).
|
||||
Non-empty defeatedTargetIds are returned in stable sorted order.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
|
|||
|
||||
**Server load (NEO-145):** host fail-fast load of `content/contracts/*_contract_templates.json` via `ContractTemplateCatalogLoader` + **`IContractTemplateRegistry`** — same E7M4 gates as CI at startup; [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145).
|
||||
|
||||
**Runtime stores (NEO-146):** per-player **`IContractInstanceStore`** (one active instance) + append-only **`IContractOutcomeStore`**; [server README — Contract instance store](../../../server/README.md#contract-instance-store-neo-146).
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -118,7 +118,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete.
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress |
|
||||
|
||||
**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
|
||||
**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); **E7M4-03 / NEO-146** contract instance + outcome stores landed ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
|
||||
|
||||
### Epic 8 — Social / Guild
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
# NEO-146 — E7M4-03: Contract instance + outcome stores
|
||||
|
||||
**Linear:** [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)
|
||||
**Branch:** `NEO-146-e7m4-03-contract-instance-outcome-stores`
|
||||
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-03**
|
||||
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
|
||||
**Pattern:** [NEO-116-implementation-plan.md](NEO-116-implementation-plan.md) (in-memory + Postgres when configured); [NEO-135-implementation-plan.md](NEO-135-implementation-plan.md) (dual-store split); [NEO-126-implementation-plan.md](NEO-126-implementation-plan.md) (grant snapshot on audit row)
|
||||
**Precursor:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) **`Done`** — `IContractTemplateRegistry`, fail-fast contract catalog load (**on `main`**)
|
||||
**Blocks:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) (E7M4-04 `ContractGeneratorOperations.TryIssue`)
|
||||
**Client counterpart:** none (infrastructure-only); playable loop is **NEO-154** (E7M4-11)
|
||||
|
||||
## Goal
|
||||
|
||||
Durable per-player **`ContractInstance`** rows (active/completed) and append-only **`ContractOutcome`** audit records. Stores expose mutation primitives for issue/complete flows; generator, payout apply, and HTTP land in later stories.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No blocking decisions needed.** Linear AC, [E7M4-03 backlog scope](E7M4-pre-production-backlog.md#e7m4-03--contract-instance--outcome-stores), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and NEO-116/NEO-135/NEO-126 precedents settle:
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| Postgres persistence | **Include when configured** — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116/NEO-135 adopted same |
|
||||
| Store split | **`IContractInstanceStore`** + **`IContractOutcomeStore`** | Backlog in-scope list; snapshot vs append-only audit |
|
||||
| Store API shape | **Mutation methods on instance store** — `TryCreateActive`, `TryMarkComplete`; outcome store **`TryAppend`** + query | NEO-116 quest store precedent; backlog unit tests “issue, complete, idempotent complete replay” |
|
||||
| One active per player | **Enforce in `TryCreateActive`** — deny when player already has `active` row | Linear AC + kickoff “One active contract per player” |
|
||||
| `expired` status | **Enum value only** — no transition API in E7M4-03 | Backlog instance row lists status; no expiry story in Slice 4 backlog |
|
||||
| Missing instance | **Implicit absence** — `TryGet` returns `false`; no `not_started` row | NEO-116 missing-row pattern |
|
||||
| Player write gate | **`CanWritePlayer`** on instance store (dev bucket / Postgres `player_position`) | NEO-116 quest store precedent |
|
||||
| Template validation | **Store persists `templateId` string only** — registry checks deferred to E7M4-04 generator | NEO-116 does not validate quest ids at store boundary |
|
||||
| Outcome grant snapshot | **Reuse `RewardItemGrantApplied` / `RewardSkillXpGrantApplied` / `RewardReputationGrantApplied`** from `Game/Rewards/` | Contract completion bundle same grant kinds (E7M4-05); avoids duplicate DTOs |
|
||||
| Idempotency key on outcome | **`{playerId}:contract_complete:{contractInstanceId}`** stored on outcome row | E7M4 kickoff decisions table |
|
||||
| Generator / payout / HTTP | **Out of scope** | Backlog E7M4-03; NEO-147+ |
|
||||
| DI registration | **`AddContractInstanceStores`** extension separate from catalog | NEO-116 `AddPlayerQuestStateStore` precedent |
|
||||
|
||||
## Scope and out-of-scope
|
||||
|
||||
**In scope (from Linear + backlog):**
|
||||
|
||||
- **`ContractInstanceState`** snapshot + **`ContractInstanceStatus`** (`Active`, `Completed`, `Expired`).
|
||||
- **`IContractInstanceStore`**, **`IContractOutcomeStore`** — in-memory + Postgres when `ConnectionStrings:NeonSprawl` is set.
|
||||
- Instance fields: `contractInstanceId`, `templateId`, `playerId`, `status`, `seedBucket`, `issuedAt`, optional `completedAt`.
|
||||
- Outcome fields: instance id, delivery idempotency key, grant snapshot summary, `recordedAt`, row id.
|
||||
- **`ContractInstanceIds`** — player/instance id normalization + composite keys.
|
||||
- **`V011__contract_instance.sql`**, **`V012__contract_outcome.sql`** + bootstrap helpers when Postgres configured.
|
||||
- Unit tests (AAA): create active (issue primitive), deny second active, complete, idempotent complete replay, outcome append-only + duplicate deny.
|
||||
- Postgres persistence integration tests (`RequirePostgresFact`) for both stores.
|
||||
- `server/README.md` contract instance + outcome store sections.
|
||||
- DI wired from **`Program.cs`**; in-memory override in **`InMemoryWebApplicationFactory`**.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- **`ContractGeneratorOperations.TryIssue`** (NEO-147 / E7M4-04).
|
||||
- **`RewardRouterOperations.TryDeliverContractCompletion`** (NEO-148 / E7M4-05).
|
||||
- Encounter completion wiring (NEO-149 / E7M4-06).
|
||||
- Economy validation at issue (NEO-150 / E7M4-07).
|
||||
- HTTP issue/GET (NEO-151 / E7M4-08), telemetry hooks (NEO-152), Godot (NEO-153+).
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] At most one **`active`** instance per player (prototype policy).
|
||||
- [x] Completed instances immutable; outcome append-only.
|
||||
- [x] `dotnet test` covers store gates.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Types:** `ContractInstanceStatus`, `ContractInstanceState`, `ContractInstanceIds`, `ContractInstanceReasonCodes`, `ContractOutcomeRow`, `ContractOutcomeIds`.
|
||||
- **Stores:** `IContractInstanceStore` / `IContractOutcomeStore`; in-memory + Postgres (`V011`, `V012`) when connection string set; partial unique index + app-level one-active enforcement.
|
||||
- **DI:** `AddContractInstanceStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
|
||||
- **Tests:** `InMemoryContractInstanceStoreTests`, `InMemoryContractOutcomeStoreTests`, Postgres persistence integration tests; host DI smoke (`875` tests green).
|
||||
- **In-memory outcome guard:** `InMemoryContractOutcomeStore.TryAppend` denies when **`IContractInstanceStore.TryGet`** is false (Postgres FK + player/instance SQL guard parity).
|
||||
- **Docs:** `server/README.md` contract instance + outcome store sections.
|
||||
- **Bruno:** `bruno/neon-sprawl-server/contract-stores/` health smoke (startup-only; no contract HTTP in E7M4-03).
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Types (`Game/Contracts/`)
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `ContractInstanceStatus` | `Active`, `Completed`, `Expired` (no expiry transition in E7M4-03) |
|
||||
| `ContractInstanceState` | Immutable snapshot: `ContractInstanceId`, `TemplateId`, `PlayerId`, `Status`, `SeedBucket`, `IssuedAt`, `CompletedAt?` |
|
||||
| `ContractInstanceIds` | `NormalizePlayerId`, `NormalizeContractInstanceId`, `MakeInstanceKey` (per-row lock key) |
|
||||
| `ContractInstanceReasonCodes` | `player_not_writable`, `active_contract_exists`, `instance_not_found`, `instance_not_active`, `invalid_ids` |
|
||||
| `ContractOutcomeRow` | `Id`, `ContractInstanceId`, `PlayerId`, `IdempotencyKey`, grant snapshots (items/skill XP/rep), `RecordedAt` |
|
||||
| `ContractOutcomeIds` | `MakeIdempotencyKey(playerId, contractInstanceId)` → `{p}:contract_complete:{i}` |
|
||||
|
||||
Grant snapshot fields on **`ContractOutcomeRow`** use existing reward types:
|
||||
|
||||
- `IReadOnlyList<RewardItemGrantApplied> GrantedItems`
|
||||
- `IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp`
|
||||
- `IReadOnlyList<RewardReputationGrantApplied> GrantedReputation`
|
||||
|
||||
### 2. `IContractInstanceStore`
|
||||
|
||||
Injected by **`ContractGeneratorOperations`** (NEO-147) and **`ContractCompletionOperations`** (NEO-149); not HTTP directly.
|
||||
|
||||
- **`bool CanWritePlayer(string playerId)`** — dev bucket or Postgres `player_position` row exists.
|
||||
- **`bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot)`** — missing ⇒ `false`.
|
||||
- **`bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot)`** — at most one active row per player; missing active ⇒ `false`.
|
||||
- **`bool TryCreateActive(string playerId, string contractInstanceId, string templateId, string seedBucket, DateTimeOffset issuedAt, out ContractInstanceState snapshot)`** — first active for player returns `true`; denies when player not writable, invalid ids, duplicate instance id, or **`active_contract_exists`** when another active row present.
|
||||
- **`bool TryMarkComplete(string playerId, string contractInstanceId, DateTimeOffset completedAt, out ContractInstanceState snapshot)`** — requires active row; first complete `true`; replay `false` preserving first `CompletedAt`; completed rows cannot regress.
|
||||
- **`bool TryClearInstance(string playerId, string contractInstanceId)`** — dev fixture only; idempotent when absent (Bruno/test reset parity with quest store `TryClearProgress`).
|
||||
|
||||
### 3. `IContractOutcomeStore`
|
||||
|
||||
- **`bool TryAppend(ContractOutcomeRow row)`** — append-only; `false` on duplicate `Id` or duplicate `IdempotencyKey` or invalid row.
|
||||
- **`bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row)`** — audit/idempotency read for tests and E7M4-05 router replay.
|
||||
- **`IReadOnlyList<ContractOutcomeRow> GetOutcomesForInstance(string contractInstanceId, int? limit = null)`** — ordered by `RecordedAt` then `Id` ascending.
|
||||
- **`bool TryClearForInstance(string contractInstanceId)`** — dev fixture only.
|
||||
|
||||
No update/delete APIs in prototype.
|
||||
|
||||
### 4. In-memory implementations
|
||||
|
||||
Mirror NEO-116 / NEO-135:
|
||||
|
||||
- **`InMemoryContractInstanceStore`** — `ConcurrentDictionary` by instance key; secondary index `playerId → active instance id`; dev player seeded from `GamePositionOptions`; per-key locks for mutations.
|
||||
- **`InMemoryContractOutcomeStore`** — append-only by row id + idempotency key index; thread-safe.
|
||||
|
||||
### 5. Postgres implementations
|
||||
|
||||
Mirror NEO-116 quest store:
|
||||
|
||||
- **`PostgresContractInstanceBootstrap`** + **`V011__contract_instance.sql`**
|
||||
- Table `contract_instance (contract_instance_id TEXT PK, player_id FK → player_position, template_id, status, seed_bucket, issued_at, completed_at NULL)`
|
||||
- Partial unique index on `(player_id) WHERE status = 'active'` — DB-enforced one-active policy
|
||||
- **`PostgresContractOutcomeBootstrap`** + **`V012__contract_outcome.sql`**
|
||||
- Table `contract_outcome (id TEXT PK, contract_instance_id FK, player_id FK, idempotency_key UNIQUE, granted_items JSONB, granted_skill_xp JSONB, granted_reputation JSONB, recorded_at)`
|
||||
- Index on `(contract_instance_id, recorded_at)`
|
||||
- **`PostgresContractInstanceStore`**, **`PostgresContractOutcomeStore`** — `EnsureSchema` on first use; instance store checks writable player before insert/update.
|
||||
|
||||
JSON columns store grant snapshots as compact arrays (same shape as in-memory lists); no behavior change vs in-memory.
|
||||
|
||||
### 6. DI and `Program.cs`
|
||||
|
||||
- **`ContractInstanceServiceCollectionExtensions.AddContractInstanceStores`** — Postgres implementations when connection string set; otherwise in-memory pair.
|
||||
- Register **after** `AddContractTemplateCatalog` (catalog unrelated; stores are runtime state).
|
||||
- **`InMemoryWebApplicationFactory`:** remove/replace Postgres registrations for both store interfaces; register in-memory implementations in `ConfigureTestServices` loop (same pattern as quest/faction stores).
|
||||
|
||||
### 7. Tests
|
||||
|
||||
**Instance store (`InMemoryContractInstanceStoreTests`):**
|
||||
|
||||
- Create active (happy path) — all fields persisted.
|
||||
- Deny second active for same player (`active_contract_exists`).
|
||||
- Complete active instance; snapshot status `Completed` + `CompletedAt`.
|
||||
- Idempotent complete replay — second `TryMarkComplete` returns `false`, `CompletedAt` unchanged.
|
||||
- Completed instance cannot accept new active without dev clear (optional: completed player can issue again after complete — **new instance id** allowed when no active row).
|
||||
- Deny create when player not writable / empty ids.
|
||||
- Host DI: `Host_ShouldResolveContractInstanceStoresFromDi`.
|
||||
|
||||
**Outcome store (`InMemoryContractOutcomeStoreTests`):**
|
||||
|
||||
- Append first outcome; query by instance + idempotency key.
|
||||
- Duplicate row id or idempotency key denies append.
|
||||
- Append-only — no mutation APIs.
|
||||
|
||||
**Postgres (`ContractInstancePersistenceIntegrationTests`, `ContractOutcomePersistenceIntegrationTests`):**
|
||||
|
||||
- `RequirePostgresFact`: write on factory A, read on factory B for instance create/complete and outcome append.
|
||||
|
||||
### 8. Docs
|
||||
|
||||
- **`server/README.md`** — contract instance store + contract outcome store sections (config, one-active policy, idempotency key shape, load order note: stores are runtime, not content catalog).
|
||||
- Optional one-line E7_M4 module store note when implementation lands.
|
||||
|
||||
### 9. Implementation order
|
||||
|
||||
1. Types, ids, reason codes.
|
||||
2. In-memory stores + interfaces.
|
||||
3. Postgres migrations + stores.
|
||||
4. DI extension, `Program.cs`, factory overrides.
|
||||
5. Unit tests → Postgres integration tests → host DI smoke.
|
||||
6. `server/README.md`.
|
||||
7. Run `dotnet test`.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-146-implementation-plan.md` | This plan. |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceStatus.cs` | Instance lifecycle enum |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceState.cs` | Immutable instance snapshot |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs` | Id normalization + composite keys |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceReasonCodes.cs` | Stable deny reason strings |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs` | Instance store contract |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs` | Thread-safe in-memory instance store |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractOutcomeRow.cs` | Append-only outcome audit row |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractOutcomeIds.cs` | Idempotency key helper |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/IContractOutcomeStore.cs` | Outcome store contract |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStore.cs` | Thread-safe in-memory outcome store |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceBootstrap.cs` | Schema ensure for instance table |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs` | Postgres instance store |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeBootstrap.cs` | Schema ensure for outcome table |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeStore.cs` | Postgres outcome store |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceServiceCollectionExtensions.cs` | DI registration |
|
||||
| `server/db/migrations/V011__contract_instance.sql` | Postgres instance migration |
|
||||
| `server/db/migrations/V012__contract_outcome.sql` | Postgres outcome migration |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs` | Instance store AAA tests |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractOutcomeStoreTests.cs` | Outcome store AAA tests |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs` | Postgres instance persistence |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceIntegrationTests.cs` | Postgres outcome persistence |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `AddContractInstanceStores` after contract catalog |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Force in-memory contract stores in tests (override Postgres registrations) |
|
||||
| `server/README.md` | Document contract instance + outcome stores |
|
||||
|
||||
## Tests
|
||||
|
||||
| Layer | What |
|
||||
|-------|------|
|
||||
| `InMemoryContractInstanceStoreTests` | Create active, one-active deny, complete, idempotent complete, immutability, writable gate, empty-id deny |
|
||||
| `InMemoryContractOutcomeStoreTests` | Append, duplicate deny, query by instance/idempotency key, append-only |
|
||||
| `ContractInstancePersistenceIntegrationTests` | Postgres round-trip create + complete (`RequirePostgresFact`) |
|
||||
| `ContractOutcomePersistenceIntegrationTests` | Postgres round-trip append + read (`RequirePostgresFact`) |
|
||||
| Host boot | Resolve `IContractInstanceStore` + `IContractOutcomeStore` from DI via factory |
|
||||
| CI | Existing `dotnet test`; no Python/content changes expected |
|
||||
|
||||
Manual Godot QA: **none** (server infrastructure; capstone is NEO-154).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| Postgres partial unique index vs app-only one-active check | **Both** — enforce in store logic (fast deny reason) + partial unique index for race safety | `adopted` |
|
||||
| Grant snapshot on outcome before E7M4-05 router | Store accepts caller-supplied snapshot on append; E7M4-06 orchestrator fills from router result | `adopted` |
|
||||
| `Expired` status unused in Slice 4 | Keep enum for forward compatibility; no API until a future expiry story | `adopted` |
|
||||
| Dev `TryClear*` on Postgres | Implement on in-memory only first; add Postgres clear if Bruno fixture story needs it (mirror quest `TryClearProgress` on both when needed) | `adopted` — both backends implement clear helpers |
|
||||
|
||||
## Client counterpart
|
||||
|
||||
None for E7M4-03. Client contract HUD: **NEO-153** / capstone **NEO-154**.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Code review (re-review) — NEO-146 (E7M4-03 contract instance + outcome stores)
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Scope:** Branch `NEO-146-e7m4-03-contract-instance-outcome-stores` — commits `82c4594`…`01e82f3` (10 commits); supersedes [2026-06-24-NEO-146.md](2026-06-24-NEO-146.md) initial review
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
Follow-up review after the initial NEO-146 review and a broad C# style/analyzer cleanup pass. All prior **Suggestions** and actionable **Nits** from the first review are addressed: decomposition alignment updated, Postgres one-active race + deny-path tests added, duplicate instance id unit test, FK-safe `PostgresContractOutcomeStore.TryAppend`, `MakeInstanceKey` / player-level locking wired in the in-memory instance store, README orchestrator ordering documented, and plan reconciliation brought current. Subsequent commits apply collection spread, `await using` / `Lock` schema gates, and related analyzer conventions repo-wide (documented in `.cursor/rules/csharp-style.md`). Contract store behavior matches the plan; `dotnet test` passes **867** tests with **0** build warnings. Risk remains low — infrastructure-only, no HTTP surface.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Alignment |
|
||||
|----------|-----------|
|
||||
| `docs/plans/NEO-146-implementation-plan.md` | **Matches** — AC complete; reconciliation includes Bruno smoke, `MakeInstanceKey` locking, Postgres clear adopted, review follow-up tests. Test count **867**. |
|
||||
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-03) | **Matches** |
|
||||
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — runtime stores paragraph (NEO-146). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M4-03 / NEO-146 stores row added. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7M4-03 / NEO-146 note added. |
|
||||
| `.cursor/rules/csharp-style.md` | **Matches** — new sections document collection spread, `using`/`await using`, `Lock` schema gates, CA1859, null-check style adopted in cleanup commits. |
|
||||
| Full-stack epic decomposition | **N/A** — infrastructure-only. |
|
||||
|
||||
## Prior review resolution
|
||||
|
||||
| Item (from `2026-06-24-NEO-146.md`) | Status |
|
||||
|-------------------------------------|--------|
|
||||
| Decomposition tracking (alignment + register) | **Done** |
|
||||
| Postgres distinct-instance-id one-active race test | **Done** — `TryCreateActive_ShouldAllowOnlyOneActive_WhenDistinctInstanceIdsRace` |
|
||||
| Postgres outcome deny paths + idempotent complete | **Done** |
|
||||
| In-memory duplicate instance id test | **Done** |
|
||||
| Postgres FK parity on outcome append | **Done** — catch + README |
|
||||
| `MakeInstanceKey` wiring / plan trim | **Done** |
|
||||
| Bruno + plan reconciliation | **Done** |
|
||||
| Postgres `TryClear*` plan status | **Done** — adopted |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**PR / commit scope for future stories**~~ — **Intentional.** Repo-wide analyzer/style cleanup (`b455ff5`…`01e82f3`) was bundled on this branch by author request; no split `chore:` PR for E7M4-03.
|
||||
|
||||
2. ~~**In-memory outcome instance guard (optional parity)**~~ — **Done.** `InMemoryContractOutcomeStore` injects **`IContractInstanceStore`** and denies **`TryAppend`** when **`TryGet`** is false; unit test + README/plan updated (Postgres FK parity).
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Plan reconciliation cites **866** tests; re-review run reports **867**~~ — **Done.** Plan reconciliation updated to **867**.
|
||||
|
||||
- Nit: `ContractInstanceReasonCodes` remains unused until NEO-147 orchestrators — expected deferral; no action in E7M4-03.
|
||||
|
||||
- Nit: `InMemoryContractInstanceStore` uses `PlayerLockKey` for create/active reads and `MakeInstanceKey` for get/complete/clear on a specific instance. One-active policy holds under analysis (create path serializes on player lock; active index checked before insert), but the split lock granularity is slightly harder to reason about than a single player-level mutex — acceptable for prototype; no change required unless concurrency tests expand.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during re-review (all green):
|
||||
|
||||
```bash
|
||||
dotnet build NeonSprawl.sln
|
||||
dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Optional Postgres integration (when `ConnectionStrings__NeonSprawl` is set):
|
||||
|
||||
```bash
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractInstancePersistence|FullyQualifiedName~ContractOutcomePersistence"
|
||||
```
|
||||
|
||||
Merge-ready: all suggestions addressed or intentionally accepted; remaining nits are expected deferrals only.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Code review — NEO-146 (E7M4-03 contract instance + outcome stores)
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Scope:** Branch `NEO-146-e7m4-03-contract-instance-outcome-stores` — commits `82c4594`…`ae641a4` (2 commits)
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-146 adds durable per-player contract instance state (`IContractInstanceStore`) and append-only completion audit rows (`IContractOutcomeStore`) with in-memory fallback and Postgres persistence (`V011`, `V012`) when `ConnectionStrings:NeonSprawl` is set. The implementation mirrors NEO-116/NEO-135 patterns: dev-player write gate, one-active enforcement (app logic + partial unique index), idempotent complete replay, outcome idempotency keys, grant snapshot reuse from `Game/Rewards/`, DI via `AddContractInstanceStores`, and test factory overrides. Unit tests cover happy/deny/idempotent paths with full AAA; Postgres integration tests include cross-factory persistence and a concurrent create race. `dotnet test` passes 862 tests. Risk is low — infrastructure-only, no HTTP or player-facing surface; primary consumers are NEO-147/NEO-149 orchestrators.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Alignment |
|
||||
|----------|-----------|
|
||||
| `docs/plans/NEO-146-implementation-plan.md` | **Matches** — AC checklist complete; reconciliation reflects shipped types, stores, migrations, DI, tests, README. Minor drift: plan lists `MakeActivePlayerKey` (not implemented) and defers Postgres `TryClear*` (implemented anyway — acceptable improvement). |
|
||||
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-03) | **Matches** — dual stores, one-active policy, idempotency key shape, grant snapshots, NEO-116-style Postgres split; generator/payout/HTTP correctly out of scope. |
|
||||
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — runtime stores paragraph + README links added (NEO-146). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E7.M4 row cites E7M4-01/NEO-144 and E7M4-02/NEO-145 only; should add E7M4-03/NEO-146 stores after merge (same pattern as NEO-145 follow-up).~~ **Done.** — E7M4-03 / NEO-146 stores added to E7.M4 row. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | ~~**Partially matches** — E7.M4 In Progress spine unchanged; optional one-line NEO-146 note after merge.~~ **Done.** — E7M4-03 / NEO-146 note added. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — server-side persistence only; no authority boundary change. |
|
||||
| Full-stack epic decomposition | **N/A** — infrastructure-only; client counterpart correctly none (NEO-154 capstone). |
|
||||
|
||||
~~Register/tracking tables should be updated for E7M4-03 after merge — **should fix** (non-blocking), mirroring NEO-145 alignment follow-up.~~ **Done.**
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Update decomposition tracking for E7M4-03** — After merge, extend `documentation_and_implementation_alignment.md` E7.M4 row (and optionally `module_dependency_register.md`) with E7M4-03: `IContractInstanceStore` + `IContractOutcomeStore`, `V011`/`V012`, README sections — same pattern as NEO-145.~~ **Done.**
|
||||
|
||||
2. ~~**Postgres one-active concurrent race** — The existing `TryCreateActive_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently` test races on the **same** instance id (PK dedup), matching the quest-store precedent. For contracts, the more important race is **different** instance ids for the same player competing for the one-active partial unique index. Consider a second `RequirePostgresFact` that launches concurrent `TryCreateActive` with distinct instance ids and asserts exactly one `active` row remains.~~ **Done.** — `TryCreateActive_ShouldAllowOnlyOneActive_WhenDistinctInstanceIdsRace`.
|
||||
|
||||
3. ~~**Postgres outcome deny paths** — In-memory tests cover duplicate row id / idempotency key; Postgres integration only covers happy-path append. Add `RequirePostgresFact` duplicate-append deny (and optionally idempotent complete replay on Postgres) to lock SQL `INSERT … WHERE NOT EXISTS` / complete `FOR UPDATE` behavior.~~ **Done.** — duplicate-append deny, missing-instance FK deny, and Postgres idempotent complete tests added.
|
||||
|
||||
4. ~~**In-memory duplicate instance id** — Plan §7 lists duplicate instance id deny; no explicit unit test calls `TryCreateActive` twice with the same instance id (second call should return `false` with unchanged snapshot). Cheap coverage gap.~~ **Done.** — `TryCreateActive_ShouldDenyDuplicateInstanceId`.
|
||||
|
||||
5. ~~**Postgres outcome FK parity** — `PostgresContractOutcomeStore.TryAppend` will throw on FK violation (missing `contract_instance` row) while in-memory accepts any normalized row. Orchestrators (NEO-149) will create the instance first, so this is low risk today; document in README or catch `PostgresException` and return `false` if you want strict bool-only store semantics across backends.~~ **Done.** — FK violation caught; README documents orchestrator ordering.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `ContractInstanceIds.MakeInstanceKey` is defined but unused; plan also lists `MakeActivePlayerKey` which was not added. Either wire keys into store indexing or trim plan/reconciliation to match shipped API.~~ **Done.** — `MakeInstanceKey` wired for per-row locks; plan trimmed; player-level lock for one-active create path.
|
||||
|
||||
- Nit: `ContractInstanceReasonCodes` is defined for E7M4-04+ orchestrators but unused in this slice — correct deferral; no action until NEO-147.
|
||||
|
||||
- ~~Nit: Bruno folder `bruno/neon-sprawl-server/contract-stores/` (health smoke) is useful but not listed in plan §Implementation reconciliation — optional one-line note for doc parity (same as NEO-145 Bruno nit).~~ **Done.** — plan reconciliation updated.
|
||||
|
||||
- ~~Nit: Plan open questions marked Postgres `TryClear*` as deferred; both Postgres stores implement clear helpers anyway — update plan risk row to `adopted` on next doc pass.~~ **Done.**
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (all green):
|
||||
|
||||
```bash
|
||||
dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Author should run Postgres integration locally when `ConnectionStrings__NeonSprawl` is available:
|
||||
|
||||
```bash
|
||||
# Requires local Postgres + connection string
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractInstancePersistence|FullyQualifiedName~ContractOutcomePersistence"
|
||||
```
|
||||
|
||||
~~Before merge: skim decomposition alignment suggestion — functional code is merge-ready without it.~~ **Done** — review follow-up committed.
|
||||
|
|
@ -37,13 +37,12 @@ public sealed class HotbarLoadoutPersistenceIntegrationTests(PostgresIntegration
|
|||
HttpStatusCode postStatus;
|
||||
|
||||
// Act
|
||||
using (var firstClient = Factory.CreateClient())
|
||||
{
|
||||
using var firstClient = Factory.CreateClient();
|
||||
var post = await firstClient.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
update);
|
||||
postStatus = post.StatusCode;
|
||||
}
|
||||
|
||||
|
||||
// Assert
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
|
|
@ -73,14 +72,10 @@ public sealed class HotbarLoadoutPersistenceIntegrationTests(PostgresIntegration
|
|||
var ddl = await File.ReadAllTextAsync(ddlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var apply = new NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
await using var apply = new NpgsqlCommand(ddl, conn);
|
||||
await apply.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_hotbar_loadout;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_hotbar_loadout;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
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 TryCreateActive_ShouldAllowOnlyOneActive_WhenDistinctInstanceIdsRace()
|
||||
{
|
||||
// Arrange
|
||||
await ResetContractInstanceTableAsync();
|
||||
const int concurrentCalls = 8;
|
||||
|
||||
// Act
|
||||
var results = await Task.WhenAll(
|
||||
Enumerable.Range(0, concurrentCalls).Select(i => Task.Run(() =>
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||
var instanceId = $"prototype_contract_instance_pg_race_{i:D3}";
|
||||
return (
|
||||
Success: store.TryCreateActive(
|
||||
PlayerId,
|
||||
instanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _),
|
||||
InstanceId: instanceId);
|
||||
})));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, results.Count(static r => r.Success));
|
||||
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r.Success));
|
||||
Assert.True(
|
||||
Factory.Services.CreateScope().ServiceProvider
|
||||
.GetRequiredService<IContractInstanceStore>()
|
||||
.TryGetActiveForPlayer(PlayerId, out var active));
|
||||
Assert.Equal(ContractInstanceStatus.Active, active.Status);
|
||||
Assert.Contains(results.Where(static r => r.Success).Select(static r => r.InstanceId), id => id == active.ContractInstanceId);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryMarkComplete_ShouldBeIdempotent_OnPostgres()
|
||||
{
|
||||
// Arrange
|
||||
await ResetContractInstanceTableAsync();
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||
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(CompletedAt, secondSnapshot.CompletedAt);
|
||||
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
|
||||
}
|
||||
|
||||
[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 readStore = secondScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||
readBack = readStore.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,262 @@
|
|||
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 readOutcomeStore = secondScope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
||||
readBack = readOutcomeStore.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);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryAppend_ShouldDenyDuplicateRowIdAndIdempotencyKey()
|
||||
{
|
||||
// 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);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
||||
Assert.True(instanceStore.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _));
|
||||
Assert.True(outcomeStore.TryAppend(outcomeRow));
|
||||
// Act
|
||||
var duplicateId = outcomeStore.TryAppend(outcomeRow with { IdempotencyKey = "other-key" });
|
||||
var duplicateKey = outcomeStore.TryAppend(outcomeRow with { Id = "outcome-row-pg-002" });
|
||||
// Assert
|
||||
Assert.False(duplicateId);
|
||||
Assert.False(duplicateKey);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryAppend_ShouldReturnFalse_WhenContractInstanceMissing()
|
||||
{
|
||||
// Arrange
|
||||
await ResetContractTablesAsync();
|
||||
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
||||
var outcomeRow = new ContractOutcomeRow(
|
||||
OutcomeRowId,
|
||||
InstanceId,
|
||||
PlayerId,
|
||||
idempotencyKey,
|
||||
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
||||
[],
|
||||
[],
|
||||
RecordedAt);
|
||||
// Act
|
||||
bool appended;
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
||||
appended = outcomeStore.TryAppend(outcomeRow);
|
||||
|
||||
// Assert
|
||||
Assert.False(appended);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryAppend_ShouldReturnFalse_WhenPlayerIdDoesNotMatchInstanceOwner()
|
||||
{
|
||||
// Arrange
|
||||
await ResetContractTablesAsync();
|
||||
const string otherPlayerId = "other-player-pg-001";
|
||||
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(otherPlayerId, InstanceId);
|
||||
var outcomeRow = new ContractOutcomeRow(
|
||||
OutcomeRowId,
|
||||
InstanceId,
|
||||
otherPlayerId,
|
||||
idempotencyKey,
|
||||
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
||||
[],
|
||||
[],
|
||||
RecordedAt);
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
||||
Assert.True(instanceStore.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _));
|
||||
await SeedPostgresPlayerAsync(otherPlayerId);
|
||||
// Act
|
||||
var appended = outcomeStore.TryAppend(outcomeRow);
|
||||
// Assert
|
||||
Assert.False(appended);
|
||||
Assert.Empty(outcomeStore.GetOutcomesForInstance(InstanceId));
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryAppend_ShouldAllowOnlyOneSuccess_WhenConcurrentDuplicateRowsRace()
|
||||
{
|
||||
// Arrange
|
||||
await ResetContractTablesAsync();
|
||||
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
||||
var outcomeRow = new ContractOutcomeRow(
|
||||
OutcomeRowId,
|
||||
InstanceId,
|
||||
PlayerId,
|
||||
idempotencyKey,
|
||||
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
||||
[],
|
||||
[],
|
||||
RecordedAt);
|
||||
using var seedScope = Factory.Services.CreateScope();
|
||||
var instanceStore = seedScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||
Assert.True(instanceStore.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _));
|
||||
|
||||
// Act
|
||||
var results = await Task.WhenAll(
|
||||
Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
||||
return outcomeStore.TryAppend(outcomeRow);
|
||||
})));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, results.Count(static r => r));
|
||||
Assert.Equal(7, results.Count(static r => !r));
|
||||
}
|
||||
|
||||
private static async Task SeedPostgresPlayerAsync(string playerId)
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence)
|
||||
VALUES (@pid, 0, 0, 0, 0)
|
||||
ON CONFLICT (player_id) DO NOTHING;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", playerId);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
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,359 @@
|
|||
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_ShouldDenyDuplicateInstanceId()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out var firstSnapshot));
|
||||
// Act
|
||||
var duplicate = store.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt.AddHours(1),
|
||||
out var secondSnapshot);
|
||||
// Assert
|
||||
Assert.False(duplicate);
|
||||
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
|
||||
Assert.Equal(firstSnapshot.IssuedAt, secondSnapshot.IssuedAt);
|
||||
Assert.Equal(ContractInstanceStatus.Active, secondSnapshot.Status);
|
||||
}
|
||||
|
||||
[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 TryCreateActiveAndTryMarkComplete_ShouldKeepActiveIndexConsistent_WhenRacingOnSameInstanceId()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
await Task.WhenAll(
|
||||
Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
{
|
||||
_ = store.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _);
|
||||
})));
|
||||
|
||||
// Assert
|
||||
if (store.TryGetActiveForPlayer(PlayerId, out var active))
|
||||
{
|
||||
Assert.True(store.TryGet(PlayerId, active.ContractInstanceId, out var row));
|
||||
Assert.Equal(ContractInstanceStatus.Active, row.Status);
|
||||
}
|
||||
|
||||
if (store.TryGet(PlayerId, InstanceId, out var final) &&
|
||||
final.Status == ContractInstanceStatus.Completed)
|
||||
{
|
||||
Assert.False(store.TryGetActiveForPlayer(PlayerId, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetActiveForPlayer_ShouldKeepActiveIndexConsistent_WhenRacingWithCompleteAndReissue()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _));
|
||||
// Act
|
||||
await Task.WhenAll(
|
||||
Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
||||
{
|
||||
if (i % 3 == 0)
|
||||
{
|
||||
_ = store.TryGetActiveForPlayer(PlayerId, out _);
|
||||
return;
|
||||
}
|
||||
|
||||
if (i % 3 == 1)
|
||||
{
|
||||
_ = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = store.TryCreateActive(
|
||||
PlayerId,
|
||||
SecondInstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt.AddDays(1),
|
||||
out _);
|
||||
})));
|
||||
|
||||
// Assert
|
||||
if (store.TryGetActiveForPlayer(PlayerId, out var active))
|
||||
{
|
||||
Assert.True(store.TryGet(PlayerId, active.ContractInstanceId, out var row));
|
||||
Assert.Equal(ContractInstanceStatus.Active, row.Status);
|
||||
}
|
||||
|
||||
Assert.False(
|
||||
store.TryGet(PlayerId, InstanceId, out var first) &&
|
||||
store.TryGet(PlayerId, SecondInstanceId, out var second) &&
|
||||
first.Status == ContractInstanceStatus.Active &&
|
||||
second.Status == ContractInstanceStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryClearInstance_ShouldReturnTrue_WhenRowMissing()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var cleared = store.TryClearInstance(PlayerId, InstanceId);
|
||||
// Assert
|
||||
Assert.True(cleared);
|
||||
Assert.False(store.TryGet(PlayerId, InstanceId, out _));
|
||||
}
|
||||
|
||||
[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,164 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
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 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 RecordedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldPersistAndQueryByInstanceAndIdempotencyKey()
|
||||
{
|
||||
// Arrange
|
||||
var (instanceStore, store) = CreateStores();
|
||||
SeedActiveInstance(instanceStore);
|
||||
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 (instanceStore, store) = CreateStores();
|
||||
SeedActiveInstance(instanceStore);
|
||||
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 (instanceStore, store) = CreateStores();
|
||||
SeedActiveInstance(instanceStore);
|
||||
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 (instanceStore, store) = CreateStores();
|
||||
SeedActiveInstance(instanceStore);
|
||||
var invalidRow = CreatePrototypeOutcome() with { PlayerId = " " };
|
||||
// Act
|
||||
var appended = store.TryAppend(invalidRow);
|
||||
// Assert
|
||||
Assert.False(appended);
|
||||
Assert.Empty(store.GetOutcomesForInstance(InstanceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldReturnFalse_WhenContractInstanceMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, store) = CreateStores();
|
||||
var row = CreatePrototypeOutcome();
|
||||
// Act
|
||||
var appended = store.TryAppend(row);
|
||||
// Assert
|
||||
Assert.False(appended);
|
||||
Assert.Empty(store.GetOutcomesForInstance(InstanceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAppend_ShouldReturnFalse_WhenPlayerIdDoesNotMatchInstanceOwner()
|
||||
{
|
||||
// Arrange
|
||||
var (instanceStore, store) = CreateStores();
|
||||
SeedActiveInstance(instanceStore);
|
||||
var row = CreatePrototypeOutcome() with { PlayerId = "other-player-xyz" };
|
||||
// Act
|
||||
var appended = store.TryAppend(row);
|
||||
// Assert
|
||||
Assert.False(appended);
|
||||
Assert.Empty(store.GetOutcomesForInstance(InstanceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAppend_ShouldAllowOnlyOneSuccess_WhenConcurrentDuplicateIdempotencyKeysRace()
|
||||
{
|
||||
// Arrange
|
||||
var (instanceStore, store) = CreateStores();
|
||||
SeedActiveInstance(instanceStore);
|
||||
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
||||
var baseRow = CreatePrototypeOutcome();
|
||||
// Act
|
||||
var results = await Task.WhenAll(
|
||||
Enumerable.Range(0, 8).Select(i => Task.Run(() =>
|
||||
{
|
||||
var row = baseRow with { Id = $"outcome-row-race-{i:D3}" };
|
||||
Assert.Equal(idempotencyKey, row.IdempotencyKey);
|
||||
return store.TryAppend(row);
|
||||
})));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, results.Count(static r => r));
|
||||
Assert.Equal(7, results.Count(static r => !r));
|
||||
Assert.Single(store.GetOutcomesForInstance(InstanceId));
|
||||
}
|
||||
|
||||
private static (InMemoryContractInstanceStore InstanceStore, InMemoryContractOutcomeStore OutcomeStore) CreateStores()
|
||||
{
|
||||
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
var instanceStore = new InMemoryContractInstanceStore(options);
|
||||
return (instanceStore, new InMemoryContractOutcomeStore(instanceStore));
|
||||
}
|
||||
|
||||
private static void SeedActiveInstance(InMemoryContractInstanceStore instanceStore)
|
||||
{
|
||||
Assert.True(instanceStore.TryCreateActive(
|
||||
PlayerId,
|
||||
InstanceId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
IssuedAt,
|
||||
out _));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,8 @@ public sealed class EncounterProgressApiTests
|
|||
PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
],
|
||||
row.DefeatedTargetIds);
|
||||
Assert.True(row.DefeatedTargetIds.SequenceEqual(
|
||||
row.DefeatedTargetIds.OrderBy(static id => id, StringComparer.Ordinal)));
|
||||
Assert.Null(row.CompletedAt);
|
||||
Assert.Null(row.RewardGrantSummary);
|
||||
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
|
|
|
|||
|
|
@ -31,12 +31,10 @@ public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrati
|
|||
}
|
||||
|
||||
FactionStandingReadOutcome readBack;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||
readBack = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
}
|
||||
var readStore = secondScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||
readBack = readStore.TryGetStanding(PlayerId, GridFactionId);
|
||||
|
||||
// Assert
|
||||
Assert.True(writeOutcome.Success);
|
||||
|
|
@ -69,11 +67,10 @@ public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrati
|
|||
|
||||
// Act
|
||||
FactionStandingReadOutcome readBack;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||
readBack = store.TryGetStanding(PlayerId, GridFactionId);
|
||||
}
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.True(readBack.Success);
|
||||
|
|
@ -102,21 +99,15 @@ public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrati
|
|||
var standingDdl = await File.ReadAllTextAsync(standingDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyStanding = new NpgsqlCommand(standingDdl, conn))
|
||||
{
|
||||
await using var applyStanding = new NpgsqlCommand(standingDdl, conn);
|
||||
await applyStanding.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ public sealed class InMemoryReputationDeltaStoreTests
|
|||
// Act
|
||||
var rows = store.GetDeltasForPlayer(PlayerId);
|
||||
// Assert
|
||||
Assert.Equal(["delta-a", "delta-m", "delta-z"], rows.Select(static r => r.Id).ToArray());
|
||||
Assert.Equal(["delta-a", "delta-m", "delta-z"], [.. rows.Select(static r => r.Id)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -40,12 +40,10 @@ public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrati
|
|||
}
|
||||
|
||||
IReadOnlyList<ReputationDeltaRow> readBack;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
|
||||
readBack = store.GetDeltasForPlayer(PlayerId);
|
||||
}
|
||||
var readStore = secondScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
|
||||
readBack = readStore.GetDeltasForPlayer(PlayerId);
|
||||
|
||||
// Assert
|
||||
var persisted = Assert.Single(readBack);
|
||||
|
|
@ -77,21 +75,15 @@ public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrati
|
|||
var auditDdl = await File.ReadAllTextAsync(auditDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyAudit = new NpgsqlCommand(auditDdl, conn))
|
||||
{
|
||||
await using var applyAudit = new NpgsqlCommand(auditDdl, conn);
|
||||
await applyAudit.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,25 +48,21 @@ public sealed class ResourceNodeInstancePersistenceIntegrationTests(PostgresInte
|
|||
await ResetInstanceTableAsync();
|
||||
using (var seedScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var registry = seedScope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
|
||||
var store = seedScope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
|
||||
var seedRegistry = seedScope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
|
||||
var seedStore = seedScope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_ = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
|
||||
_ = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, seedRegistry, seedStore);
|
||||
}
|
||||
}
|
||||
|
||||
// Act
|
||||
ResourceNodeInstanceMutationOutcome denyOutcome;
|
||||
ResourceNodeInstanceSnapshot persisted;
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using (var scope = secondFactory.Services.CreateScope())
|
||||
{
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
|
||||
denyOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
|
||||
store.TryGetRemainingGathers(AlphaId, out persisted);
|
||||
}
|
||||
using var actScope = secondFactory.Services.CreateScope();
|
||||
var actRegistry = actScope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
|
||||
var actStore = actScope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
|
||||
var denyOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, actRegistry, actStore);
|
||||
actStore.TryGetRemainingGathers(AlphaId, out var persisted);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ResourceNodeInstanceMutationKind.Denied, denyOutcome.Kind);
|
||||
|
|
@ -92,14 +88,10 @@ public sealed class ResourceNodeInstancePersistenceIntegrationTests(PostgresInte
|
|||
var ddl = await File.ReadAllTextAsync(ddlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var apply = new NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
await using var apply = new NpgsqlCommand(ddl, conn);
|
||||
await apply.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE resource_node_instance;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE resource_node_instance;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,26 +97,18 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
|
|||
var gigDdl = await File.ReadAllTextAsync(gigDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyGig = new NpgsqlCommand(gigDdl, conn))
|
||||
{
|
||||
await using var applyGig = new NpgsqlCommand(gigDdl, conn);
|
||||
await applyGig.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncateGig = new NpgsqlCommand("TRUNCATE player_gig_progression;", conn))
|
||||
{
|
||||
await using var truncateGig = new NpgsqlCommand("TRUNCATE player_gig_progression;", conn);
|
||||
await truncateGig.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,31 +54,31 @@ public sealed class PlayerInventoryPersistenceIntegrationTests(PostgresIntegrati
|
|||
await ResetInventoryTableAsync();
|
||||
using (var seedScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var registry = seedScope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var store = seedScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
|
||||
var seedRegistry = seedScope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var seedStore = seedScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
|
||||
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
|
||||
{
|
||||
_ = PlayerInventoryOperations.TryAddStack(
|
||||
"dev-local-1",
|
||||
"survey_drone_kit",
|
||||
quantity: 1,
|
||||
registry,
|
||||
store);
|
||||
seedRegistry,
|
||||
seedStore);
|
||||
}
|
||||
}
|
||||
|
||||
// Act
|
||||
PlayerInventoryMutationOutcome denyOutcome;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
using (var actScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
|
||||
var actRegistry = actScope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var actStore = actScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
|
||||
denyOutcome = PlayerInventoryOperations.TryAddStack(
|
||||
"dev-local-1",
|
||||
"scrap_metal_bulk",
|
||||
quantity: 1,
|
||||
registry,
|
||||
store);
|
||||
actRegistry,
|
||||
actStore);
|
||||
}
|
||||
|
||||
// Act — verify on fresh host
|
||||
|
|
@ -120,28 +120,20 @@ public sealed class PlayerInventoryPersistenceIntegrationTests(PostgresIntegrati
|
|||
var inventoryDdl = await File.ReadAllTextAsync(inventoryDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyInventory = new NpgsqlCommand(inventoryDdl, conn))
|
||||
{
|
||||
await using var applyInventory = new NpgsqlCommand(inventoryDdl, conn);
|
||||
await applyInventory.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncateInventory = new NpgsqlCommand("TRUNCATE player_inventory;", conn))
|
||||
{
|
||||
await using var truncateInventory = new NpgsqlCommand("TRUNCATE player_inventory;", conn);
|
||||
await truncateInventory.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static int OccupiedBagSlotCount(PlayerInventorySnapshot snapshot)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ public class MasteryCatalogRegistryTests
|
|||
// Act
|
||||
var tracks = registry.GetTracksInSkillIdOrder();
|
||||
// Assert
|
||||
Assert.Equal(["intrusion", "refine", "salvage"], tracks.Select(t => t.SkillId).ToArray());
|
||||
Assert.Equal(["intrusion", "refine", "salvage"], [.. tracks.Select(t => t.SkillId)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -111,7 +111,7 @@ public class MasteryCatalogRegistryTests
|
|||
// Act
|
||||
var ordered = registry.GetPerksInIdOrder();
|
||||
// Assert
|
||||
Assert.Equal(["a_perk", "m_perk", "z_perk"], ordered.Select(p => p.Id).ToArray());
|
||||
Assert.Equal(["a_perk", "m_perk", "z_perk"], [.. ordered.Select(p => p.Id)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -158,10 +158,9 @@ public sealed class PerkStateApiTests
|
|||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
var json = await post.Content.ReadAsStringAsync();
|
||||
using (var doc = JsonDocument.Parse(json))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.False(doc.RootElement.TryGetProperty("reasonCode", out _));
|
||||
}
|
||||
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<PerkBranchSelectResponse>(json);
|
||||
Assert.NotNull(envelope);
|
||||
|
|
|
|||
|
|
@ -20,23 +20,20 @@ public sealed class PerkStatePersistenceIntegrationTests(PostgresIntegrationHarn
|
|||
// Arrange
|
||||
await ResetPerkTablesAsync();
|
||||
const string playerId = "dev-local-1";
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var engine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
Assert.True(xpStore.TryApplyXpDelta(playerId, "salvage", 450, out _, out _));
|
||||
Assert.True(engine.TrySelectBranch(playerId, "salvage", 1, "scrap_efficiency").Success);
|
||||
_ = engine.ReevaluateAfterLevelUp(playerId, "salvage", newLevel: 4);
|
||||
}
|
||||
|
||||
|
||||
// Act
|
||||
PerkStateSnapshot snapshot;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
using var scope = secondFactory.Services.CreateScope();
|
||||
var perkStore = scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var perkStore = secondScope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
|
||||
snapshot = perkStore.GetSnapshot(playerId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal("scrap_efficiency", snapshot.BranchPicksBySkillId["salvage"][1]);
|
||||
|
|
@ -65,21 +62,15 @@ public sealed class PerkStatePersistenceIntegrationTests(PostgresIntegrationHarn
|
|||
var perkDdl = await File.ReadAllTextAsync(perkDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyPerk = new NpgsqlCommand(perkDdl, conn))
|
||||
{
|
||||
await using var applyPerk = new NpgsqlCommand(perkDdl, conn);
|
||||
await applyPerk.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using Npgsql;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
|
@ -13,7 +14,7 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
|
|||
/// </remarks>
|
||||
internal static class PostgresDockerHelper
|
||||
{
|
||||
private static readonly object StateLock = new();
|
||||
private static readonly Lock StateLock = new();
|
||||
private static Task? _ensureTask;
|
||||
private static bool _composeStartedByUs;
|
||||
private static string? _repoRoot;
|
||||
|
|
@ -84,12 +85,9 @@ internal static class PostgresDockerHelper
|
|||
return;
|
||||
}
|
||||
|
||||
var root = FindDockerComposeRoot();
|
||||
if (root is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
var root = FindDockerComposeRoot()
|
||||
?? throw new InvalidOperationException(
|
||||
"Postgres is not reachable and docker-compose.yml was not found by walking up from the test output directory.");
|
||||
}
|
||||
|
||||
await RunDockerComposeAsync(root, "compose up -d", CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -62,12 +62,11 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
|||
PositionStateResponse? persisted = null;
|
||||
|
||||
// Act
|
||||
using (var first = Factory.CreateClient())
|
||||
{
|
||||
using var first = Factory.CreateClient();
|
||||
var post = await first.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
|
||||
postStatus = post.StatusCode;
|
||||
persisted = await post.Content.ReadFromJsonAsync<PositionStateResponse>();
|
||||
}
|
||||
|
||||
|
||||
// Assert
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
|
|
@ -122,15 +121,11 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
|||
var ddl = await File.ReadAllTextAsync(ddlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var apply = new NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
await using var apply = new NpgsqlCommand(ddl, conn);
|
||||
await apply.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
|
|||
// Arrange
|
||||
await ResetQuestProgressTableAsync();
|
||||
const int concurrentCalls = 8;
|
||||
var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
|
||||
// Act
|
||||
var results = await Task.WhenAll(
|
||||
|
|
@ -70,17 +70,15 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
|
|||
|
||||
// Act — read back through a fresh host
|
||||
QuestStepState readBack;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
readBack = store.TryGetProgress(
|
||||
var readStore = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
readBack = readStore.TryGetProgress(
|
||||
PlayerId,
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||
out var snapshot)
|
||||
? snapshot
|
||||
: null!;
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(readBack);
|
||||
|
|
@ -95,7 +93,7 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
|
|||
{
|
||||
// Arrange
|
||||
await ResetQuestProgressTableAsync();
|
||||
var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
|
||||
// Act
|
||||
using (var firstScope = Factory.Services.CreateScope())
|
||||
|
|
@ -108,12 +106,10 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
|
|||
}
|
||||
|
||||
var readBackExists = false;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
readBackExists = store.TryGetProgress(PlayerId, questId, out _);
|
||||
}
|
||||
var readStore = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
readBackExists = readStore.TryGetProgress(PlayerId, questId, out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(readBackExists);
|
||||
|
|
@ -141,21 +137,15 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
|
|||
var questDdl = await File.ReadAllTextAsync(questDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyQuest = new NpgsqlCommand(questDdl, conn))
|
||||
{
|
||||
await using var applyQuest = new NpgsqlCommand(questDdl, conn);
|
||||
await applyQuest.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ public class QuestDefinitionsWorldApiTests
|
|||
Assert.NotNull(body.Quests);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, body.Quests.Count);
|
||||
Assert.Equal(
|
||||
PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(),
|
||||
body.Quests.Select(q => q.Id).ToArray());
|
||||
[.. PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal)],
|
||||
[.. body.Quests.Select(q => q.Id)]);
|
||||
|
||||
var gatherIntro = body.Quests.Single(q => q.Id == "prototype_quest_gather_intro");
|
||||
Assert.Equal("Intro: Salvage Run", gatherIntro.DisplayName);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public sealed class QuestProgressApiTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var registry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
|
||||
var expectedQuestOrder = registry.GetDefinitionsInIdOrder().Select(static d => d.Id).ToArray();
|
||||
string[] expectedQuestOrder = [.. registry.GetDefinitionsInIdOrder().Select(static d => d.Id)];
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
|
|
@ -75,7 +75,8 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Equal(QuestProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.Equal(PlayerId, body.PlayerId);
|
||||
Assert.Equal(expectedQuestOrder.Length, body.Quests.Count);
|
||||
Assert.Equal(expectedQuestOrder, body.Quests.Select(static row => row.QuestId).ToArray());
|
||||
string[] actualQuestOrder = [.. body.Quests.Select(static row => row.QuestId)];
|
||||
Assert.Equal(expectedQuestOrder, actualQuestOrder);
|
||||
foreach (var row in body.Quests)
|
||||
{
|
||||
Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status);
|
||||
|
|
|
|||
|
|
@ -803,7 +803,7 @@ public sealed class RewardRouterOperationsTests
|
|||
var filtered = rows.Where(r => r.PlayerId == normalized).ToList();
|
||||
if (limit is > 0)
|
||||
{
|
||||
return filtered.Take(limit.Value).ToList();
|
||||
return [.. filtered.Take(limit.Value)];
|
||||
}
|
||||
|
||||
return filtered;
|
||||
|
|
|
|||
|
|
@ -29,12 +29,11 @@ public sealed class SkillProgressionGrantPersistenceIntegrationTests(PostgresInt
|
|||
HttpResponseMessage postResponse;
|
||||
|
||||
// Act — write grant through first host
|
||||
using (var firstClient = Factory.CreateClient())
|
||||
{
|
||||
using var firstClient = Factory.CreateClient();
|
||||
postResponse = await firstClient.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/skill-progression",
|
||||
grant);
|
||||
}
|
||||
|
||||
|
||||
// Act — read back through a fresh host (forces DB round-trip / new DI scope)
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
|
|
@ -78,26 +77,18 @@ public sealed class SkillProgressionGrantPersistenceIntegrationTests(PostgresInt
|
|||
var progressionDdl = await File.ReadAllTextAsync(progressionDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyProgression = new NpgsqlCommand(progressionDdl, conn))
|
||||
{
|
||||
await using var applyProgression = new NpgsqlCommand(progressionDdl, conn);
|
||||
await applyProgression.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncateProgression = new NpgsqlCommand("TRUNCATE player_skill_progression;", conn))
|
||||
{
|
||||
await using var truncateProgression = new NpgsqlCommand("TRUNCATE player_skill_progression;", conn);
|
||||
await truncateProgression.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
||||
d.ServiceType == typeof(IContractInstanceStore) ||
|
||||
d.ServiceType == typeof(IContractOutcomeStore) ||
|
||||
d.ServiceType == typeof(IFactionStandingStore) ||
|
||||
d.ServiceType == typeof(IReputationDeltaStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
|
|
@ -120,6 +122,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||
services.AddSingleton<IContractInstanceStore, InMemoryContractInstanceStore>();
|
||||
services.AddSingleton<IContractOutcomeStore, InMemoryContractOutcomeStore>();
|
||||
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
|
||||
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Applies NEO-29 hotbar loadout table DDL once per process.</summary>
|
||||
public static class PostgresHotbarLoadoutBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V002__player_hotbar_loadout.sql");
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ public static class AbilityDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(abilitiesDirectory, "*_abilities.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(abilitiesDirectory, "*_abilities.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_abilities.json files under {abilitiesDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public sealed class AbilityDefinitionRegistry(AbilityDefinitionCatalog catalog)
|
|||
|
||||
private static IReadOnlyList<AbilityDefRow> BuildDefinitionsInIdOrder(AbilityDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<AbilityDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -62,9 +62,8 @@ public static class ContractTemplateCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_contract_templates.json files under {contractsDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public sealed class ContractTemplateRegistry(ContractTemplateCatalog catalog) :
|
|||
/// <inheritdoc />
|
||||
public IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder()
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<ContractTemplateRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
list.Add(catalog.ById[id]);
|
||||
|
|
|
|||
|
|
@ -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,279 @@
|
|||
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 string PlayerLockKey(string player) => $"{player}\0active";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var lockKey = ContractInstanceIds.MakeInstanceKey(player, instanceId);
|
||||
if (lockKey.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (instanceLocks.GetOrAdd(lockKey, _ => 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;
|
||||
}
|
||||
|
||||
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
||||
{
|
||||
if (!activeInstanceByPlayer.TryGetValue(player, out var instanceId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
||||
row.Status != ContractInstanceStatus.Active ||
|
||||
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
||||
{
|
||||
activeInstanceByPlayer.TryRemove(new KeyValuePair<string, string>(player, instanceId));
|
||||
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(PlayerLockKey(player), _ => new object()))
|
||||
{
|
||||
lock (instanceLocks.GetOrAdd(ContractInstanceIds.MakeInstanceKey(player, 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(new KeyValuePair<string, string>(player, existingActiveId));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var lockKey = ContractInstanceIds.MakeInstanceKey(player, instanceId);
|
||||
if (lockKey.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
||||
{
|
||||
lock (instanceLocks.GetOrAdd(lockKey, _ => 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(new KeyValuePair<string, string>(player, instanceId));
|
||||
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;
|
||||
}
|
||||
|
||||
var lockKey = ContractInstanceIds.MakeInstanceKey(player, instanceId);
|
||||
if (lockKey.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
||||
{
|
||||
lock (instanceLocks.GetOrAdd(lockKey, _ => new object()))
|
||||
{
|
||||
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
||||
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_ = byInstanceId.TryRemove(instanceId, out _);
|
||||
if (row.Status == ContractInstanceStatus.Active)
|
||||
{
|
||||
activeInstanceByPlayer.TryRemove(new KeyValuePair<string, string>(player, instanceId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
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(IContractInstanceStore instanceStore) : 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;
|
||||
}
|
||||
|
||||
if (!instanceStore.TryGet(normalized.PlayerId, normalized.ContractInstanceId, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var appended = false;
|
||||
LockRowAndIdempotencyKey(normalized.Id, normalized.IdempotencyKey, () =>
|
||||
{
|
||||
if (rowsById.ContainsKey(normalized.Id) ||
|
||||
rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
rowsById[normalized.Id] = normalized;
|
||||
rowIdByIdempotencyKey[normalized.IdempotencyKey] = normalized.Id;
|
||||
appended = true;
|
||||
});
|
||||
|
||||
return appended;
|
||||
}
|
||||
|
||||
private static string IdempotencyLockKey(string idempotencyKey) => $"idem\0{idempotencyKey}";
|
||||
|
||||
private void LockRowAndIdempotencyKey(string rowId, string idempotencyKey, Action action)
|
||||
{
|
||||
var idemLockKey = IdempotencyLockKey(idempotencyKey);
|
||||
if (string.CompareOrdinal(rowId, idemLockKey) <= 0)
|
||||
{
|
||||
lock (idLocks.GetOrAdd(rowId, _ => new object()))
|
||||
{
|
||||
lock (idLocks.GetOrAdd(idemLockKey, _ => new object()))
|
||||
{
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lock (idLocks.GetOrAdd(idemLockKey, _ => new object()))
|
||||
{
|
||||
lock (idLocks.GetOrAdd(rowId, _ => new object()))
|
||||
{
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryClearForInstance(string contractInstanceId)
|
||||
{
|
||||
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
||||
if (instanceId.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] idsToRemove = [.. rowsById.Values
|
||||
.Where(row => string.Equals(row.ContractInstanceId, instanceId, StringComparison.Ordinal))
|
||||
.Select(static row => row.Id)];
|
||||
|
||||
foreach (var id in idsToRemove)
|
||||
{
|
||||
if (rowsById.TryRemove(id, out var removed))
|
||||
{
|
||||
rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _);
|
||||
idLocks.TryRemove(IdempotencyLockKey(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],
|
||||
GrantedSkillXp = [..row.GrantedSkillXp],
|
||||
GrantedReputation = [..row.GrantedReputation],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using System.Threading;
|
||||
|
||||
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 Lock 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,43 @@
|
|||
using System.Threading;
|
||||
|
||||
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 Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
PostgresContractInstanceBootstrap.EnsureSchema(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,215 @@
|
|||
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)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM contract_instance
|
||||
WHERE contract_instance_id = @instance_id AND player_id = @pid);
|
||||
""",
|
||||
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);
|
||||
try
|
||||
{
|
||||
return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
catch (Npgsql.PostgresException ex) when (
|
||||
ex.SqlState == Npgsql.PostgresErrorCodes.ForeignKeyViolation ||
|
||||
ex.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 RewardItemGrantApplied[] DeserializeItems(string json)
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<RewardItemGrantApplied[]>(json, JsonOptions);
|
||||
return parsed ?? [];
|
||||
}
|
||||
|
||||
private static RewardSkillXpGrantApplied[] DeserializeSkillXp(string json)
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<RewardSkillXpGrantApplied[]>(json, JsonOptions);
|
||||
return parsed ?? [];
|
||||
}
|
||||
|
||||
private static 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],
|
||||
GrantedSkillXp = [..row.GrantedSkillXp],
|
||||
GrantedReputation = [..row.GrantedReputation],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -36,9 +36,8 @@ public static class RecipeDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(recipesDirectory, "*_recipes.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(recipesDirectory, "*_recipes.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_recipes.json files under {recipesDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public sealed class RecipeDefinitionRegistry(RecipeDefinitionCatalog catalog) :
|
|||
|
||||
private static IReadOnlyList<RecipeDefRow> BuildDefinitionsInIdOrder(RecipeDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<RecipeDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -30,9 +30,8 @@ public static class EncounterDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(encountersDirectory, "*_encounters.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(encountersDirectory, "*_encounters.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_encounters.json files under {encountersDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public sealed class EncounterDefinitionRegistry(EncounterDefinitionCatalog catal
|
|||
|
||||
private static IReadOnlyList<EncounterDefRow> BuildDefinitionsInIdOrder(EncounterDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<EncounterDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ public static class EncounterProgressApi
|
|||
{
|
||||
var hasProgress = progressStore.TryGetProgress(playerId, encounterId, out var progress);
|
||||
var defeatedTargetIds = hasProgress
|
||||
? progress.DefeatedNpcInstanceIds.OrderBy(static id => id, StringComparer.Ordinal).ToArray()
|
||||
? [.. progress.DefeatedNpcInstanceIds.OrderBy(static id => id, StringComparer.Ordinal)]
|
||||
: Array.Empty<string>();
|
||||
|
||||
if (completionStore.IsCompleted(playerId, encounterId))
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEven
|
|||
|
||||
eventsByKey[key] = completeEvent with
|
||||
{
|
||||
GrantedItems = completeEvent.GrantedItems.ToArray(),
|
||||
GrantedItems = [..completeEvent.GrantedItems],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@ public static class RewardTableDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(rewardTablesDirectory, "*_reward_tables.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(rewardTablesDirectory, "*_reward_tables.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_reward_tables.json files under {rewardTablesDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public sealed class RewardTableDefinitionRegistry(RewardTableDefinitionCatalog c
|
|||
|
||||
private static IReadOnlyList<RewardTableDefRow> BuildDefinitionsInIdOrder(RewardTableDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<RewardTableDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ public static class FactionDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(factionsDirectory, "*_factions.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(factionsDirectory, "*_factions.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_factions.json files under {factionsDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public sealed class FactionDefinitionRegistry(FactionDefinitionCatalog catalog)
|
|||
/// <inheritdoc />
|
||||
public IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder()
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<FactionDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
|
|||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return query.ToArray();
|
||||
return [..query];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -71,13 +71,12 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
|
|||
return false;
|
||||
}
|
||||
|
||||
var idsToRemove = rowsById.Values
|
||||
string[] idsToRemove = [.. rowsById.Values
|
||||
.Where(row =>
|
||||
string.Equals(row.PlayerId, player, StringComparison.Ordinal) &&
|
||||
string.Equals(row.SourceKind, ReputationDeltaSourceKinds.QuestCompletion, StringComparison.Ordinal) &&
|
||||
string.Equals(row.SourceId, sourceId, StringComparison.Ordinal))
|
||||
.Select(static row => row.Id)
|
||||
.ToArray();
|
||||
.Select(static row => row.Id)];
|
||||
|
||||
foreach (var id in idsToRemove)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Applies NEO-135 faction standing table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresPlayerFactionStandingBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V009__player_faction_standing.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Factions;
|
||||
|
||||
/// <summary>Applies NEO-135 reputation delta audit table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresReputationDeltaBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V010__reputation_delta_audit.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>Applies NEO-61 resource-node instance table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresResourceNodeInstanceBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V006__resource_node_instance.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,15 +35,13 @@ public static class ResourceNodeCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var nodeJsonFiles = Directory.GetFiles(resourceNodesDirectory, "*_resource_nodes.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] nodeJsonFiles = [.. Directory.GetFiles(resourceNodesDirectory, "*_resource_nodes.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (nodeJsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_resource_nodes.json files under {resourceNodesDirectory}");
|
||||
|
||||
var yieldJsonFiles = Directory.GetFiles(resourceNodesDirectory, "*_resource_yields.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] yieldJsonFiles = [.. Directory.GetFiles(resourceNodesDirectory, "*_resource_yields.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (yieldJsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_resource_yields.json files under {resourceNodesDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ public enum ResourceNodeInstanceMutationKind
|
|||
Applied,
|
||||
}
|
||||
|
||||
/// <param name="ReasonCode">Populated when <see cref="Kind"/> is <see cref="ResourceNodeInstanceMutationKind.Denied"/>.</param>
|
||||
/// <param name="Snapshot">Authoritative snapshot after apply, or current snapshot on depletion deny; null for unknown node.</param>
|
||||
/// <summary>
|
||||
/// Result of <see cref="ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement"/> (NEO-61).
|
||||
/// <see cref="ReasonCode"/> is populated when <see cref="Kind"/> is <see cref="ResourceNodeInstanceMutationKind.Denied"/>.
|
||||
/// <see cref="Snapshot"/> is the authoritative snapshot after apply, or the current snapshot on depletion deny; null for unknown node.
|
||||
/// </summary>
|
||||
public readonly record struct ResourceNodeInstanceMutationOutcome(
|
||||
ResourceNodeInstanceMutationKind Kind,
|
||||
string? ReasonCode,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Applies NEO-44 gig progression table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresGigProgressionBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V007__player_gig_progression.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ public static class ItemDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(itemsDirectory, "*_items.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(itemsDirectory, "*_items.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_items.json files under {itemsDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public sealed class ItemDefinitionRegistry(ItemDefinitionCatalog catalog) : IIte
|
|||
/// <inheritdoc />
|
||||
public IReadOnlyList<ItemDefRow> GetDefinitionsInIdOrder()
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<ItemDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ public enum PlayerInventoryMutationKind
|
|||
Applied,
|
||||
}
|
||||
|
||||
/// <param name="ReasonCode">Populated when <see cref="Kind"/> is <see cref="PlayerInventoryMutationKind.Denied"/>.</param>
|
||||
/// <param name="Snapshot">Authoritative snapshot after apply, or unchanged snapshot on deny; null when store missing.</param>
|
||||
/// <summary>
|
||||
/// Result of <see cref="PlayerInventoryOperations.TryAddStack"/> or <see cref="PlayerInventoryOperations.TryRemoveStack"/> (NEO-54).
|
||||
/// <see cref="ReasonCode"/> is populated when <see cref="Kind"/> is <see cref="PlayerInventoryMutationKind.Denied"/>.
|
||||
/// <see cref="Snapshot"/> is the authoritative snapshot after apply, or unchanged snapshot on deny; null when store missing.
|
||||
/// </summary>
|
||||
public readonly record struct PlayerInventoryMutationOutcome(
|
||||
PlayerInventoryMutationKind Kind,
|
||||
string? ReasonCode,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Items;
|
||||
|
||||
/// <summary>Applies NEO-54 inventory table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresPlayerInventoryBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V005__player_inventory.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@ public static class MasteryCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(masteryDirectory, "*_mastery.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(masteryDirectory, "*_mastery.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_mastery.json files under {masteryDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>Applies NEO-47 perk state table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresPerkStateBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V004__player_perk_state.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ public static class NpcBehaviorDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(npcBehaviorsDirectory, "*_npc_behaviors.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(npcBehaviorsDirectory, "*_npc_behaviors.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_npc_behaviors.json files under {npcBehaviorsDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public sealed class NpcBehaviorDefinitionRegistry(NpcBehaviorDefinitionCatalog c
|
|||
|
||||
private static IReadOnlyList<NpcBehaviorDefRow> BuildDefinitionsInIdOrder(NpcBehaviorDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<NpcBehaviorDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -8,7 +10,7 @@ public static class PostgresPositionBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V001__player_position.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
private static int _schemaReady;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -36,10 +38,9 @@ public static class PostgresPositionBootstrap
|
|||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using (var cmd = new Npgsql.NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
|
||||
System.Threading.Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Applies NEO-116 quest progress table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresPlayerQuestProgressBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V008__player_quest_progress.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -126,14 +126,12 @@ public static class PrototypeE7M2QuestCatalogRules
|
|||
|
||||
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
|
||||
new(
|
||||
bundle.ItemGrants
|
||||
[.. bundle.ItemGrants
|
||||
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Quantity)
|
||||
.ToArray(),
|
||||
bundle.SkillXpGrants
|
||||
.ThenBy(g => g.Quantity)],
|
||||
[.. bundle.SkillXpGrants
|
||||
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Amount)
|
||||
.ToArray());
|
||||
.ThenBy(g => g.Amount)]);
|
||||
|
||||
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ public static class PrototypeE7M3QuestFactionRules
|
|||
/// <summary>Returns a human-readable error if grid contract quest shape fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetGridContractShapeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
|
||||
{
|
||||
var qid = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||
const string qid = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
|
||||
if (!rowsById.TryGetValue(qid, out var row))
|
||||
return $"error: missing quest '{qid}'";
|
||||
|
||||
|
|
@ -161,18 +161,15 @@ public static class PrototypeE7M3QuestFactionRules
|
|||
|
||||
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
|
||||
new(
|
||||
bundle.ItemGrants
|
||||
[.. bundle.ItemGrants
|
||||
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Quantity)
|
||||
.ToArray(),
|
||||
bundle.SkillXpGrants
|
||||
.ThenBy(g => g.Quantity)],
|
||||
[.. bundle.SkillXpGrants
|
||||
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Amount)
|
||||
.ToArray(),
|
||||
bundle.ReputationGrants
|
||||
.ThenBy(g => g.Amount)],
|
||||
[.. bundle.ReputationGrants
|
||||
.OrderBy(g => g.FactionId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Amount)
|
||||
.ToArray());
|
||||
.ThenBy(g => g.Amount)]);
|
||||
|
||||
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,9 +64,8 @@ public static class QuestDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(questsDirectory, "*_quests.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(questsDirectory, "*_quests.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_quests.json files under {questsDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public sealed class QuestDefinitionRegistry(QuestDefinitionCatalog catalog) : IQ
|
|||
|
||||
private static IReadOnlyList<QuestDefRow> BuildDefinitionsInIdOrder(QuestDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<QuestDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
|
|||
|
||||
eventsByKey[key] = deliveryEvent with
|
||||
{
|
||||
GrantedItems = deliveryEvent.GrantedItems.ToArray(),
|
||||
GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(),
|
||||
GrantedReputation = deliveryEvent.GrantedReputation.ToArray(),
|
||||
GrantedItems = [..deliveryEvent.GrantedItems],
|
||||
GrantedSkillXp = [..deliveryEvent.GrantedSkillXp],
|
||||
GrantedReputation = [..deliveryEvent.GrantedReputation],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Threading;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Applies NEO-38 skill progression table DDL once per process.</summary>
|
||||
|
|
@ -5,7 +7,7 @@ public static class PostgresSkillProgressionBootstrap
|
|||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V003__player_skill_progression.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
private static readonly Lock SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ public static class SkillDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(skillsDirectory, "*_skills.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] jsonFiles = [.. Directory.GetFiles(skillsDirectory, "*_skills.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_skills.json files under {skillsDirectory}");
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public sealed class SkillDefinitionRegistry(SkillDefinitionCatalog catalog) : IS
|
|||
/// <inheritdoc />
|
||||
public IReadOnlyList<SkillDefRow> GetDefinitionsInIdOrder()
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
string[] ids = [.. catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal)];
|
||||
var list = new List<SkillDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
|||
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
||||
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddContractTemplateCatalog(builder.Configuration);
|
||||
builder.Services.AddContractInstanceStores(builder.Configuration);
|
||||
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
||||
builder.Services.AddRewardDeliveryStore();
|
||||
builder.Services.AddFactionStandingStores(builder.Configuration);
|
||||
|
|
|
|||
|
|
@ -269,6 +269,36 @@ Bundle- and gate-related schemas resolve as siblings under **`{parent of contrac
|
|||
|
||||
On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code.
|
||||
|
||||
## Contract instance store (NEO-146)
|
||||
|
||||
Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed by **`contractInstanceId`** with at most **one active** row per player (prototype policy). Missing row ⇒ no instance; **`TryCreateActive`** creates an **`active`** row with template id, seed bucket, and issue timestamp. **`ContractGeneratorOperations`** (NEO-147) and **`ContractCompletionOperations`** (NEO-149) should call the store through orchestrators — not HTTP directly.
|
||||
|
||||
**Store interface methods:**
|
||||
|
||||
- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`).
|
||||
- **`TryGet`** — read one instance by player + instance id; false when absent.
|
||||
- **`TryGetActiveForPlayer`** — read the player's single active instance, if any.
|
||||
- **`TryCreateActive`** — first active for player returns `true`; denies when another active exists, duplicate instance id, or player not writable.
|
||||
- **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**.
|
||||
- **`TryClearInstance`** — dev fixture only; removes one instance row.
|
||||
|
||||
Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation against **`IContractTemplateRegistry`** is deferred to NEO-147.
|
||||
|
||||
**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresContractInstanceStore`** persists to **`contract_instance`** ([`V011__contract_instance.sql`](../db/migrations/V011__contract_instance.sql)) with a partial unique index enforcing one active row per player. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md).
|
||||
|
||||
## Contract outcome store (NEO-146)
|
||||
|
||||
Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.
|
||||
|
||||
**Store interface methods:**
|
||||
|
||||
- **`TryAppend`** — first row id or idempotency key returns `true`; duplicates return `false`. Missing **`contract_instance`** row, or a **`player_id`** that does not match the instance owner, returns `false` (Postgres SQL guard + FK/unique catch; in-memory checks **`IContractInstanceStore.TryGet`**) — orchestrators (NEO-149) must create the instance before appending an outcome.
|
||||
- **`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 (**`InMemoryContractOutcomeStore`** requires a matching instance row in **`IContractInstanceStore`**); **`PostgresContractOutcomeStore`** appends to **`contract_outcome`** ([`V012__contract_outcome.sql`](../db/migrations/V012__contract_outcome.sql)) with JSONB grant snapshots when configured. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md).
|
||||
|
||||
## Quest definitions (NEO-115)
|
||||
|
||||
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
|
|
|
|||
|
|
@ -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