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
parent
8d9d02fde5
commit
bcf6fd2641
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue