NEO-149: wire contract completion on encounter clear

Add ContractCompletionOperations orchestrator (deliver-then-mark,
outcome audit), hook from EncounterCompletionOperations after E5.M3
loot and quest wiring, extend dev fixture reset for contract rows,
Bruno quest-fixture reset request, and cover with tests (904 green).
pull/190/head
VinPropane 2026-06-27 21:48:17 -04:00
parent 8d9d02fde5
commit bcf6fd2641
20 changed files with 981 additions and 8 deletions

View File

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

View File

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

View File

@ -61,6 +61,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**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.

View File

@ -56,8 +56,17 @@ Encounter clear completes a matching **active** contract instance and triggers t
## Acceptance criteria checklist
- [ ] Active contract completes when bound encounter clears.
- [ ] Encounter first-time loot idempotency unchanged (E5.M3).
- [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

View File

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

View File

@ -0,0 +1,263 @@
using Microsoft.Extensions.DependencyInjection;
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;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
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 issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
var first = CompleteOnEncounterClear(deps, EncounterId);
Assert.True(first.Success);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
var salvageAfterFirst = deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
// Act
var replay = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(replay.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterReplay);
Assert.Equal(
CountBagItem(afterFirst!, "scrap_metal_bulk"),
CountBagItem(afterReplay!, "scrap_metal_bulk"));
Assert.Equal(
salvageAfterFirst,
deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
}
private static ContractIssueOperationResult IssuePrototypeContract(CompletionTestDependencies deps) =>
ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static ContractCompletionOperationResult CompleteOnEncounterClear(
CompletionTestDependencies deps,
string encounterId) =>
ContractCompletionOperations.TryCompleteOnEncounterClear(
PlayerId,
encounterId,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static void FillBag(CompletionTestDependencies deps)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static CompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
return new CompletionTestDependencies(
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
new FakeTimeProvider(CompletedAt));
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private sealed record CompletionTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
@ -49,6 +50,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -88,6 +92,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -131,6 +138,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -172,6 +182,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -206,6 +219,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 +248,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -272,6 +291,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -321,6 +343,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -367,6 +392,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -397,6 +425,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
}
@ -455,7 +486,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 +523,8 @@ public sealed class EncounterCombatWiringTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry);
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
@ -19,6 +20,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 +111,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
// Assert
@ -100,6 +165,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 +192,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
// Assert
@ -190,6 +261,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -232,6 +306,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -276,6 +353,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -318,6 +398,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -358,6 +441,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -428,7 +514,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 +565,10 @@ public sealed class EncounterCompletionOperationsTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
@ -270,6 +271,9 @@ public sealed class QuestObjectiveWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -459,6 +463,9 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>(),
TimeProvider.System);
}
@ -483,5 +490,8 @@ public sealed class QuestObjectiveWiringTests
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry,
TimeProvider TimeProvider);
}

View File

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
@ -412,6 +413,9 @@ public sealed class QuestProgressApiTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -552,6 +556,9 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>(),
TimeProvider.System);
}
@ -576,5 +583,8 @@ public sealed class QuestProgressApiTests
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry,
TimeProvider TimeProvider);
}

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gigs;
@ -93,6 +94,9 @@ public static class AbilityCastApi
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore,
IContractOutcomeStore contractOutcomeStore,
IContractTemplateRegistry contractTemplateRegistry,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -302,6 +306,9 @@ public static class AbilityCastApi
deliveryStore,
standingStore,
auditStore,
contractInstanceStore,
contractOutcomeStore,
contractTemplateRegistry,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-internal contract completion resolution envelope (NEO-149).
/// HTTP DTOs via E7M4-08 (NEO-151).
/// </summary>
public readonly record struct ContractCompletionOperationResult(
bool Success,
string? ReasonCode,
ContractInstanceState? Snapshot,
ContractOutcomeRow? Outcome);

View File

@ -0,0 +1,197 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-authoritative contract completion on encounter clear (NEO-149).
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152).
/// </summary>
public static class ContractCompletionOperations
{
/// <summary>
/// When the player has an active contract bound to <paramref name="encounterId"/>, delivers the template
/// completion bundle then marks the instance complete and appends an outcome audit row.
/// </summary>
public static ContractCompletionOperationResult TryCompleteOnEncounterClear(
string playerId,
string encounterId,
IContractInstanceStore instanceStore,
IContractOutcomeStore outcomeStore,
IContractTemplateRegistry templateRegistry,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId);
var normalizedEncounterId = NormalizeEncounterId(encounterId);
if (normalizedPlayerId.Length == 0 || normalizedEncounterId.Length == 0)
{
return Deny(ContractCompletionReasonCodes.InvalidIds);
}
if (!instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot))
{
return NoOp(ContractCompletionReasonCodes.NoActiveContract);
}
if (!templateRegistry.TryGetDefinition(activeSnapshot.TemplateId, out var template))
{
return Deny(ContractCompletionReasonCodes.UnknownTemplate, activeSnapshot);
}
if (!EncounterMatchesTemplate(normalizedEncounterId, template.EncounterTemplateId))
{
return NoOp(ContractCompletionReasonCodes.EncounterMismatch, activeSnapshot);
}
if (activeSnapshot.Status == ContractInstanceStatus.Completed)
{
return Success(activeSnapshot, TryGetExistingOutcome(outcomeStore, activeSnapshot));
}
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
{
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
}
var delivery = RewardRouterOperations.TryDeliverContractCompletion(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
template.CompletionRewardBundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
if (!delivery.Success)
{
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidContractInstanceId, activeSnapshot);
}
var completedAt = timeProvider.GetUtcNow();
if (instanceStore.TryMarkComplete(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
completedAt,
out var completedSnapshot))
{
var outcome = AppendOutcome(
outcomeStore,
completedSnapshot,
delivery,
completedAt);
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_complete` ---
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns below without hook.
// Planned payload: playerId, contractInstanceId, templateId, encounterId, completedAt, idempotencyKey.
return Success(completedSnapshot, outcome);
}
if (completedSnapshot?.Status == ContractInstanceStatus.Completed)
{
return Success(completedSnapshot, TryGetExistingOutcome(outcomeStore, completedSnapshot));
}
if (instanceStore.TryGet(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
out var afterAttempt) &&
afterAttempt.Status == ContractInstanceStatus.Completed)
{
return Success(afterAttempt, TryGetExistingOutcome(outcomeStore, afterAttempt));
}
return Deny(ContractCompletionReasonCodes.InvalidIds, activeSnapshot);
}
private static ContractOutcomeRow? AppendOutcome(
IContractOutcomeStore outcomeStore,
ContractInstanceState snapshot,
RewardDeliveryResult delivery,
DateTimeOffset recordedAt)
{
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
snapshot.PlayerId,
snapshot.ContractInstanceId);
if (idempotencyKey.Length == 0)
{
return null;
}
var row = new ContractOutcomeRow(
MakeOutcomeRowId(idempotencyKey),
snapshot.ContractInstanceId,
snapshot.PlayerId,
idempotencyKey,
delivery.GrantedItems,
delivery.GrantedSkillXp,
delivery.GrantedReputation,
recordedAt);
if (outcomeStore.TryAppend(row))
{
return row;
}
return outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
? existing
: null;
}
private static ContractOutcomeRow? TryGetExistingOutcome(
IContractOutcomeStore outcomeStore,
ContractInstanceState snapshot)
{
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
snapshot.PlayerId,
snapshot.ContractInstanceId);
return idempotencyKey.Length > 0 &&
outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
? existing
: null;
}
private static string MakeOutcomeRowId(string idempotencyKey) => $"{idempotencyKey}:outcome";
private static bool EncounterMatchesTemplate(string normalizedEncounterId, string templateEncounterId) =>
string.Equals(
normalizedEncounterId,
NormalizeEncounterId(templateEncounterId),
StringComparison.Ordinal);
private static string NormalizeEncounterId(string? encounterId) =>
encounterId?.Trim() ?? string.Empty;
private static ContractCompletionOperationResult NoOp(
string reasonCode,
ContractInstanceState? snapshot = null) =>
new(true, reasonCode, snapshot, null);
private static ContractCompletionOperationResult Success(
ContractInstanceState snapshot,
ContractOutcomeRow? outcome) =>
new(true, null, snapshot, outcome);
private static ContractCompletionOperationResult Deny(
string reasonCode,
ContractInstanceState? snapshot = null) =>
new(false, reasonCode, snapshot, null);
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Stable reason codes for <see cref="ContractCompletionOperations.TryCompleteOnEncounterClear"/> (NEO-149).</summary>
public static class ContractCompletionReasonCodes
{
public const string InvalidIds = ContractInstanceReasonCodes.InvalidIds;
public const string PlayerNotWritable = ContractInstanceReasonCodes.PlayerNotWritable;
public const string NoActiveContract = "no_active_contract";
public const string EncounterMismatch = "encounter_mismatch";
public const string UnknownTemplate = "unknown_template";
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -38,6 +39,9 @@ public static class EncounterCombatWiring
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore,
IContractOutcomeStore contractOutcomeStore,
IContractTemplateRegistry contractTemplateRegistry,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -104,6 +108,9 @@ public static class EncounterCombatWiring
deliveryStore,
standingStore,
auditStore,
contractInstanceStore,
contractOutcomeStore,
contractTemplateRegistry,
timeProvider);
if (!completion.Success)

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -41,6 +42,9 @@ public static class EncounterCompletionOperations
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore,
IContractOutcomeStore contractOutcomeStore,
IContractTemplateRegistry contractTemplateRegistry,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
@ -167,6 +171,24 @@ public static class EncounterCompletionOperations
auditStore,
timeProvider);
_ = ContractCompletionOperations.TryCompleteOnEncounterClear(
normalizedPlayerId,
normalizedEncounterId,
contractInstanceStore,
contractOutcomeStore,
contractTemplateRegistry,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
@ -15,6 +16,7 @@ public static class QuestFixtureApi
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore, IContractOutcomeStore contractOutcomeStore,
TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
@ -37,6 +39,8 @@ public static class QuestFixtureApi
standingStore,
deliveryStore,
auditStore,
contractInstanceStore,
contractOutcomeStore,
timeProvider))
{
return Results.NotFound();

View File

@ -21,6 +21,10 @@ public sealed class QuestFixtureRequest
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
[JsonPropertyName("resetFactionIds")]
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
/// <summary>Contract instance ids to clear from instance, delivery, and outcome stores (dev Bruno reset).</summary>
[JsonPropertyName("resetContractInstanceIds")]
public IReadOnlyList<string> ResetContractInstanceIds { get; init; } = [];
}
/// <summary>POST response for quest fixture apply.</summary>

View File

@ -1,5 +1,6 @@
namespace NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
@ -19,11 +20,14 @@ public static class QuestFixtureOperations
IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore,
IReputationDeltaStore auditStore,
IContractInstanceStore instanceStore,
IContractOutcomeStore outcomeStore,
TimeProvider timeProvider)
{
if (body.CompletedQuestIds.Count == 0 &&
body.ResetQuestIds.Count == 0 &&
body.ResetFactionIds.Count == 0)
body.ResetFactionIds.Count == 0 &&
body.ResetContractInstanceIds.Count == 0)
{
return true;
}
@ -69,6 +73,33 @@ public static class QuestFixtureOperations
}
}
foreach (var contractInstanceId in body.ResetContractInstanceIds)
{
var normalizedInstanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (normalizedInstanceId.Length == 0)
{
return false;
}
if (!deliveryStore.TryClear(
playerId,
RewardDeliverySourceKinds.ContractCompletion,
normalizedInstanceId))
{
return false;
}
if (!instanceStore.TryClearInstance(playerId, normalizedInstanceId))
{
return false;
}
if (!outcomeStore.TryClearForInstance(normalizedInstanceId))
{
return false;
}
}
foreach (var questId in body.CompletedQuestIds)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))

View File

@ -314,6 +314,37 @@ Completed instances are immutable (no status regression). Player, template, and
Economy band-cap lint at issue time lands in **NEO-150** (E7M4-07). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md).
## Contract completion operations (NEO-149)
**`ContractCompletionOperations.TryCompleteOnEncounterClear`** completes an **`active`** contract when the cleared encounter matches the template **`encounterTemplateId`**. Call this orchestrator (or rely on encounter wiring) — not stores or the reward router directly.
**Wiring order:** **`EncounterCompletionOperations.TryCompleteAndGrant`** applies first-time encounter loot (E5.M3), records **`EncounterCompleteEvent`**, runs **`QuestObjectiveWiring`**, then invokes contract completion. Encounter success is never blocked by contract payout failure.
**Flow:**
1. **`TryGetActiveForPlayer`** — no active row ⇒ success no-op (**`no_active_contract`**).
2. Load template; deny **`unknown_template`** when missing.
3. Encounter id mismatch ⇒ success no-op (**`encounter_mismatch`**).
4. **`RewardRouterOperations.TryDeliverContractCompletion`** (deliver-then-mark).
5. On delivery deny, instance stays **`active`**; router **`reasonCode`** passthrough (e.g. **`inventory_full`**).
6. **`IContractInstanceStore.TryMarkComplete`** then **`IContractOutcomeStore.TryAppend`** with grant snapshots from the delivery result.
**Reason codes:**
| Code | When |
|------|------|
| **`no_active_contract`** | Success no-op — player has no active instance |
| **`encounter_mismatch`** | Success no-op — cleared encounter is not the template objective |
| **`unknown_template`** | Active instance references a missing template id |
| **`invalid_ids`**, **`player_not_writable`** | Normalization / write gate failures |
| *(router passthrough)* | Delivery deny — e.g. **`inventory_full`**, **`already_delivered`** |
**Dev fixture reset:** **`POST …/__dev/quest-fixture`** accepts optional **`resetContractInstanceIds`** — clears contract instance, **`contract_completion`** delivery row, and outcome audit for each id (NEO-149).
**Prototype edge case:** issuing a contract after the bound encounter is already cleared leaves the instance **`active`** with no re-trigger in v1 — prototype flow is issue-then-clear.
HTTP contract GET/issue projections land in **NEO-151** (E7M4-08). Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md).
## Contract outcome store (NEO-146)
Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.
@ -491,7 +522,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations`** will call **`TryDeliverContractCompletion`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — invoked from **`EncounterCompletionOperations`** after encounter loot + quest wiring; deliver-then-mark via **`TryDeliverContractCompletion`**, outcome append via **`IContractOutcomeStore`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md), [NEO-149](../../docs/plans/NEO-149-implementation-plan.md).
### Reward telemetry hooks (NEO-130)