diff --git a/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru b/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru new file mode 100644 index 0000000..4ac34c8 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js index 89ec479..b97bce1 100644 --- a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js +++ b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js @@ -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, diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index 3bdf964..08e6e0d 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -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. diff --git a/docs/plans/NEO-149-implementation-plan.md b/docs/plans/NEO-149-implementation-plan.md index aef0a3a..f5f9a59 100644 --- a/docs/plans/NEO-149-implementation-plan.md +++ b/docs/plans/NEO-149-implementation-plan.md @@ -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 diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs new file mode 100644 index 0000000..4f729ff --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs @@ -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(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + 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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs new file mode 100644 index 0000000..4c63022 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs @@ -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(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + 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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index 3361d16..1508193 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } 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); } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 3f2d1fd..9682d2e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } 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 { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs index 1d7ae70..1debd92 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -483,5 +490,8 @@ public sealed class QuestObjectiveWiringTests IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore, + IContractInstanceStore ContractInstanceStore, + IContractOutcomeStore ContractOutcomeStore, + IContractTemplateRegistry ContractTemplateRegistry, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index 258bb65..b081f91 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -576,5 +583,8 @@ public sealed class QuestProgressApiTests IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore, + IContractInstanceStore ContractInstanceStore, + IContractOutcomeStore ContractOutcomeStore, + IContractTemplateRegistry ContractTemplateRegistry, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 6b4cd95..49099d7 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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))); diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperationResult.cs b/server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperationResult.cs new file mode 100644 index 0000000..2fc7beb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperationResult.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Server-internal contract completion resolution envelope (NEO-149). +/// HTTP DTOs via E7M4-08 (NEO-151). +/// +public readonly record struct ContractCompletionOperationResult( + bool Success, + string? ReasonCode, + ContractInstanceState? Snapshot, + ContractOutcomeRow? Outcome); diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs b/server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs new file mode 100644 index 0000000..c18909a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs @@ -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; + +/// +/// Server-authoritative contract completion on encounter clear (NEO-149). +/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152). +/// +public static class ContractCompletionOperations +{ + /// + /// When the player has an active contract bound to , delivers the template + /// completion bundle then marks the instance complete and appends an outcome audit row. + /// + 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); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractCompletionReasonCodes.cs b/server/NeonSprawl.Server/Game/Contracts/ContractCompletionReasonCodes.cs new file mode 100644 index 0000000..a6bc319 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractCompletionReasonCodes.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Stable reason codes for (NEO-149). +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"; +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index 2799067..2f08862 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -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) diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index b45350e..17cfa2e 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs index d5e9ce7..6c11780 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs @@ -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(); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs index 3f8141e..cc0b207 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs @@ -21,6 +21,10 @@ public sealed class QuestFixtureRequest /// Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0). [JsonPropertyName("resetFactionIds")] public IReadOnlyList ResetFactionIds { get; init; } = []; + + /// Contract instance ids to clear from instance, delivery, and outcome stores (dev Bruno reset). + [JsonPropertyName("resetContractInstanceIds")] + public IReadOnlyList ResetContractInstanceIds { get; init; } = []; } /// POST response for quest fixture apply. diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs index 90b7b1b..8cec549 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs @@ -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)) diff --git a/server/README.md b/server/README.md index aa9b8d0..2d45829 100644 --- a/server/README.md +++ b/server/README.md @@ -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)