Compare commits
30 Commits
29bc2423b5
...
177b316efa
| Author | SHA1 | Date |
|---|---|---|
|
|
177b316efa | |
|
|
299de3c796 | |
|
|
b233fa040d | |
|
|
2cf8bcd0b9 | |
|
|
c5b8b7a726 | |
|
|
eb6a4fedde | |
|
|
6f735f8643 | |
|
|
68ba26724b | |
|
|
d1df940084 | |
|
|
bcf6fd2641 | |
|
|
8d9d02fde5 | |
|
|
36ffec94e9 | |
|
|
7268935bc7 | |
|
|
e75ac3932f | |
|
|
76ce1943c7 | |
|
|
b20d7d001e | |
|
|
52ab7dbec2 | |
|
|
c237bead53 | |
|
|
81f4cde673 | |
|
|
58a188eac0 | |
|
|
39805ef8e6 | |
|
|
fbb248770f | |
|
|
3549b647a5 | |
|
|
be0525b152 | |
|
|
c37c3c76a3 | |
|
|
44f4e4c3ce | |
|
|
60aa0be4f0 | |
|
|
87a0eb25ec | |
|
|
7f62f43f92 | |
|
|
e0efaa3c15 |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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+.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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-144–NEO-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).
|
||||
|
|
@ -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-144–NEO-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 plan’s “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.
|
||||
|
|
@ -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 post–idle→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).
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Factions;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Factions;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Gathering;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Items;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Items;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue