Compare commits

...

30 Commits

Author SHA1 Message Date
VinPropane 177b316efa
Merge pull request #191 from ViPro-Technologies/chore/cleanup
chore: server and test analyzer cleanup
2026-06-27 23:32:04 -04:00
VinPropane 299de3c796 chore: address cleanup review follow-ups and Postgres test flake
Fix gig persistence integration flake with FakeTimeProvider on the Postgres
host, restore StringComparer.Ordinal on quest counter copies, remove the Bruno
docs block, and record resolved review items.
2026-06-27 23:24:18 -04:00
VinPropane b233fa040d chore: analyzer cleanup for server and tests
Apply Roslyn/dotnet-format fixes across NeonSprawl.Server and
NeonSprawl.Server.Tests: collection expressions, concrete helper
return types, record XML docs, logging guards, quest store refactors,
and removal of redundant usings via GlobalUsings.cs. Extend
csharp-style.md with the recurring patterns.
2026-06-27 22:55:50 -04:00
VinPropane 2cf8bcd0b9
Merge pull request #190 from ViPro-Technologies/NEO-149-e7m4-06-contract-completion-encounter-wiring
NEO-149: E7M4-06 contract completion on encounter clear
2026-06-27 22:05:41 -04:00
VinPropane c5b8b7a726 NEO-149: re-review after code review follow-ups 2026-06-27 21:58:26 -04:00
VinPropane eb6a4fedde NEO-149: fix CraftResult and craft API analyzer warnings
Move CraftResult param docs to primary-constructor summaries (RCS1263),
use CraftIoAppliedJson[] return and collection expression in MapIoRows,
prefer is true for debug log guard (CDT1001), and add MapResponse deny test.
2026-06-27 21:56:52 -04:00
VinPropane 6f735f8643 NEO-149: fix analyzer warnings in encounter wiring
RCS1123 parentheses in quest wiring test, CA1873 guarded LogDebug,
CA1859 List parameter for CompensatingRemoveGrants.
2026-06-27 21:52:13 -04:00
VinPropane 68ba26724b NEO-149: address code review follow-ups
Update E7.M4 alignment register, add quest-fixture contract reset
API test, align mark-complete deny codes with instance reason codes,
refactor idempotent replay test AAA, strike review findings done.
2026-06-27 21:51:17 -04:00
VinPropane d1df940084 NEO-149: add code review for contract completion wiring 2026-06-27 21:50:13 -04:00
VinPropane bcf6fd2641 NEO-149: wire contract completion on encounter clear
Add ContractCompletionOperations orchestrator (deliver-then-mark,
outcome audit), hook from EncounterCompletionOperations after E5.M3
loot and quest wiring, extend dev fixture reset for contract rows,
Bruno quest-fixture reset request, and cover with tests (904 green).
2026-06-27 21:48:17 -04:00
VinPropane 8d9d02fde5 NEO-149: add E7M4-06 implementation plan for contract completion wiring
Kickoff plan for ContractCompletionOperations orchestrator, encounter
hook after E5.M3 loot, and integration test coverage; Bruno HTTP loop
deferred to NEO-151.
2026-06-27 21:43:46 -04:00
VinPropane 36ffec94e9
Merge pull request #189 from ViPro-Technologies/NEO-148-e7m4-05-reward-router-contract-completion
NEO-148: E7M4-05 contract completion reward router
2026-06-27 21:41:26 -04:00
VinPropane 7268935bc7 NEO-148: address code review doc follow-ups
Add E7.M4 alignment register entry, E7.M2 cross-ref, Postgres store-key
migration note, and defer contract fixture reset to NEO-149.
2026-06-27 21:24:12 -04:00
VinPropane e75ac3932f NEO-148: add code review for contract reward router 2026-06-27 19:59:31 -04:00
VinPropane 76ce1943c7 NEO-148: add contract completion reward router and delivery store extension
TryDeliverContractCompletion reuses the quest grant-apply path with
contract-specific idempotency keys and source-kind-aware delivery store rows.
2026-06-27 19:58:07 -04:00
VinPropane b20d7d001e NEO-148: add E7M4-05 implementation plan for contract reward router 2026-06-27 19:54:16 -04:00
VinPropane 52ab7dbec2
Merge pull request #188 from ViPro-Technologies/NEO-147-e7m4-04-contract-generator-try-issue
NEO-147: E7M4-04 contract generator TryIssue
2026-06-27 19:50:36 -04:00
VinPropane c237bead53 NEO-147: guard DenyCreateFailure when create snapshot is null 2026-06-27 19:43:44 -04:00
VinPropane 81f4cde673 chore: remove redundant test namespace using in gate tests 2026-06-27 19:40:13 -04:00
VinPropane 58a188eac0 chore: dedupe faction id constants in gate operation tests 2026-06-27 19:30:28 -04:00
VinPropane 39805ef8e6 chore: fix QuestDefinitionCatalogLoader analyzer warnings 2026-06-27 19:26:52 -04:00
VinPropane fbb248770f NEO-147: update decomposition tracking and strike review findings 2026-06-27 19:23:17 -04:00
VinPropane 3549b647a5 NEO-147: add standing and duplicate-seed review follow-up tests 2026-06-27 19:23:17 -04:00
VinPropane be0525b152 NEO-147: add code review for contract generator TryIssue 2026-06-27 19:21:24 -04:00
VinPropane c37c3c76a3 NEO-147: use class-level grid faction id in generator tests 2026-06-27 19:18:23 -04:00
VinPropane 44f4e4c3ce NEO-147: mark faction id locals const in generator tests 2026-06-27 19:15:43 -04:00
VinPropane 60aa0be4f0 NEO-147: document generator operations and reconcile plan 2026-06-27 19:15:14 -04:00
VinPropane 87a0eb25ec NEO-147: add contract generator unit and integration tests 2026-06-27 19:15:14 -04:00
VinPropane 7f62f43f92 NEO-147: add ContractGeneratorOperations.TryIssue orchestrator 2026-06-27 19:15:11 -04:00
VinPropane e0efaa3c15 NEO-147: add E7M4-04 contract generator kickoff plan 2026-06-27 19:12:27 -04:00
265 changed files with 3636 additions and 1112 deletions

View File

@ -50,31 +50,57 @@ if (string.IsNullOrEmpty(key))
return false;
```
- **Indentation:** **4 spaces** per level; no tabs unless the file already uses tabs—never mix.
- **Trailing whitespace:** do not leave spaces or tabs at the end of a line, including blank lines between statements (RCS1037).
- **`var`:** use when the type is obvious from the right-hand side; use explicit types when it improves readability or for literals where the type matters.
- **File-scoped namespaces** (`namespace X;`) for new single-namespace files when the SDK/version allows.
- **Empty marker types** (collection fixtures, `[CollectionDefinition]`, marker interfaces): omit `{ }` and end with `;` when the type has no members (RCS1251).
- **Pattern matching / nullability:** prefer modern C# features where they simplify code; honor **nullable reference type** annotations when the project enables them.
## Collection expressions (IDE0305)
## Collection expressions (IDE0300 / IDE0305)
- Prefer **`["a", "b"]`** over **`new[] { "a", "b" }`** for inline string (and other) array/list literals.
- 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:
- In **assertions**, avoid passing a bare spread directly to APIs with span overloads (e.g. `Assert.Equal`) — assign to a typed local first, or use a **`private static readonly`** field when the same literal is reused (CA1861):
```csharp
// Prefer
return [.. query];
GrantedItems = [.. row.GrantedItems],
new SkillDefRow("salvage", "gather", "Salvage", ["activity", "mission_reward"]),
// 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);
// Reused expected literals in tests
private static readonly string[] ExpectedSkillIds = ["intrusion", "refine", "salvage"];
Assert.Equal(ExpectedSkillIds, ids);
// Avoid (ambiguous Assert overloads; unnecessary allocation helper)
Assert.Equal(expectedOrder, [.. body.Quests.Select(static row => row.QuestId)]);
return query.ToArray();
new[] { "activity", "mission_reward" }
```
## Target-typed `new` (IDE0090)
- When the return type or variable type is already explicit, prefer **`new()`** over repeating the type name in object initializers (common in test request helpers):
```csharp
private static SkillProgressionGrantRequest Grant(...) =>
new()
{
SchemaVersion = schemaVersion,
SkillId = skillId,
};
```
## Async pass-through (CDT1003)
- Do not mark a method **`async`** when it only **`await`**s and returns a single **`Task`** — return that task directly with an expression body or **`return`** statement.
## `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).
@ -83,15 +109,19 @@ return query.ToArray();
## 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`**.
- When a local is initialized from a **compile-time constant** (string literal, raw string literal, numeric literal, or `public const` field), declare it **`const`** instead of **`var`**.
```csharp
const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
const string fixtureJson = """
{ "schemaVersion": 1 }
""";
```
## Inline declarations (IDE0018)
## Inline declarations (IDE0018 / IDE0059)
- 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.
- Do **not** pre-initialize locals with **`default`**, **`null`**, or **`false`** when the variable is assigned in the next block before its first read (IDE0059) — declare without an initializer instead.
```csharp
// Prefer — single disposable, single follow-on statement
@ -119,8 +149,27 @@ var store = secondScope.ServiceProvider.GetRequiredService<IExampleStore>(); //
## 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.
- When a private helper always returns a materialized array or list, prefer the **concrete type** as the return type (e.g. `List<T>`, `T[]`, `Dictionary<,>`) instead of `IReadOnlyList<T>` / `IReadOnlyDictionary<,>` when callers only need the materialized value and analyzers suggest it.
- Same rule applies to **`BuildDefinitionsInIdOrder`**, **`MapSlots`**, **`ReadBindings`**, and similar helpers that always construct a new collection.
## Logging guards (CA1873)
- Before **`LogInformation`**, **`LogDebug`**, or other structured logs whose arguments allocate (interpolated strings, exceptions, collection counts), guard with **`logger.IsEnabled(LogLevel.…)`** (or **`logger?.IsEnabled(...) is true`** when the logger is nullable).
```csharp
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Loaded catalog from {Path} with {Count} rows.", path, rows.Count);
}
```
## SQL parameter names (RCS1015)
- When an ADO.NET / Npgsql parameter name matches a local variable, prefer **`nameof(variable)`** over a string literal: **`AddWithValue(nameof(xp), xp)`**.
## Arithmetic clarity (RCS1123)
- In mixed products inside calls (e.g. **`Math.Sqrt((dx * dx) + (dz * dz))`**), parenthesize each product so precedence is explicit.
- Prefer **expression-bodied** members only when they stay one clear idea.
- **LINQ:** favor readability over micro-chains; break complex queries across lines.
@ -133,7 +182,14 @@ var store = secondScope.ServiceProvider.GetRequiredService<IExampleStore>(); //
## 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.
- **Primary-constructor records:** include a **`<summary>`** on the type. Document positional parameters with **`<see cref="PropertyName"/>`** inside the summary — **never** **`/// <param>`** on **`record struct`** or **`record class`** primary constructors (Roslynator **RCS1263** false positive; [roslynator#1730](https://github.com/dotnet/roslynator/issues/1730)).
- **Property / field docs:** every **`///`** block must include a **`<summary>`** — do not ship **`<remarks>`**-only comments (RCS1139). Put extra detail in **`<remarks>`** after the summary when needed.
```csharp
/// <summary>Deny reason when <see cref="Granted"/> is <c>false</c>.</summary>
/// <remarks>Stable snake_case values; see README.</remarks>
public string? ReasonCode { get; init; }
```
## Null checks (IDE0270)
@ -207,3 +263,4 @@ public async Task PostExample_ShouldReturnOk_WhenBodyValid()
## Tooling
- Prefer fixes that satisfy built-in **.NET analyzers** / `dotnet format` (if adopted) rather than fighting IDE warnings without reason.
- Remove **unnecessary `using` directives** (IDE0005 / CS8019) — with **implicit usings** and **global usings**, many `Microsoft.Extensions.*`, `System.Threading`, and `System.Linq` imports are redundant. Run `dotnet format <project>.csproj --diagnostics IDE0005 --severity info` before merge on touched projects.

View File

@ -0,0 +1,35 @@
meta {
name: Reset contract instance via quest fixture (NEO-149)
type: http
seq: 2
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/__dev/quest-fixture
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"resetContractInstanceIds": [
"ci_00000000000000000000000000000000"
]
}
}
docs {
NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent).
Full issue → clear → GET Bruno loop lands in NEO-151 when contract HTTP ships.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("applied true", function () {
expect(res.getBody().applied).to.equal(true);
});
}

View File

@ -5,7 +5,7 @@ meta {
}
docs {
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). No inventory POST shortcuts for spine materials.
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). Deny envelopes return empty inputsConsumed/outputsGranted arrays. No inventory POST shortcuts for spine materials.
}
script:pre-request {

View File

@ -66,6 +66,20 @@ async function resetPrototypeFactionStanding(bru) {
}
}
async function resetContractInstances(bru, contractInstanceIds) {
const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru);
const response = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{ schemaVersion: 1, resetContractInstanceIds: contractInstanceIds },
jsonHeaders,
);
if (response.status !== 200 || response.data?.applied !== true) {
throw new Error(
`quest-fixture resetContractInstanceIds failed: ${response.status} ${JSON.stringify(response.data)}`,
);
}
}
async function assertAllPrototypeQuestsNotStarted(bru) {
const { baseUrl, playerId } = resolveConfig(bru);
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
@ -111,6 +125,7 @@ module.exports = {
resetGatherIntroQuestProgress,
resetGatherIntroSpine,
resetPrototypeFactionStanding,
resetContractInstances,
clearInventory,
assertAllPrototypeQuestsNotStarted,
ALL_PROTOTYPE_QUEST_IDS,

View File

@ -72,6 +72,8 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).
**NEO-148 (E7.M4 contract reuse):** **`RewardRouterOperations.TryDeliverContractCompletion`** reuses the same **`IRewardDeliveryStore`** and shared **`TryDeliverBundle`** core for contract template **`completionRewardBundle`** rows (idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, **`SourceKind = contract_completion`**). See [NEO-148 implementation plan](../../plans/NEO-148-implementation-plan.md); [E7.M4 — ContractMissionGenerator](E7_M4_ContractMissionGenerator.md).
**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
**NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
@ -81,3 +83,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-131 (E7M2-08 client HUD):** Godot **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection paints **`completionRewardSummary`** grant lines when a quest newly becomes **`completed`** in-session (parse via **`quest_progress_client.gd`**). See [NEO-131 implementation plan](../../plans/NEO-131-implementation-plan.md); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131).
**NEO-132 (E7M2-09 client capstone):** playable four-quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); economy HUD refresh on completion transition via **`quest_completion_reward_transition`** signal. Epic 7 Slice 2 client capstone complete.
**NEO-148 (E7.M4 contract router reuse):** [E7.M4](E7_M4_ContractMissionGenerator.md) **`TryDeliverContractCompletion`** extends the same **`IRewardDeliveryStore`** + shared grant-apply core with **`contract_completion`** source kind and key **`{playerId}:contract_complete:{contractInstanceId}`** — see [NEO-148 implementation plan](../../plans/NEO-148-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).

View File

@ -57,6 +57,12 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**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).
**Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147).
**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127).
**Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149).
## 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

View File

@ -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)); **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).
**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)); **E7M4-04 / NEO-147** contract generator **`TryIssue`** landed ([NEO-147 plan](../../plans/NEO-147-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

View File

@ -0,0 +1,204 @@
# NEO-147 — E7M4-04: ContractGeneratorOperations.TryIssue
**Linear:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)
**Branch:** `NEO-147-e7m4-04-contract-generator-try-issue`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-04**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-117-implementation-plan.md](NEO-117-implementation-plan.md) (`QuestStateOperations` orchestrator + reason codes); [NEO-146-implementation-plan.md](NEO-146-implementation-plan.md) (instance store primitives); [NEO-145-implementation-plan.md](NEO-145-implementation-plan.md) (`IContractTemplateRegistry`)
**Precursor:** [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) **`Done`** — `IContractInstanceStore`, `IContractOutcomeStore` (**on `main`**)
**Blocks:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) (E7M4-06 completion wiring), [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) (E7M4-07 economy lint at issue)
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (blocked by E7M4-08 HTTP — not this story)
## Goal
Issue a validated **`active`** contract instance from **`ContractSeed`** inputs + player context via **`ContractGeneratorOperations.TryIssue`**. Template selection respects zone difficulty band and faction standing floor; persist through **`IContractInstanceStore.TryCreateActive`**.
## Kickoff clarifications
**No blocking decisions needed.** Linear AC, [E7M4-04 backlog scope](E7M4-pre-production-backlog.md#e7m4-04--contractgeneratoroperationstryissue), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and NEO-117/NEO-146 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Economy validation at issue | **Out of scope** — lands in E7M4-07 / NEO-150 | Backlog E7M4-04 out-of-scope; NEO-145 plan defers issue-time band-cap enforcement |
| HTTP / Godot | **Out of scope** — E7M4-08 / NEO-151 | Backlog E7M4-04 |
| Encounter completion wiring | **Out of scope** — E7M4-06 / NEO-149 | Backlog E7M4-04 |
| Optional `templateId` | **Operations layer accepts null/empty** — auto-select from band + standing filters | Backlog inputs list; HTTP schema (`contract-seed.schema.json`) keeps required `templateId` for POST body in NEO-151 |
| `zoneDifficultyBand` default | **Default 1** when omitted | Backlog + kickoff table “prototype defaults to 1” |
| Template selection (multi-match) | **First by `GetDefinitionsInIdOrder()`** among eligible rows | Deterministic with future multi-template catalogs; prototype has one row |
| Faction filter | **Reuse `FactionGateOperations.TryEvaluate`** on template `MinFactionStanding` (single rule → one-element list) | Quest accept precedent (NEO-137); template row uses `FactionGateRuleRow` |
| Active cap deny | **`active_contract_exists`** before template selection | Backlog AC + store reason code already defined |
| Unknown explicit template | **`unknown_template`** when id not in registry | Backlog “unknown template fail-closed” |
| No eligible template | **`no_eligible_template`** when filter yields zero rows (band/standing mismatch) | Backlog template selection |
| Deterministic instance id | **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 over `{playerId}|{templateId}|{seedBucket}` (normalized), format **`ci_{32 hex}`** lowercase | Backlog seed/determinism row; ids match `[a-z][a-z0-9_]*` store normalization |
| Result shape | **`ContractIssueOperationResult`** — `success`, `reasonCode`, `ContractInstanceState?` snapshot, **`EncounterTemplateId`** from selected template | AC “correct encounter binding”; HTTP projection in NEO-151 can map snapshot + encounter |
| Telemetry | **Comment-only hook site** deferred to E7M4-09 / NEO-152 | Backlog E7M4-09 lists `contract_issued` in TryIssue |
| Store boundary | **Orchestrator validates template + gates**; store persists ids only (NEO-146 precedent) | README contract instance store section |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`ContractGeneratorOperations.TryIssue`** static orchestrator in `Game/Contracts/`.
- **`ContractGeneratorReasonCodes`** + **`ContractIssueOperationResult`** envelope.
- **`ContractInstanceIds.MakeDeterministicInstanceId`** helper.
- Inputs: `playerId`, optional `templateId`, `seedBucket`, optional `zoneDifficultyBand` (default **1**), injected registries/stores + `TimeProvider`.
- Template selection: band match + faction standing floor; structured deny paths.
- **`TryCreateActive`** persist with deterministic instance id + `issuedAt` from `TimeProvider`.
- Unit tests (AAA): happy issue, second issue while active deny, unknown template, no eligible template (band mismatch), player not writable, invalid ids, deterministic id stability.
- Integration test via **`InMemoryWebApplicationFactory`**: full DI path issue → active snapshot with **`prototype_combat_pocket`** encounter binding.
- Optional Postgres integration test (`RequirePostgresFact`): issue persists cross-factory (mirror NEO-146 persistence tests).
- `server/README.md` contract generator operations section.
**Out of scope (from Linear + backlog):**
- **`POST …/contracts/issue`** HTTP + DTOs (NEO-151 / E7M4-08).
- **`ContractEconomyValidation`** at issue (NEO-150 / E7M4-07).
- **`ContractCompletionOperations`** + encounter wiring (NEO-149 / E7M4-06).
- **`RewardRouterOperations.TryDeliverContractCompletion`** (NEO-148 / E7M4-05).
- Godot client (NEO-153+).
- Telemetry comment hooks (NEO-152 / E7M4-09).
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — server HTTP must land in NEO-151 first.
## Acceptance criteria checklist
- [x] Issue creates **`active`** instance with correct encounter binding.
- [x] Second issue while active returns structured deny.
- [x] `dotnet test` covers generator gates.
## Implementation reconciliation (shipped)
- **Types:** `ContractGeneratorReasonCodes`, `ContractIssueOperationResult`, `ContractInstanceIds.MakeDeterministicInstanceId`.
- **Orchestrator:** `ContractGeneratorOperations.TryIssue` — band + faction template selection, deterministic id, store persist.
- **Tests:** `ContractGeneratorOperationsTests` (14 unit), `ContractGeneratorOperationsIntegrationTests` (DI), `ContractGeneratorPersistenceIntegrationTests` (`RequirePostgresFact`); **891** tests green.
- **Docs:** `server/README.md` contract generator section; `E7_M4_ContractMissionGenerator.md` NEO-147 note.
## Technical approach
### 1. Types (`Game/Contracts/`)
| Type | Role |
|------|------|
| `ContractGeneratorReasonCodes` | `player_not_writable`, `active_contract_exists`, `no_eligible_template`, `unknown_template`, `invalid_ids` |
| `ContractIssueOperationResult` | `Success`, `ReasonCode?`, `ContractInstanceState?` Snapshot, `EncounterTemplateId?` |
| `ContractInstanceIds.MakeDeterministicInstanceId` | SHA-256 deterministic id (see kickoff table) |
Reuse **`ContractInstanceReasonCodes.ActiveContractExists`** string value in generator denials for consistency (`active_contract_exists`).
### 2. `ContractGeneratorOperations.TryIssue`
Signature (conceptual):
```csharp
public static ContractIssueOperationResult TryIssue(
string playerId,
string? templateId,
string seedBucket,
int? zoneDifficultyBand,
IContractTemplateRegistry templateRegistry,
IContractInstanceStore instanceStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider)
```
**Flow:**
1. Normalize `playerId`, `seedBucket`; resolve band = `zoneDifficultyBand ?? 1`.
2. Deny **`invalid_ids`** when player or seed bucket empty after normalize.
3. Deny **`player_not_writable`** when `!instanceStore.CanWritePlayer(playerId)`.
4. Deny **`active_contract_exists`** when `instanceStore.TryGetActiveForPlayer` returns true (include existing snapshot in result when helpful for tests — mirror quest `AlreadyActive`).
5. **Resolve template:**
- **Explicit `templateId`:** `TryGetDefinition`**`unknown_template`** if missing; else require `ZoneDifficultyBand == band` and `FactionGateOperations.TryEvaluate` passes on `[MinFactionStanding]` → else **`no_eligible_template`**.
- **Omitted/empty `templateId`:** filter `GetDefinitionsInIdOrder()` where band matches and faction gate passes; zero rows → **`no_eligible_template`**; pick first.
6. `instanceId = MakeDeterministicInstanceId(playerId, template.Id, seedBucket)`; deny **`invalid_ids`** if empty.
7. `instanceStore.TryCreateActive(playerId, instanceId, template.Id, seedBucket, timeProvider.GetUtcNow(), out snapshot)` — expect `true`; on false (race), re-read active and deny **`active_contract_exists`**.
8. Return success with snapshot + `template.EncounterTemplateId`.
No outcome-store writes in E7M4-04.
### 3. Template selection helpers (private static in same file)
- `TrySelectTemplate(...)` — keeps `TryIssue` readable; unit-test selection indirectly through public API.
- Prototype happy path: band **1**, open standing **0**, single template **`prototype_contract_clear_combat_pocket`** → encounter **`prototype_combat_pocket`**.
### 4. Tests
**`ContractGeneratorOperationsTests`** (in-memory deps, no host):
| Test | Covers |
|------|--------|
| Happy issue | Active snapshot, template id, seed bucket, encounter binding |
| Deterministic id | Same inputs → same `contractInstanceId`; different seed bucket → different id |
| Second issue while active | `active_contract_exists`, snapshot unchanged |
| Issue after complete | New instance allowed when no active row (complete via store, then issue) |
| Unknown explicit template | `unknown_template` |
| Band mismatch | `no_eligible_template` (request band 99) |
| Player not writable | `player_not_writable` |
| Empty player / seed bucket | `invalid_ids` |
**`ContractGeneratorOperationsIntegrationTests`** (optional single `InMemoryWebApplicationFactory` test):
- Resolve stores + registry from DI; issue for dev player; assert encounter id.
**`ContractGeneratorPersistenceIntegrationTests`** (`RequirePostgresFact`, optional):
- Factory A issues; factory B reads active instance by player.
**Host smoke:** extend or add `Host_ShouldIssueContractViaGeneratorOperations` if useful; not required if integration test covers DI.
### 5. Docs
- **`server/README.md`** — new **Contract generator operations (NEO-147)** section: inputs, selection rules, reason codes, deterministic id shape, orchestrator-only (no HTTP).
- One-line note in **`E7_M4_ContractMissionGenerator.md`** runtime paragraph when implementation lands.
### 6. Implementation order
1. `ContractGeneratorReasonCodes`, `ContractIssueOperationResult`, `MakeDeterministicInstanceId`.
2. `ContractGeneratorOperations.TryIssue` + selection helpers.
3. Unit tests → integration test → optional Postgres test.
4. `server/README.md`.
5. Run `dotnet test`.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-147-implementation-plan.md` | This plan |
| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs` | Stable generator deny reason strings |
| `server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs` | Issue operation envelope |
| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs` | `TryIssue` orchestrator |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs` | AAA unit tests for issue/deny paths |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs` | DI integration test via test host |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs` | Postgres cross-factory issue read (`RequirePostgresFact`) |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs` | Add `MakeDeterministicInstanceId` |
| `server/README.md` | Document contract generator operations |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | Note NEO-147 generator operations landed (post-implementation) |
## Tests
| Layer | What |
|-------|------|
| `ContractGeneratorOperationsTests` | Happy issue, encounter binding, deterministic id, active deny, post-complete re-issue, unknown template, band mismatch, writable gate, invalid ids |
| `ContractGeneratorOperationsIntegrationTests` | Full DI path with loaded prototype catalog |
| `ContractGeneratorPersistenceIntegrationTests` | Optional Postgres cross-factory issue read (`RequirePostgresFact`) |
| CI | Existing `dotnet test`; no Python/content changes expected |
Manual Godot QA: **none** (server orchestrator; capstone NEO-154).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Explicit template fails band/standing vs `unknown_template` | Use **`no_eligible_template`** (template known but ineligible); reserve **`unknown_template`** for missing registry id | `adopted` |
| Re-issue same seed after complete → same instance id collision | **`TryCreateActive`** denies duplicate instance id; orchestrator should treat as success-with-existing or deny — **deny duplicate id**, caller must rotate `seedBucket` for new contract | `adopted` |
| Economy caps slipped past CI | Accept for E7M4-04; NEO-150 adds issue-time lint | `deferred` |
| Telemetry `contract_issued` comment | Defer to NEO-152; no hook in E7M4-04 unless zero-cost comment added | `deferred` |
## Client counterpart
Server-only orchestrator. Playable issue UI: **NEO-153** after **NEO-151** HTTP.

View File

@ -0,0 +1,172 @@
# NEO-148 — E7M4-05: Extend RewardRouterOperations for contract completion bundles
**Linear:** [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)
**Branch:** `NEO-148-e7m4-05-reward-router-contract-completion`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-05**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-127-implementation-plan.md](NEO-127-implementation-plan.md) — grant order + compensating rollback + `TryRecord` race mirror; [NEO-138-implementation-plan.md](NEO-138-implementation-plan.md) — reputation grants + store snapshot extension
**Precursors:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) **`Done`** — contract template catalog load; [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) **`Done`** — `ContractOutcomeIds.MakeIdempotencyKey`, grant snapshot types
**Blocks:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) (E7M4-06 `ContractCompletionOperations` + encounter wiring)
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (blocked by E7M4-08; no Godot work in this story)
## Goal
Contract completion payouts reuse E7.M2 delivery idempotency with contract-specific keys (`{playerId}:contract_complete:{contractInstanceId}`).
## Kickoff clarifications
**No blocking decisions needed.** Linear AC, [E7M4-05 backlog scope](E7M4-pre-production-backlog.md#e7m4-05--extend-rewardrouteroperations-for-contract-completion-bundles), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-127/NEO-138/NEO-146 code settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Idempotency key | **`ContractOutcomeIds.MakeIdempotencyKey`** → `{playerId}:contract_complete:{contractInstanceId}` | E7M4 kickoff table; NEO-146 shipped helper |
| Bundle DTO | Reuse **`QuestRewardBundleRow`** on **`ContractTemplateRow.CompletionRewardBundle`** | NEO-145 loader; prototype freeze table |
| Grant kinds + order | Items → skill XP → reputation; compensating rollback on partial deny and **`TryRecord`** race loss | E7M4-05 backlog “mirrors E7.M2 quest path”; NEO-127/NEO-138 |
| Delivery store | Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** with **`contract_completion`** source kind | E7M4-05 backlog in-scope list |
| Outcome audit store | **Out of scope****`IContractOutcomeStore.TryAppend`** is E7M4-06 orchestrator responsibility | NEO-146 open-questions table (“orchestrator fills from router result”) |
| Reputation source | **`ReputationDeltaSourceKinds.ContractCompletion`** + **`contractInstanceId`** as **`sourceId`** | NEO-138 **`QuestCompletion`** precedent |
| Skill XP source kind | **`mission_reward`** (same as quest completion) | Prototype contract freeze; NEO-127 |
| Router shape | **`TryDeliverContractCompletion`** public entry + private shared grant-apply core extracted from quest path | Avoid ~200-line duplication while keeping **`TryDeliverQuestCompletion`** signature stable |
| Store API | Source-kind-aware **`TryGet`/`TryRecord`/`TryClear`**; existing quest methods remain thin wrappers | Backlog “extend store for contract source kind”; backward compat for NEO-126 callers |
| **`IContractOutcomeStore` replay** | Router idempotency uses **`IRewardDeliveryStore`** only; outcome store is separate audit trail | E7M4-05 vs E7M4-06 scope split |
| Postgres delivery store | **Deferred** — in-memory only (same as quest delivery today) | NEO-126/NEO-138 precedent |
| Encounter / HTTP wiring | **Out of scope** | E7M4-06, E7M4-08 |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`RewardRouterOperations.TryDeliverContractCompletion`** — same grant kinds as quest completion with contract idempotency key.
- Extend **`IRewardDeliveryStore`**, **`RewardDeliveryEvent`**, **`RewardDeliveryIds`**, **`InMemoryRewardDeliveryStore`** for **`contract_completion`** source kind.
- Add **`ReputationDeltaSourceKinds.ContractCompletion`**.
- Compensating rollback policy mirrors E7.M2 quest path (items, skill XP, reputation, perk revert, race loser).
- Integration tests: first delivery, idempotent replay, unknown item/faction deny.
- `server/README.md` reward router + delivery store sections.
**Out of scope (from Linear + backlog):**
- **`ContractCompletionOperations`** + encounter wiring (NEO-149 / E7M4-06).
- **`IContractOutcomeStore.TryAppend`** from router (orchestrator in E7M4-06).
- HTTP contract projections (NEO-151 / E7M4-08).
- E9.M1 telemetry ingest (comment-only hook sites optional; not required by AC).
- Godot client (NEO-153+).
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract HUD after E7M4-08.
## Acceptance criteria checklist
- [x] First-time contract complete applies bundle exactly once per instance id.
- [x] Replay returns **`already_delivered`** without double grant.
## Implementation reconciliation (shipped)
- **Types:** `RewardDeliverySourceKinds`; `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`; `RewardDeliveryReasonCodes.InvalidContractInstanceId`; `ReputationDeltaSourceKinds.ContractCompletion`.
- **Store:** `IRewardDeliveryStore` source-kind-aware `TryGet`/`TryClear`; `InMemoryRewardDeliveryStore` keys `{playerId}\0{sourceKind}\0{sourceId}`.
- **Router:** `TryDeliverContractCompletion` + private shared `TryDeliverBundle` core; quest path unchanged at public API.
- **Tests:** 5 contract router tests + 1 store isolation test; full suite **897** tests green.
- **Docs:** `server/README.md` delivery store + router sections; E7.M4 module anchor.
## Technical approach
### 1. Types (`Game/Rewards/`)
| Type | Change |
|------|--------|
| **`RewardDeliverySourceKinds`** (new) | Stable strings: **`quest_completion`**, **`contract_completion`** |
| **`RewardDeliveryEvent`** | Add **`SourceKind`**; add **`ContractInstanceId`** (empty for quest rows); quest rows keep **`QuestId`** populated |
| **`RewardDeliveryIds`** | Add **`NormalizeContractInstanceId`** (delegate to **`ContractInstanceIds`**); **`MakeContractStoreKey(playerId, contractInstanceId)`**; extend **`MakeStoreKey`** to include source kind `{p}\0{kind}\0{id}` |
| **`RewardDeliveryReasonCodes`** | Add **`invalid_contract_instance_id`** |
| **`IRewardDeliveryStore`** | Add **`TryGet(playerId, sourceKind, sourceId, …)`**, **`TryRecord`**, **`TryClear`** overloads; keep existing quest methods as wrappers calling **`quest_completion`** |
| **`InMemoryRewardDeliveryStore`** | Index by source-kind-aware store key; quest + contract rows isolated |
Add **`ReputationDeltaSourceKinds.ContractCompletion = "contract_completion"`** in `Game/Factions/`.
### 2. `RewardRouterOperations`
**New public method:**
```csharp
public static RewardDeliveryResult TryDeliverContractCompletion(
string playerId,
string contractInstanceId,
QuestRewardBundleRow? bundle,
/* same dependency injection as TryDeliverQuestCompletion */)
```
**Flow** (mirror quest):
1. Normalize **`playerId`** + **`contractInstanceId`**; deny on empty ids.
2. **`deliveryStore.TryGet(playerId, contract_completion, contractInstanceId)`** — idempotent replay → **`already_delivered`** success without re-grant.
3. Resolve bundle grant lists (null → empty).
4. Apply items → skill XP → reputation with **`ContractOutcomeIds.MakeIdempotencyKey`** and **`ReputationDeltaSourceKinds.ContractCompletion`** + **`contractInstanceId`** as rep **`sourceId`**.
5. Build **`RewardDeliveryEvent`** with **`SourceKind = contract_completion`**, **`ContractInstanceId`**, empty **`QuestId`**, grant snapshots, idempotency key.
6. **`TryRecord`**; on race loss, compensating rollback all grant types and return winner event.
**Refactor:** Extract private **`TryDeliverBundleCore`** (or equivalent) parameterized by delivery context (source kind, source id, idempotency key factory, rep source kind/id) so quest and contract paths share rollback/apply logic. **`TryDeliverQuestCompletion`** remains a thin wrapper — no signature change for callers.
### 3. Prototype test fixtures
Use frozen template bundle from **`prototype_contract_clear_combat_pocket`**:
- **`scrap_metal_bulk` ×5**, **`salvage` 15 XP** (no rep in prototype contract).
Contract instance id: synthetic test value (e.g. **`prototype_contract_clear_combat_pocket:test-seed-001`**) — no generator dependency required for router unit tests.
### 4. Documentation
- **`server/README.md`**: extend reward delivery store table with **`SourceKind`** + contract key; add **`TryDeliverContractCompletion`** section under reward router with reason codes (reuse quest passthrough set + **`invalid_contract_instance_id`**).
- Update **`E7_M4_ContractMissionGenerator.md`** Slice 4 anchor when shipped (router landed).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs` | Stable **`quest_completion`** / **`contract_completion`** source kind constants |
*(No other new source files expected — router + store extensions land in existing files.)*
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | Add **`TryDeliverContractCompletion`**; extract shared grant-apply core |
| `server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs` | Source-kind-aware **`TryGet`/`TryRecord`/`TryClear`** overloads |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Add **`SourceKind`**, **`ContractInstanceId`** |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | Contract instance normalization + source-kind store keys |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | **`invalid_contract_instance_id`** |
| `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Source-kind-aware indexing |
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | **`ContractCompletion`** constant |
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Contract delivery happy path, replay, deny tests |
| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Contract source kind idempotency at store layer |
| `server/README.md` | Delivery store + router contract completion docs |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | Slice 4 router anchor when shipped |
## Tests
| File | Coverage |
|------|----------|
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | **`TryDeliverContractCompletion_ShouldApplyPrototypeBundle_WhenFirstDelivery`** — items + skill XP applied once; store record with **`contract_completion`** key |
| Same | **`TryDeliverContractCompletion_ShouldReturnAlreadyDelivered_WhenReplayed`** — second call success + **`already_delivered`**; inventory/xp unchanged |
| Same | **`TryDeliverContractCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord`** — rep grant with unknown faction; no delivery row |
| Same | **`TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord`** — unknown item / full bag deny (backlog “unknown item/faction deny”) |
| Same | **`TryDeliverContractCompletion_ShouldDenyInvalidContractInstanceId_WhenEmpty`** — fail-closed id validation |
| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Contract **`TryRecord`** idempotency + source-kind isolation from quest rows |
All new tests follow AAA with **`// Arrange` / `// Act` / `// Assert`** labels per [csharp-style](../.cursor/rules/csharp-style.md).
Manual Godot QA: **none** (server engine; capstone NEO-154).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Shared-core refactor touches quest path | Keep **`TryDeliverQuestCompletion`** as thin wrapper; run full existing **`RewardRouterOperationsTests`** suite unchanged | `adopted` |
| Quest vs contract store key collision | Prefix store key with **`SourceKind`** — ids already namespaced (`prototype_quest_*` vs generated instance ids) | `adopted` |
| Rep grants on future contract templates | Apply path ready; prototype template has no rep rows — test unknown-faction deny with synthetic bundle | `adopted` |
| **`IContractOutcomeStore`** vs delivery store duplication | Delivery store = grant idempotency (E7.M2 pattern); outcome store = audit append from E7M4-06 orchestrator only | `adopted` |
| Contract delivery fixture reset | **`QuestFixtureOperations`** clears quest rows only today; add contract **`TryClear(playerId, contract_completion, instanceId)`** when E7M4-06+ Bruno loops land — track on **NEO-149** | `deferred` |
| Postgres store key migration | Pre-NEO-148 quest keys **`{playerId}\0{questId}`** → **`{playerId}\0{sourceKind}\0{sourceId}`**; migrate or document at Postgres delivery persistence | `deferred` |
## Client counterpart
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after E7M4-08 HTTP. This story is server-only; Bruno/HTTP contract loop verification lands in E7M4-06+.

View File

@ -0,0 +1,179 @@
# NEO-149 — E7M4-06: ContractCompletionOperations + encounter wiring
**Linear:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)
**Branch:** `NEO-149-e7m4-06-contract-completion-encounter-wiring`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-06**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-128-implementation-plan.md](NEO-128-implementation-plan.md) — deliver-then-mark + idempotent replay; [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) — `ContractGeneratorOperations` orchestrator shape; [NEO-148-implementation-plan.md](NEO-148-implementation-plan.md) — `TryDeliverContractCompletion` + outcome audit split
**Precursors:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue`; [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148) **`Done`** — `RewardRouterOperations.TryDeliverContractCompletion`
**Blocks:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) (E7M4-08 HTTP contract projections + Bruno issue/clear loop)
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) (blocked by E7M4-08; no Godot work in this story)
## Goal
Encounter clear completes a matching **active** contract instance and triggers the template **`completionRewardBundle`** payout. First-time encounter loot (E5.M3) remains unchanged and runs **before** contract payout.
## Kickoff clarifications
**No blocking decisions needed.** Linear AC, [E7M4-06 backlog scope](E7M4-pre-production-backlog.md#e7m4-06--contractcompletionoperations--encounter-wiring), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-128/NEO-147/NEO-148 code settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Grant order vs encounter loot | **Encounter loot first**, contract payout second — contract wiring runs only after `TryCompleteAndGrant` commits encounter grants + completion mark | Backlog E7M4-06 in-scope wiring note; `EncounterCompletionOperations` already applies reward-table grants before downstream hooks |
| Deliver-then-mark | **`TryDeliverContractCompletion`** then **`IContractInstanceStore.TryMarkComplete`**; on delivery deny, instance stays **`active`** | NEO-128 quest precedent; README reward router § contract wiring note |
| Outcome audit | **`IContractOutcomeStore.TryAppend`** after successful delivery + mark; grant snapshots from **`RewardDeliveryResult`** / **`RewardDeliveryEvent`** | NEO-146 open-questions table; outcome store requires existing instance row |
| Encounter template match | Compare cleared **`encounterId`** to template **`encounterTemplateId`** on the player's active instance | Backlog “match encounterTemplateId”; prototype binds **`prototype_combat_pocket`** |
| No active / wrong encounter | **Structured no-op success** — do not fail encounter completion | Mirror quest wiring best-effort; encounter already committed |
| Idempotent replay | When instance already **`completed`**, return success without re-delivering (router **`already_delivered`** path if invoked) | NEO-128 + store **`TryMarkComplete`** replay semantics |
| Wiring call site | **`EncounterCompletionOperations.TryCompleteAndGrant`** after **`QuestObjectiveWiring.TryProcessEncounterCompleteSuccess`** | Same hook point as quest credit; `EncounterCombatWiring` unchanged except extended dependency pass-through |
| Best-effort | Contract deny does **not** roll back encounter loot or completion mark | Encounter grants committed before contract hook; quest wiring precedent |
| Bruno smoke (`issue → clear → GET`) | **Deferred to NEO-151** — no contract HTTP routes yet | E7M4-08 scope; this story covers orchestrator + encounter wiring + integration tests |
| Contract fixture reset | Add contract delivery **`TryClear`** helper for Bruno/dev loops (NEO-148 deferred item) | NEO-148 plan open questions |
| Telemetry | **Comment-only** `contract_complete` hook in orchestrator — E7M4-09 / NEO-152 | Backlog E7M4-09 |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`ContractCompletionOperations.TryCompleteOnEncounterClear`** static orchestrator in `Game/Contracts/`.
- **`ContractCompletionReasonCodes`** + **`ContractCompletionOperationResult`** envelope.
- Wire from **`EncounterCompletionOperations`** success path (after encounter loot + quest wiring).
- Pass contract dependencies through **`EncounterCombatWiring`** → **`AbilityCastApi`** (mirror quest store injection).
- Flow: resolve active instance → match encounter → deliver bundle → mark instance **`completed`** → append **`ContractOutcomeRow`**.
- Integration tests: issue → encounter clear → instance **`completed`** + delivery row + outcome row; clear without contract unchanged; wrong encounter no-op; encounter first-time loot idempotency unchanged.
- Extend **`QuestFixtureOperations`** (or adjacent dev fixture) with contract delivery **`TryClear`** for test/Bruno reset loops.
- `server/README.md` contract completion operations section; E7.M4 module anchor.
**Out of scope (from Linear + backlog):**
- HTTP **`POST …/contracts/issue`**, contract GET projections (NEO-151 / E7M4-08).
- Bruno smoke requiring HTTP (NEO-151).
- **`ContractEconomyValidation`** at issue (NEO-150 / E7M4-07).
- Godot HUD (NEO-153 / NEO-154).
- E9.M1 telemetry ingest (comment-only hook sites optional).
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) — server HTTP must land in NEO-151 first.
## Acceptance criteria checklist
- [x] Active contract completes when bound encounter clears.
- [x] Encounter first-time loot idempotency unchanged (E5.M3).
## Implementation reconciliation (shipped)
- **Types:** `ContractCompletionReasonCodes`, `ContractCompletionOperationResult`.
- **Orchestrator:** `ContractCompletionOperations.TryCompleteOnEncounterClear` — deliver-then-mark, outcome append, no-op on mismatch/no active.
- **Wiring:** `EncounterCompletionOperations` → contract hook after quest wiring; `EncounterCombatWiring` / `AbilityCastApi` pass contract DI.
- **Fixture:** `QuestFixtureRequest.resetContractInstanceIds` clears instance + delivery + outcome rows.
- **Tests:** 5 unit + 1 integration + 1 encounter wiring test; full suite **904** tests green.
- **Docs:** `server/README.md` contract completion section; E7.M4 module anchor.
## Technical approach
### 1. Types (`Game/Contracts/`)
| Type | Role |
|------|------|
| **`ContractCompletionReasonCodes`** | Stable codes: **`no_active_contract`**, **`encounter_mismatch`**, **`unknown_template`**, **`player_not_writable`**, **`invalid_ids`**; passthrough router codes on delivery deny (e.g. **`inventory_full`**, **`already_delivered`**) |
| **`ContractCompletionOperationResult`** | `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, optional **`ContractOutcomeRow`** |
### 2. `ContractCompletionOperations.TryCompleteOnEncounterClear`
**Inputs:** `playerId`, `encounterId`, injected `IContractInstanceStore`, `IContractOutcomeStore`, `IContractTemplateRegistry`, reward-router dependencies (same set as **`QuestStateOperations.TryMarkComplete`**), `TimeProvider`.
**Flow:**
1. Normalize **`playerId`** + **`encounterId`**; deny **`invalid_ids`** when empty.
2. **`instanceStore.TryGetActiveForPlayer`** — no active row ⇒ success no-op (**`no_active_contract`**, or null reason for silent no-op — prefer explicit reason in tests, null in wiring if best-effort silent).
3. Load template via **`templateRegistry.TryGetDefinition(active.TemplateId)`**; deny **`unknown_template`** when missing.
4. Normalize template **`encounterTemplateId`** vs cleared **`encounterId`** — mismatch ⇒ success no-op (**`encounter_mismatch`**).
5. If active snapshot status is already **`completed`** (defensive) ⇒ idempotent success.
6. **`RewardRouterOperations.TryDeliverContractCompletion`** with template **`CompletionRewardBundle`**.
7. On delivery deny ⇒ return deny with router **`reasonCode`**; instance stays **`active`**.
8. **`instanceStore.TryMarkComplete`** with **`timeProvider.GetUtcNow()`**; handle store replay idempotency (completed snapshot ⇒ success).
9. Build **`ContractOutcomeRow`** from delivery grants + **`ContractOutcomeIds.MakeIdempotencyKey`**; **`outcomeStore.TryAppend`** (best-effort if duplicate idempotency key after race — treat as success when instance already completed).
10. Comment-only **`contract_complete`** telemetry hook site (NEO-152).
**No-op vs deny:** Structural preconditions that mean “this encounter clear is not a contract objective” (**no active**, **encounter mismatch**) return **`success: true`** so encounter completion is never blocked. Delivery/store failures return **`success: false`**.
### 3. Encounter wiring
In **`EncounterCompletionOperations.TryCompleteAndGrant`**, after encounter event record + **`QuestObjectiveWiring.TryProcessEncounterCompleteSuccess`**, call:
```csharp
ContractCompletionOperations.TryCompleteOnEncounterClear(
normalizedPlayerId,
normalizedEncounterId,
/* contract + router deps */);
```
Extend **`EncounterCombatWiring.TryProcessCastOutcome`** and **`AbilityCastApi`** to resolve and pass:
- **`IContractInstanceStore`**
- **`IContractOutcomeStore`**
- **`IContractTemplateRegistry`**
(Mirror existing quest registry / progress store injection.)
### 4. Dev fixture reset (NEO-148 follow-on)
Extend **`QuestFixtureOperations`** reset body (or parallel contract fixture helper) to clear contract delivery rows via **`IRewardDeliveryStore.TryClear(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId)`** when resetting contract test state — enables future Bruno loops in NEO-151.
### 5. Documentation
- **`server/README.md`**: new **Contract completion operations (NEO-149)** section — orchestrator flow, reason codes, deliver-then-mark, outcome append, encounter wiring order.
- Update **`E7_M4_ContractMissionGenerator.md`** Slice 4 anchor when shipped.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionReasonCodes.cs` | Stable deny / no-op reason codes |
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperationResult.cs` | Orchestrator result envelope |
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs` | **`TryCompleteOnEncounterClear`** orchestrator |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs` | Unit tests — match/mismatch, deliver-then-mark, idempotent replay, delivery deny leaves active |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs` | DI path: issue → encounter complete → completed instance + delivery + outcome |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Call contract completion after quest wiring on successful encounter grant |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass contract store/registry deps to **`TryCompleteAndGrant`** |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Resolve contract DI services for combat wiring |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Update dependency resolution + assert encounter loot unchanged when contract completes |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | Pass contract deps; optional end-to-end issue → cast → contract completed |
| `server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs` | Contract delivery **`TryClear`** on fixture reset (NEO-148 deferred) |
| `server/README.md` | Contract completion operations + wiring order docs |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-149 completion wiring anchor when shipped |
## Tests
| File | Coverage |
|------|----------|
| `ContractCompletionOperationsTests.cs` | **`TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter`** — issue via generator helper, complete on **`prototype_combat_pocket`**, assert **`completed`**, delivery store row, outcome row, bundle grants |
| Same | **`TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract`** — encounter clear succeeds structurally; no delivery row |
| Same | **`TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch`** — active contract bound to combat pocket; clear different encounter id ⇒ still **`active`** |
| Same | **`TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies`** — full inventory / deny path; instance stays **`active`**, no outcome row |
| Same | **`TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed`** — second call after complete returns success without double grant |
| `ContractCompletionOperationsIntegrationTests.cs` | Full DI: **`TryIssue`** → **`TryCompleteAndGrant`** → stores consistent via **`InMemoryWebApplicationFactory`** |
| `EncounterCompletionOperationsTests.cs` | **`TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated`** still passes with contract deps wired; add case with active contract asserting **both** encounter loot (×10 scrap + token) **and** contract bundle (×5 scrap + salvage XP) |
| `EncounterCombatWiringTests.cs` | Update all **`TryProcessCastOutcome`** call sites with contract deps (compile fix) |
All new tests follow AAA with **`// Arrange` / `// Act` / `// Assert`** labels per [csharp-style](../.cursor/rules/csharp-style.md).
Manual Godot QA: **none** (server engine; capstone NEO-154). Bruno HTTP loop: **NEO-151**.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Contract issued after encounter already cleared | Prototype flow is issue-then-clear; edge case leaves contract **`active`** until player can re-trigger (not possible in v1) — document in README; no v1 fix | `adopted` |
| Outcome append fails after mark complete | Treat duplicate idempotency append as success when instance **`completed`**; log at debug if needed | `adopted` |
| Large signature growth on encounter completion | Accept for prototype — mirror quest deps already on **`TryCompleteAndGrant`**; refactor to context object only if a follow-on story demands | `deferred` |
| Bruno smoke in backlog vs no HTTP | Integration tests + fixture reset in this story; Bruno **`issue → clear → GET`** in **NEO-151** | `adopted` |
| **`contract_complete` telemetry | Comment-only hook in orchestrator; NEO-152 fills payload | `deferred` |
## Client counterpart
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after E7M4-08 HTTP. [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) — Godot capstone. This story is server-only; Bruno verification of the full player loop lands with NEO-151.

View File

@ -0,0 +1,63 @@
# Code review — NEO-147 (E7M4-04 contract generator TryIssue)
**Date:** 2026-06-27
**Scope:** Branch `NEO-147-e7m4-04-contract-generator-try-issue` — commits `7f62f43`…`c37c3c7` (5 commits)
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
NEO-147 adds `ContractGeneratorOperations.TryIssue`, a server-authoritative orchestrator that selects a contract template (explicit id or auto-select by band + faction standing), mints a deterministic instance id (`ci_{32 hex}`), and persists via `IContractInstanceStore.TryCreateActive`. Reason codes align with NEO-146 store codes where shared; generator-specific codes cover unknown template and no eligible template. The flow mirrors `QuestStateOperations` / NEO-117 patterns: early player/active gates, delegated faction evaluation, structured deny envelopes with optional snapshots, and race-safe create-failure handling. Eleven AAA unit tests, one DI integration test, and one optional Postgres persistence test cover happy paths and primary deny gates; `dotnet test` passes **888** tests. Risk is low — orchestrator-only, no HTTP or client surface; downstream NEO-149/NEO-151 consume this layer.
## Documentation checked
| Document | Alignment |
|----------|-----------|
| `docs/plans/NEO-147-implementation-plan.md` | **Matches** — AC checklist complete; reconciliation reflects shipped types, orchestrator, tests (11 unit + DI + Postgres), README, module note. Minor drift: plan “Files to add” omits `ContractGeneratorPersistenceIntegrationTests.cs` (listed under Tests section only). |
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-04) | **Matches** — template selection, active cap, deterministic ids, structured denies; economy lint, HTTP, completion wiring correctly out of scope. |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-147 generator operations paragraph + README link added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E7.M4 row stops at E7M4-03/NEO-146; should add E7M4-04/NEO-147 generator ops after merge (same follow-up pattern as NEO-146).~~ **Done.** — E7M4-04 / NEO-147 generator ops added to E7.M4 row. |
| `docs/decomposition/modules/module_dependency_register.md` | ~~**Partially matches** — E7.M4 note ends at NEO-146; optional one-line NEO-147 after merge.~~ **Done.** — E7M4-04 / NEO-147 note added. |
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — server orchestrator; authority unchanged. |
| Full-stack epic decomposition | **N/A** — server-only; client counterpart NEO-153 correctly deferred until NEO-151 HTTP. |
~~Register/tracking tables should be updated for E7M4-04 after merge — **should fix** (non-blocking), mirroring NEO-146 alignment follow-up.~~ **Done.**
## Blocking issues
None.
## Suggestions
1. ~~**Update decomposition tracking for E7M4-04** — After merge, extend `documentation_and_implementation_alignment.md` E7.M4 row (and optionally `module_dependency_register.md`) with E7M4-04: `ContractGeneratorOperations.TryIssue`, reason codes, deterministic ids, README section — same pattern as NEO-145/NEO-146.~~ **Done.**
2. ~~**Faction standing deny path** — Plan and README document faction floor via `FactionGateOperations`; unit tests cover band mismatch but not standing failure → `no_eligible_template`. Prototype template min standing is **0** (always passes with default standing), so add a unit test with a synthetic template row (`minStanding: 1`) and zero player standing to lock the orchestrator wiring (distinct from `FactionGateOperationsTests`).~~ **Done.**`TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin`.
3. ~~**Duplicate instance id after complete** — Plan adopted: re-issue with the same `seedBucket` after completion hits store duplicate-id deny; orchestrator maps to `invalid_ids` via `DenyCreateFailure`. Add a unit test (complete first issue, re-issue same seed) to document caller contract (“rotate `seedBucket` for a new contract”).~~ **Done.**`TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete`.
## Nits
- ~~Nit: `MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket` has an empty `// Act` block — move equality assertions into Act or fold into Arrange per AAA convention.~~ **Done.**
- ~~Nit: `ContractGeneratorPersistenceIntegrationTests` mixes second-factory read into Act; acceptable but slightly blurs Act vs Assert compared to other integration tests.~~ **Done.** — cross-factory read moved to Assert.
- Nit: `DenyCreateFailure` fallback `invalid_ids` for duplicate completed instance id is correct per plan but coarse for future HTTP mappers — consider a dedicated reason code if NEO-151 needs clearer client messaging (defer unless HTTP design requires it).
## Verification
```bash
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractGenerator"
```
Optional (Postgres configured):
```bash
export ConnectionStrings__NeonSprawl='…'
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractGeneratorPersistence"
```
Manual Godot QA: **none** (server orchestrator per plan).

View File

@ -0,0 +1,56 @@
# Code review — NEO-148 contract completion reward router
**Date:** 2026-06-27
**Scope:** Branch `NEO-148-e7m4-05-reward-router-contract-completion` — commits `b20d7d0`..`76ce194` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-148 extends the E7.M2 reward delivery stack for contract completion: `RewardDeliverySourceKinds`, source-kind-aware `IRewardDeliveryStore` / `InMemoryRewardDeliveryStore` keys, `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`, `ReputationDeltaSourceKinds.ContractCompletion`, and `RewardRouterOperations.TryDeliverContractCompletion` with a shared private `TryDeliverBundle` core extracted from the quest path. Grant order, compensating rollback, and `TryRecord` race handling mirror NEO-127/NEO-138. Five contract router tests plus one store isolation test cover happy path, idempotent replay, faction deny, inventory deny, and invalid instance id. Implementation plan reconciliation, `server/README.md`, and E7.M4 module anchor are updated. Server-only slice; no client work expected. Full suite: **897** tests passed locally.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-148-implementation-plan.md` | **Matches** — AC checklist marked complete; shipped reconciliation aligns with diff |
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-05) | **Matches** — router + store extension, orchestrator/outcome store out of scope |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-148 router anchor added |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-148 contract reuse anchor added |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes NEO-148 |
| `server/README.md` | **Matches** — delivery store fields, `TryGet` overloads, contract router + reason codes |
| Client counterpart (NEO-153) | **N/A** — explicitly out of scope; no prototype-complete claim |
Register/tracking table updated for E7.M4 (NEO-148).
## Blocking issues
None.
## Suggestions
1. ~~**Update implementation alignment register** — Add **E7M4-05 / NEO-148 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (router + source-kind store extension), consistent with NEO-144NEO-147 entries.~~ **Done.**
2. ~~**Optional E7.M2 module cross-ref** — A one-line anchor on `E7_M2_RewardAndUnlockRouter.md` noting contract completion reuse via `TryDeliverContractCompletion` (NEO-148) would help readers who land on E7.M2 first.~~ **Done.**
3. ~~**Store key format note for future Postgres** — Quest store keys changed from `{playerId}\0{questId}` to `{playerId}\0{sourceKind}\0{sourceId}`. Harmless for in-memory prototype (process restart clears state). When Postgres delivery persistence lands, document or migrate old rows explicitly so idempotent replay cannot double-grant after upgrade.~~ **Done.** (`server/README.md` + plan open questions)
4. ~~**Contract fixture reset (follow-on)** — `QuestFixtureOperations` still clears quest rows via `TryClear(playerId, questId)` only. When E7M4-06+ adds Bruno contract loops, contract delivery rows will need a parallel reset path — track on NEO-149 or fixture story, not blocking here.~~ **Done.** (deferred to NEO-149 in plan open questions)
## Nits
- Nit: Plan test table lists “unknown item / full bag deny”; `TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord` covers full bag only. Shared `TryDeliverBundle` item path is quest-tested for inventory deny; an explicit `invalid_item` contract test would lock naming to AC but is low value given shared core.
- Nit: NEO-130 telemetry hook comments in `TryDeliverBundle` still reference `questId` in payload notes; fine until contract telemetry wiring — consider generalizing when E9.M1 touches contract completion.
- Nit: Plan mentions `MakeContractStoreKey`; implementation uses `MakeStoreKey(playerId, sourceKind, sourceId)` instead — equivalent, no action needed.
## Verification
```bash
dotnet test NeonSprawl.sln
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~RewardRouterOperationsTests|FullyQualifiedName~InMemoryRewardDeliveryStoreTests"
```
Manual Godot QA: none (server engine per plan).

View File

@ -0,0 +1,60 @@
# Code review — NEO-149 contract completion encounter wiring
**Date:** 2026-06-27
**Scope:** Branch `NEO-149-e7m4-06-contract-completion-encounter-wiring` — commits `8d9d02f`..`eb6a4fe` vs `origin/main`
**Base:** `origin/main`
**Follow-up:** Re-review after code-review follow-ups (`68ba267``eb6a4fe`).
## Verdict
**Approve**
## Summary
NEO-149 adds `ContractCompletionOperations.TryCompleteOnEncounterClear` — deliver-then-mark contract completion on encounter clear, following the NEO-128 quest precedent and NEO-148 router. Encounter wiring invokes the hook after encounter loot and quest objective wiring; contract payout failure does not block encounter success. Contract store/registry dependencies are plumbed through `EncounterCombatWiring` and `AbilityCastApi`. `QuestFixtureRequest.resetContractInstanceIds` clears instance, delivery, and outcome rows for Bruno/dev reset loops.
**Follow-up commits** address all prior review suggestions: E7.M4 alignment register updated, `PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided` added, `TryMarkComplete` failure path returns `player_not_writable` / `instance_not_active` / `instance_not_found` instead of `invalid_ids`, and idempotent replay test refactored with `IssueAndCompleteOnce`. Ancillary analyzer fixes in encounter wiring (debug log guard) and craft API (`CraftResult` XML docs, `MapIoRows` return type, deny empty IO arrays test) keep the branch clean; craft changes are orthogonal to contract completion but low risk.
Seven contract-related tests (5 unit + 1 integration + 1 encounter wiring) plus 1 quest-fixture API test cover the story. Full suite: **906** tests passed locally.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-149-implementation-plan.md` | **Matches** — AC checklist complete; shipped reconciliation aligns with diff (test count still cites 904 — minor drift) |
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-06) | **Matches** — orchestrator + encounter wiring in scope; HTTP Bruno loop deferred to NEO-151 |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-149 completion wiring anchor added |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes **E7M4-06 / NEO-149** |
| `server/README.md` | **Matches** — contract completion operations section, wiring order, reason codes, fixture reset |
| Client counterpart (NEO-153, NEO-154) | **N/A** — explicitly out of scope; no prototype-complete claim |
Register/tracking table updated for E7.M4 (NEO-149).
## Blocking issues
None.
## Suggestions
1. ~~**Update implementation alignment register** — Add **E7M4-06 / NEO-149 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (orchestrator + encounter wiring + quest-fixture reset), consistent with NEO-144NEO-148 entries.~~ **Done.** E7.M4 row + plan link updated (`68ba267`).
2. ~~**Quest-fixture test for contract reset** — `QuestFixtureOperations` now clears contract instance, delivery, and outcome rows via `resetContractInstanceIds`, but `QuestFixtureApiTests` has no parallel to `PostQuestFixture_ShouldClearRewardDeliveryAndAudit_WhenResetQuestIdsProvided`. A test that issues a contract, completes it, resets via fixture, and asserts stores are empty would lock the NEO-148 follow-on reset path.~~ **Done.** `PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided` (`68ba267`).
3. ~~**`TryMarkComplete` failure reason code** — When delivery succeeds but `TryMarkComplete` fails without leaving a completed snapshot, the orchestrator returns `invalid_ids`. `QuestStateOperations.TryMarkComplete` uses richer fallback codes. Consider aligning deny semantics.~~ **Done.** Final deny uses `player_not_writable`, `instance_not_active`, or `instance_not_found` (`68ba267`).
## Nits
- Nit: Idempotent replay after successful completion returns `no_active_contract` (instance no longer active) rather than hitting the defensive `completed` snapshot branch. Behavior is correct — no double grant — but the reason code differs from the plans “completed replay” wording. **Deferred** — acceptable for prototype.
- ~~Nit: `TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed` performs success assertions on the first completion inside **Arrange**; prefer moving first-call verification to **Assert** after **Act** for strict AAA, or extract a shared setup helper.~~ **Done.** `IssueAndCompleteOnce` setup helper (`68ba267`).
- Nit: Plan lists optional `EncounterCombatWiring` end-to-end issue → cast → contract completed; only compile-fix call-site updates landed. **Deferred** — integration + encounter completion tests cover the path.
- Nit: Bruno `Reset contract instance via quest fixture.bru` uses placeholder `ci_000…` id — fine until NEO-151 wires issue → clear → GET.
- Nit: Follow-up commits include craft API analyzer/doc fixes unrelated to E7M4-06 scope; harmless but could have been a separate chore commit.
## Verification
```bash
dotnet test NeonSprawl.sln
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractCompletionOperations|FullyQualifiedName~QuestFixtureApiTests.PostQuestFixture_ShouldClearContractStores"
```
Manual Godot QA: none (server engine per plan). Bruno issue → clear → GET loop: NEO-151.

View File

@ -0,0 +1,57 @@
# Code review — chore/cleanup analyzer pass
**Date:** 2026-06-27
**Scope:** Branch `chore/cleanup` — commit `b233fa0` vs `2cf8bcd` (merge base on `main`)
**Base:** `2cf8bcd` (merge of PR #190)
**Follow-up:** Suggestions and nits below are **done** (strikethrough + **Done.**).
## Verdict
**Approve with nits**
## Summary
This is a broad mechanical refactor across `NeonSprawl.Server` and `NeonSprawl.Server.Tests` (231 files, net 524 lines): collection expressions, primary-constructor catalog types, concrete private helper return types (CA1859), logging `IsEnabled` guards (CA1873), record XML doc fixes (RCS1263/RCS1139), `nameof` for SQL parameters, quest-store mutator delegate → `Func` + `MutateRowResult` record struct, removal of redundant `using` directives via new `GlobalUsings.cs`, and a substantial `csharp-style.md` appendix documenting the patterns. No HTTP routes, request bodies, or game contracts change. Build is clean (0 warnings). Full `dotnet test` passed **905/906** locally with one intermittent Postgres integration failure under parallel load; the same Postgres specs pass in isolation on this branch and on the parent commit.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/` (NEO-* / E7M4 stories) | **N/A** — unticketed `chore:` branch; no feature acceptance criteria |
| `.cursor/rules/csharp-style.md` | **Matches** — new sections document patterns applied in this diff (collection expressions, CA1859/CA1873, RCS1015, record XML docs, test GlobalUsings) |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A** — no module behavior or authority changes |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **N/A** — no implementation-status update required |
| `bruno/neon-sprawl-server/collection.bru` | **Matches**~~Partially matches~~ docs block removed (**Done.**) |
## Blocking issues
None.
## Suggestions
1. ~~**Remove the Bruno `docs { }` block** in `bruno/neon-sprawl-server/collection.bru`. The note (“chore/cleanup analyzer pass: no HTTP route or request-body changes”) belongs in the PR/commit message, not in the Bruno collection metadata consumers may read as API documentation.~~ **Done.** Removed the `docs { }` block from `collection.bru`.
2. ~~**Preserve explicit `StringComparer.Ordinal` in quest objective counter copies.** In `PostgresPlayerQuestStateStore.MutateObjectiveCounter`, the old code used `new Dictionary<string, int>(row.ObjectiveCounters, StringComparer.Ordinal)`. The new `ToDictionary` call relies on default string equality (ordinal in practice). For consistency with the rest of the quests module and defense-in-depth, pass `StringComparer.Ordinal` to `ToDictionary` or keep the dictionary copy constructor.~~ **Done.** `ToDictionary(..., StringComparer.Ordinal)` in `PostgresPlayerQuestStateStore.cs`.
3. ~~**Confirm Postgres integration stability under parallel xUnit.** Full-suite runs intermittently fail one of `FactionStandingPersistenceIntegrationTests`, `GigProgressionGrantPersistenceIntegrationTests`, or `SkillProgressionGrantPersistenceIntegrationTests` (expected persisted value vs `0`), while filtered runs pass on both `2cf8bcd` and `b233fa0`. This looks like pre-existing collection/fixture contention rather than a logic regression from this diff, but worth a quick check that `[Collection("Postgres integration")]` isolation is sufficient before merge if CI runs the full suite in parallel.~~ **Done.** Root cause for gig flake: `GigProgressionGrantPersistenceIntegrationTests` used wall-clock `Task.Delay` while cooldown uses `TimeProvider`; casts could return HTTP 200 with `on_cooldown` (`Accepted: false`) without defeat. Fixed by registering `FakeTimeProvider` on `PostgresWebApplicationFactory` and advancing the fake clock in the gig persistence test (same pattern as `InMemoryWebApplicationFactory`). Full suite **906/906** after fix.
## Nits
- ~~Nit: `GlobalUsings.cs` pulls in domain namespaces (`NeonSprawl.Server.Game.Npc`, `NeonSprawl.Server.Tests.Game.Npc`, etc.). Works today (0 build warnings), but a one-line file comment listing intentional globals would help future contributors avoid accidental name clashes.~~ **Done.** File header comment added.
- ~~Nit: `QuestProgressIds.cs` has a whitespace-only trailing blank line before the closing brace — harmless; could drop in a follow-up.~~ **Done.**
- Nit: `QuestStateOperations.DenyAccept` no longer threads `playerId` / `questId`; telemetry comment correctly says “capture at call site when wiring E9.M1.” Fine for now — just remember at E9.M1.
- Nit: `NpcRuntimeOperations.AdvanceAll` drops the postidle→aggro `TryGet` refresh; the while-loop `TryGet` on the first iteration makes this equivalent. No action needed.
- Nit: `PostgresPositionStateStore` drops unused `IOptions<GamePositionOptions>` / startup validation — correct dead-code removal; DI registration unchanged.
## Verification
```bash
cd server/NeonSprawl.Server && dotnet build
cd server/NeonSprawl.Server.Tests && dotnet test
cd server/NeonSprawl.Server.Tests && dotnet test --filter "FullyQualifiedName~PersistenceIntegrationTests"
```
Expect **906** passed; if a single Postgres persistence test fails under full parallel run, re-run filtered as above to distinguish flake from regression.
Manual Godot QA: none (no client or player-visible surface changes).

View File

@ -1,17 +1,10 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
@ -36,8 +29,8 @@ public sealed class AbilityCastApiTests
post.EnsureSuccessStatusCode();
}
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) =>
await LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId);
private static Task LockPrototypeTargetAlphaAsync(HttpClient client) =>
LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId);
private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId)
{

View File

@ -1,10 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;

View File

@ -1,8 +1,4 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.AbilityInput;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;

View File

@ -1,9 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.AbilityInput;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;

View File

@ -1,12 +1,6 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,10 +1,5 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Combat;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,9 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,10 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,13 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,13 +1,6 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,10 +1,6 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,13 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -1,7 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;

View File

@ -0,0 +1,167 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractCompletionOperationsIntegrationTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
private const string SeedBucket = "2026-06-22";
[Fact]
public async Task TryIssueThenEncounterComplete_ShouldMarkContractCompleted_WhenResolvedFromDi()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var deps = ResolveDependencies(factory);
var issued = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
Assert.True(issued.Success);
var instanceId = issued.Snapshot!.ContractInstanceId;
DefeatAllRequiredTargets(deps);
// Act
var encounterResult = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.TimeProvider);
// Assert
Assert.True(encounterResult.Success);
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(15, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
private static void DefeatAllRequiredTargets(IntegrationTestDependencies deps)
{
foreach (var npcId in new[] { MeleeNpc, RangedNpc, EliteNpc })
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
}
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static IntegrationTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
return new IntegrationTestDependencies(
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<TimeProvider>());
}
private sealed record IntegrationTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore,
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore EventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}

View File

@ -0,0 +1,262 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string MismatchEncounterId = "prototype_encounter_other";
private const string SeedBucket = "2026-06-22";
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(ContractInstanceStatus.Completed, result.Snapshot!.Status);
Assert.NotNull(result.Outcome);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out var delivery));
Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, delivery.SourceKind);
Assert.Equal(instanceId, delivery.ContractInstanceId);
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "scrap_metal_bulk") + 5,
CountBagItem(afterInventory!, "scrap_metal_bulk"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
Assert.Equal(15, salvageXp);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var stored));
Assert.Equal(ContractInstanceStatus.Completed, stored.Status);
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(result.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.Null(result.Outcome);
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
// Act
var result = CompleteOnEncounterClear(deps, MismatchEncounterId);
// Assert
Assert.True(result.Success);
Assert.Equal(ContractCompletionReasonCodes.EncounterMismatch, result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot!.Status);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(instanceId, active.ContractInstanceId);
Assert.False(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
FillBag(deps);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(instanceId, active.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, active.Status);
Assert.Empty(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var instanceId = IssueAndCompleteOnce(deps);
// Act
var replay = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(replay.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode);
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
}
private static string IssueAndCompleteOnce(CompletionTestDependencies deps)
{
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
var first = CompleteOnEncounterClear(deps, EncounterId);
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.Equal(ContractInstanceStatus.Completed, first.Snapshot!.Status);
return instanceId;
}
private static ContractIssueOperationResult IssuePrototypeContract(CompletionTestDependencies deps) =>
ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static ContractCompletionOperationResult CompleteOnEncounterClear(
CompletionTestDependencies deps,
string encounterId) =>
ContractCompletionOperations.TryCompleteOnEncounterClear(
PlayerId,
encounterId,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static void FillBag(CompletionTestDependencies deps)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static CompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
return new CompletionTestDependencies(
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
new FakeTimeProvider(CompletedAt));
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private sealed record CompletionTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}

View File

@ -0,0 +1,50 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractGeneratorOperationsIntegrationTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string SeedBucket = "2026-06-22";
[Fact]
public async Task TryIssue_ShouldCreateActiveInstanceWithEncounterBinding_WhenResolvedFromDi()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
using var scope = factory.Services.CreateScope();
var templateRegistry = scope.ServiceProvider.GetRequiredService<IContractTemplateRegistry>();
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
var standingStore = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
var result = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
templateRegistry,
instanceStore,
standingStore,
timeProvider);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
Assert.Equal(EncounterId, result.EncounterTemplateId);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
Assert.True(instanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
}
}

View File

@ -0,0 +1,347 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractGeneratorOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string GridOperatorsFactionId = "prototype_faction_grid_operators";
private const string GatedTemplateId = "test_gated_contract_template";
private const string SeedBucket = "2026-06-22";
private const string AlternateSeedBucket = "2026-06-23";
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 TryIssue_ShouldCreateActiveInstance_WhenTemplateEligible()
{
// Arrange
var deps = CreateDependencies();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
Assert.Equal(PlayerId, result.Snapshot.PlayerId);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
Assert.Equal(SeedBucket, result.Snapshot.SeedBucket);
Assert.Equal(IssuedAt, result.Snapshot.IssuedAt);
Assert.Equal(EncounterId, result.EncounterTemplateId);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
}
[Fact]
public void TryIssue_ShouldAutoSelectTemplate_WhenTemplateIdOmitted()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket);
// Assert
Assert.True(result.Success);
Assert.Equal(TemplateId, result.Snapshot!.TemplateId);
Assert.Equal(EncounterId, result.EncounterTemplateId);
}
[Fact]
public void MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket()
{
// Arrange
// Act
var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket);
// Assert
Assert.Equal(first, same);
Assert.NotEqual(first, different);
Assert.StartsWith("ci_", first, StringComparison.Ordinal);
}
[Fact]
public void TryIssue_ShouldDenyActiveContractExists_WhenPlayerAlreadyHasActiveInstance()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
// Assert
Assert.False(second.Success);
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, second.ReasonCode);
Assert.NotNull(second.Snapshot);
Assert.Equal(first.Snapshot!.ContractInstanceId, second.Snapshot!.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
Assert.Null(second.EncounterTemplateId);
}
[Fact]
public void TryIssue_ShouldAllowNewIssue_WhenPriorInstanceCompleted()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
Assert.True(deps.InstanceStore.TryMarkComplete(
PlayerId,
first.Snapshot!.ContractInstanceId,
CompletedAt,
out _));
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
// Assert
Assert.True(second.Success);
Assert.NotEqual(first.Snapshot.ContractInstanceId, second.Snapshot!.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
Assert.Equal(EncounterId, second.EncounterTemplateId);
}
[Fact]
public void TryIssue_ShouldDenyUnknownTemplate_WhenExplicitTemplateMissing()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: "not_a_real_template", seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.UnknownTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenBandMismatch()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket, zoneDifficultyBand: 99);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenAutoSelectBandHasNoTemplates()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket, zoneDifficultyBand: 99);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var gatedTemplate = prototype with
{
Id = GatedTemplateId,
MinFactionStanding = new FactionGateRuleRow(GridOperatorsFactionId, 1),
};
var deps = CreateDependencies(gatedTemplate);
// Act
var result = TryIssue(deps, templateId: GatedTemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
Assert.True(deps.InstanceStore.TryMarkComplete(
PlayerId,
first.Snapshot!.ContractInstanceId,
CompletedAt,
out _));
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(second.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, second.ReasonCode);
Assert.NotNull(second.Snapshot);
Assert.Equal(ContractInstanceStatus.Completed, second.Snapshot!.Status);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenCreateActiveReturnsFalseWithoutSnapshot()
{
// Arrange
var deps = CreateDependencies();
var store = new CreateActiveNullSnapshotStore(deps.InstanceStore);
var templateRegistry = deps.TemplateRegistry;
var standingStore = deps.StandingStore;
// Act
var result = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
templateRegistry,
store,
standingStore,
deps.TimeProvider);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, playerId: UnknownPlayerId, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.PlayerNotWritable, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenPlayerIdEmpty()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, playerId: " ", templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenSeedBucketEmpty()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: " ");
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
}
private static ContractIssueOperationResult TryIssue(
GeneratorTestDependencies deps,
string? playerId = PlayerId,
string? templateId = TemplateId,
string seedBucket = SeedBucket,
int? zoneDifficultyBand = null) =>
ContractGeneratorOperations.TryIssue(
playerId!,
templateId,
seedBucket,
zoneDifficultyBand,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static GeneratorTestDependencies CreateDependencies(params ContractTemplateRow[] templates)
{
var rows = templates.Length == 0
? new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[TemplateId] = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId],
}
: templates.ToDictionary(static t => t.Id, StringComparer.Ordinal);
var catalog = new ContractTemplateCatalog("/tmp/catalog", rows, catalogJsonFileCount: 1);
var templateRegistry = new ContractTemplateRegistry(catalog);
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
var instanceStore = new InMemoryContractInstanceStore(positionOptions);
var standingStore = CreateStandingStore();
return new GeneratorTestDependencies(
templateRegistry,
instanceStore,
standingStore,
new FakeTimeProvider(IssuedAt));
}
private static InMemoryFactionStandingStore CreateStandingStore()
{
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
var registry = new FactionDefinitionRegistry(catalog);
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private readonly record struct GeneratorTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider);
/// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary>
private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore
{
public bool CanWritePlayer(string playerId) => inner.CanWritePlayer(playerId);
public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) =>
inner.TryGet(playerId, contractInstanceId, out snapshot);
public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) =>
inner.TryGetActiveForPlayer(playerId, out snapshot);
public bool TryCreateActive(
string playerId,
string contractInstanceId,
string templateId,
string seedBucket,
DateTimeOffset issuedAt,
out ContractInstanceState snapshot)
{
snapshot = null!;
return false;
}
public bool TryMarkComplete(
string playerId,
string contractInstanceId,
DateTimeOffset completedAt,
out ContractInstanceState snapshot) =>
inner.TryMarkComplete(playerId, contractInstanceId, completedAt, out snapshot);
public bool TryClearInstance(string playerId, string contractInstanceId) =>
inner.TryClearInstance(playerId, contractInstanceId);
}
}

View File

@ -0,0 +1,92 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
namespace NeonSprawl.Server.Tests.Game.Contracts;
[Collection("Postgres integration")]
public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string SeedBucket = "2026-06-22";
[RequirePostgresFact]
public async Task TryIssue_ShouldPersistActiveInstanceAcrossNewFactory()
{
// Arrange
await ResetContractInstanceTableAsync();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
ContractIssueOperationResult issued;
using (var firstScope = Factory.Services.CreateScope())
{
var services = firstScope.ServiceProvider;
issued = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<TimeProvider>());
}
// Assert
Assert.True(issued.Success);
Assert.Equal(expectedInstanceId, issued.Snapshot!.ContractInstanceId);
Assert.Equal(EncounterId, issued.EncounterTemplateId);
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondScope = secondFactory.Services.CreateScope();
var readStore = secondScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
Assert.True(readStore.TryGetActiveForPlayer(PlayerId, out var readBack));
Assert.Equal(expectedInstanceId, readBack.ContractInstanceId);
Assert.Equal(TemplateId, readBack.TemplateId);
Assert.Equal(ContractInstanceStatus.Active, readBack.Status);
Assert.Equal(SeedBucket, readBack.SeedBucket);
}
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 generator persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
await applyPosition.ExecuteNonQueryAsync();
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
await truncate.ExecuteNonQueryAsync();
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using var applyInstance = new NpgsqlCommand(instanceDdl, conn);
await applyInstance.ExecuteNonQueryAsync();
}
}

View File

@ -1,10 +1,6 @@
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;

View File

@ -1,11 +1,7 @@
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;

View File

@ -1,13 +1,8 @@
using System.Collections.Frozen;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;

View File

@ -1,14 +1,4 @@
using System.Collections.Frozen;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;

View File

@ -1,9 +1,5 @@
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;

View File

@ -1,8 +1,6 @@
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;

View File

@ -1,13 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;

View File

@ -1,12 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;
@ -266,6 +260,28 @@ public sealed class PlayerCraftApiTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public void MapResponse_ShouldReturnEmptyIoArrays_WhenCraftDenied()
{
// Arrange
var result = new CraftResult(
Success: false,
ReasonCode: CraftReasonCodes.UnknownRecipe,
InputsConsumed: [],
OutputsGranted: [],
XpGrantSummary: null);
// Act
var response = PlayerCraftApi.MapResponse(result);
// Assert
Assert.False(response.Success);
Assert.Equal(CraftReasonCodes.UnknownRecipe, response.ReasonCode);
Assert.Empty(response.InputsConsumed);
Assert.Empty(response.OutputsGranted);
Assert.Null(response.XpGrantSummary);
}
private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
{
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();

View File

@ -1,14 +1,8 @@
using System.Collections.Frozen;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;

View File

@ -1,11 +1,6 @@
using System.Collections.Frozen;
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Crafting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;

View File

@ -1,13 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
@ -49,6 +46,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -88,6 +88,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -131,6 +134,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -172,6 +178,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -206,6 +215,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -232,6 +244,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -272,6 +287,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -321,6 +339,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -367,6 +388,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -397,6 +421,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
}
@ -455,7 +482,10 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -489,5 +519,8 @@ public sealed class EncounterCombatWiringTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry);
}

View File

@ -1,13 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
@ -19,6 +16,67 @@ public sealed class EncounterCompletionOperationsTests
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryCompleteAndGrant_ShouldApplyContractBundle_WhenActiveContractMatchesEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = ContractGeneratorOperations.TryIssue(
PlayerId,
"prototype_contract_clear_combat_pocket",
"2026-06-22",
zoneDifficultyBand: null,
deps.ContractTemplateRegistry,
deps.ContractInstanceStore,
deps.StandingStore,
TimeProvider.System);
Assert.True(issued.Success);
var instanceId = issued.Snapshot!.ContractInstanceId;
DefeatAllRequiredTargets(deps);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
Assert.True(result.Success);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(15, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
Assert.True(deps.ContractInstanceStore.TryGet(PlayerId, instanceId, out var instance));
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(deps.ContractOutcomeStore.GetOutcomesForInstance(instanceId));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
{
@ -49,6 +107,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
// Assert
@ -100,6 +161,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -124,6 +188,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
// Assert
@ -190,6 +257,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -232,6 +302,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -276,6 +349,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -318,6 +394,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -358,6 +437,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -428,7 +510,10 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -476,7 +561,10 @@ public sealed class EncounterCompletionOperationsTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{

View File

@ -1,11 +1,5 @@
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,7 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,7 +1,4 @@
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,13 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,6 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,8 +1,4 @@
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,7 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;

View File

@ -1,7 +1,4 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
@ -90,7 +87,7 @@ public class FactionDefinitionCatalogLoaderTests
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var singleFaction = """
const string singleFaction = """
{
"schemaVersion": 1,
"factions": [
@ -118,7 +115,7 @@ public class FactionDefinitionCatalogLoaderTests
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var oneFaction = """
const string oneFaction = """
{
"schemaVersion": 1,
"factions": [

View File

@ -1,9 +1,4 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,10 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
@ -12,6 +8,7 @@ public sealed class FactionGateOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding;
[Fact]
@ -63,19 +60,18 @@ public sealed class FactionGateOperationsTests
{
// Arrange
var standingStore = CreateStandingStore();
const string rustFactionId = "prototype_faction_rust_collective";
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
var rules = new[]
{
new FactionGateRuleRow(GridFactionId, GridMinStanding),
new FactionGateRuleRow(rustFactionId, 1),
new FactionGateRuleRow(RustCollectiveFactionId, 1),
};
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
Assert.Equal(rustFactionId, outcome.FailingFactionId);
Assert.Equal(RustCollectiveFactionId, outcome.FailingFactionId);
Assert.Equal(1, outcome.RequiredMinStanding);
Assert.Equal(0, outcome.CurrentStanding);
}
@ -120,12 +116,10 @@ public sealed class FactionGateOperationsTests
private static FactionDefinitionRegistry CreatePrototypeRegistry()
{
var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
var rustFactionId = "prototype_faction_rust_collective";
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId],
[rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId],
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
[RustCollectiveFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustCollectiveFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);

View File

@ -1,14 +1,8 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,10 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,8 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,10 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,9 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;

View File

@ -1,13 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.Gathering;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,12 +1,6 @@
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,9 +1,4 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Gathering;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,10 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,7 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,8 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;

View File

@ -1,7 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gigs;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gigs;

View File

@ -1,15 +1,8 @@
using System.Linq;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gigs;
@ -31,7 +24,10 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
};
// Act — defeat alpha through first host (four pulses)
// Act — defeat alpha through first host (pulse loop; fake clock avoids wall-clock cooldown flakes)
Assert.NotNull(Factory.FakeClock);
var clock = Factory.FakeClock;
clock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
using (var firstClient = Factory.CreateClient())
{
await firstClient.PostAsJsonAsync(
@ -48,15 +44,26 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
for (var i = 0; i < 4; i++)
AbilityCastResponse? defeatBody = null;
for (var i = 0; i < 12; i++)
{
var response = await firstClient.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
if (i < 3)
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(body);
Assert.True(body!.Accepted, body.ReasonCode);
Assert.NotNull(body.CombatResolution);
if (body.CombatResolution!.TargetDefeated)
{
await Task.Delay(TimeSpan.FromSeconds(3.2));
defeatBody = body;
break;
}
clock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
Assert.NotNull(defeatBody);
Assert.True(defeatBody!.CombatResolution!.TargetDefeated);
}
await using var secondFactory = new PostgresWebApplicationFactory();

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Gigs;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gigs;

View File

@ -1,5 +1,4 @@
using NeonSprawl.Server.Game.World;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
@ -62,10 +61,10 @@ public class HorizontalReachTests
public void IsWithinHorizontalRadius_NegativeRadius_ThrowsArgumentOutOfRange()
{
// Arrange
Action act = () => HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0);
// Act
var ex = Assert.Throws<ArgumentOutOfRangeException>(act);
var ex = Assert.Throws<ArgumentOutOfRangeException>(
() => HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0));
// Assert
Assert.NotNull(ex);

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Interaction;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
@ -28,7 +24,8 @@ public class InteractablesWorldApiTests
Assert.Equal(5, body.Interactables.Count);
var ids = body.Interactables.Select(static x => x.InteractableId).ToList();
Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids);
string[] expectedOrder = [.. ids.OrderBy(static x => x, StringComparer.Ordinal)];
Assert.Equal(expectedOrder, ids);
var alpha = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId);
Assert.Equal("resource_node", alpha.Kind);

View File

@ -1,9 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;

View File

@ -1,13 +1,7 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;

View File

@ -1,11 +1,5 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;

View File

@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
@ -12,11 +11,8 @@ using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
namespace NeonSprawl.Server.Tests.Game.Interaction;
@ -110,19 +106,19 @@ internal sealed class SalvageActivityDeniedSkillRegistry : ISkillDefinitionRegis
"salvage",
"gather",
"Salvage",
new[] { "mission_reward" });
["mission_reward"]);
private static readonly SkillDefRow Refine = new(
"refine",
"process",
"Refine",
new[] { "activity", "mission_reward", "trainer" });
["activity", "mission_reward", "trainer"]);
private static readonly SkillDefRow Intrusion = new(
"intrusion",
"tech",
"Intrusion",
new[] { "activity", "mission_reward", "book_or_item" });
["activity", "mission_reward", "book_or_item"]);
private static readonly IReadOnlyList<SkillDefRow> Ordered =
[

View File

@ -1,6 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,12 +1,6 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,9 +1,4 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,8 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,6 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,10 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;

View File

@ -1,11 +1,6 @@
using System.Collections.Frozen;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;

View File

@ -1,8 +1,4 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;

View File

@ -1,12 +1,5 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
@ -18,7 +11,7 @@ public sealed class MasteryFixtureApiTests
private static MasteryFixtureRequest ValidFixture(
bool resetPerkState = true,
Dictionary<string, int>? skillXp = null) =>
new MasteryFixtureRequest
new()
{
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion,
ResetPerkState = resetPerkState,

View File

@ -1,12 +1,4 @@
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
@ -19,7 +11,7 @@ public sealed class PerkStateApiTests
int tierIndex,
string branchId,
int schemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion) =>
new PerkBranchSelectRequest
new()
{
SchemaVersion = schemaVersion,
SkillId = skillId,
@ -28,7 +20,7 @@ public sealed class PerkStateApiTests
};
private static SkillProgressionGrantRequest SalvageGrant(int amount) =>
new SkillProgressionGrantRequest
new()
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = "salvage",

View File

@ -1,11 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;

View File

@ -1,11 +1,6 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;

View File

@ -1,7 +1,4 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;

View File

@ -1,11 +1,6 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;

View File

@ -1,5 +1,3 @@
using NeonSprawl.Server.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;

Some files were not shown because too many files have changed in this diff Show More