From f581cf412cb84408f186534d122f7fd95dd570eb Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:08:28 -0400 Subject: [PATCH] NEO-106: Wire encounter progress and completion from ability cast. --- .../Game/AbilityInput/AbilityCastApiTests.cs | 192 +++++++++++++- .../Encounters/EncounterCombatWiringTests.cs | 251 ++++++++++++++++++ .../Game/AbilityInput/AbilityCastApi.cs | 23 ++ .../Game/Encounters/EncounterCombatWiring.cs | 91 +++++++ 4 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index e2d6f2d..5db6470 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -4,7 +4,9 @@ using System.Text; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Gigs; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Npc; @@ -34,14 +36,17 @@ public sealed class AbilityCastApiTests post.EnsureSuccessStatusCode(); } - private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) + private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) => + await LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId); + + private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId) { var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, + TargetId = targetId, }); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); @@ -49,6 +54,80 @@ public sealed class AbilityCastApiTests Assert.True(body!.SelectionApplied); } + private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId) + { + Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry)); + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/move", + new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector + { + X = entry.X - 1, + Y = 0.9, + Z = entry.Z - 1, + }, + }); + response.EnsureSuccessStatusCode(); + } + + private static AbilityCastRequest PulseCastRequestForTarget(string targetId) => + new() + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = targetId, + }; + + private static async Task DefeatLockedTargetWithPulseAsync( + HttpClient client, + InMemoryWebApplicationFactory factory, + string targetId) + { + Assert.NotNull(factory.FakeClock); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + await BindSlot0PulseAsync(client); + await MoveDevPlayerNearNpcAsync(client, targetId); + await LockPrototypeTargetAsync(client, targetId); + var cast = PulseCastRequestForTarget(targetId); + AbilityCastResponse? defeatBody = null; + for (var i = 0; i < 12; i++) + { + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Accepted, body.ReasonCode); + Assert.NotNull(body.CombatResolution); + if (body.CombatResolution!.TargetDefeated) + { + defeatBody = body; + break; + } + + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + + Assert.NotNull(defeatBody); + Assert.True(defeatBody!.CombatResolution!.TargetDefeated); + } + + private static int CountBagItem(PlayerInventorySnapshotResponse 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 async Task BindSlot0GuardAsync(HttpClient client) { var post = await client.PostAsJsonAsync( @@ -777,4 +856,113 @@ public sealed class AbilityCastApiTests Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode); Assert.True(body3!.Accepted); } + + [Fact] + public async Task PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + var inventoryBefore = await client.GetFromJsonAsync( + "/game/players/dev-local-1/inventory"); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcRangedId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcEliteId); + var inventoryAfter = await client.GetFromJsonAsync( + "/game/players/dev-local-1/inventory"); + var completionStore = factory.Services.GetRequiredService(); + + // Assert + Assert.NotNull(inventoryBefore); + Assert.NotNull(inventoryAfter); + Assert.Equal( + CountBagItem(inventoryBefore!, "scrap_metal_bulk") + 10, + CountBagItem(inventoryAfter!, "scrap_metal_bulk")); + Assert.Equal( + CountBagItem(inventoryBefore!, "contract_handoff_token") + 1, + CountBagItem(inventoryAfter!, "contract_handoff_token")); + Assert.True(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket")); + } + + [Fact] + public async Task PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + var inventoryBefore = await client.GetFromJsonAsync( + "/game/players/dev-local-1/inventory"); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcRangedId); + var inventoryAfter = await client.GetFromJsonAsync( + "/game/players/dev-local-1/inventory"); + var completionStore = factory.Services.GetRequiredService(); + + // Assert + Assert.NotNull(inventoryBefore); + Assert.NotNull(inventoryAfter); + Assert.Equal( + CountBagItem(inventoryBefore!, "scrap_metal_bulk"), + CountBagItem(inventoryAfter!, "scrap_metal_bulk")); + Assert.Equal( + CountBagItem(inventoryBefore!, "contract_handoff_token"), + CountBagItem(inventoryAfter!, "contract_handoff_token")); + Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket")); + } + + [Fact] + public async Task PostAbilityCast_ShouldGrantGigXpPerDefeat_WhenAllThreeNpcDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + var gigBefore = await client.GetFromJsonAsync( + "/game/players/dev-local-1/gig-progression"); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcRangedId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcEliteId); + var gigAfter = await client.GetFromJsonAsync( + "/game/players/dev-local-1/gig-progression"); + + // Assert + Assert.NotNull(gigBefore); + Assert.NotNull(gigAfter); + var breachBefore = Assert.Single(gigBefore!.Gigs!); + var breachAfter = Assert.Single(gigAfter!.Gigs!); + Assert.Equal(0, breachBefore.Xp); + Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount * 3, breachAfter.Xp); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs new file mode 100644 index 0000000..825a5f7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -0,0 +1,251 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class EncounterCombatWiringTests +{ + private const string PlayerId = "dev-local-1"; + 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"; + + [Fact] + public async Task TryProcessCastOutcome_ShouldActivateEncounter_WhenDamagingHitWithoutDefeat() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + MeleeNpc, + damageDealt: 25, + targetDefeated: false, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress)); + Assert.True(progress.Started); + Assert.Empty(progress.DefeatedNpcInstanceIds); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + } + + [Fact] + public async Task TryProcessCastOutcome_ShouldMarkDefeatWithoutCompletion_WhenOnlyOneTargetDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + MeleeNpc, + damageDealt: 25, + targetDefeated: true, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress)); + Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryProcessCastOutcome_ShouldGrantLootOnce_WhenThirdTargetDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatNpc(deps, MeleeNpc); + DefeatNpc(deps, RangedNpc); + + // Act + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + EliteNpc, + damageDealt: 25, + targetDefeated: true, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryProcessCastOutcome_ShouldNoOp_WhenNpcNotInAnyEncounter() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + "unknown_npc_instance", + damageDealt: 25, + targetDefeated: true, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out _)); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + } + + [Fact] + public async Task TryProcessCastOutcome_ShouldSkipActivateAndMark_WhenEncounterAlreadyCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + _ = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var before); + + // Act + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + MeleeNpc, + damageDealt: 25, + targetDefeated: true, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var after); + Assert.Equal(CountBagItem(before!, "scrap_metal_bulk"), CountBagItem(after!, "scrap_metal_bulk")); + Assert.Equal(CountBagItem(before!, "contract_handoff_token"), CountBagItem(after!, "contract_handoff_token")); + } + + [Fact] + public async Task TryProcessCastOutcome_ShouldNotActivate_WhenZeroDamageWithoutDefeat() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + MeleeNpc, + damageDealt: 0, + targetDefeated: false, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress) && progress.Started); + } + + private static void DefeatNpc(EncounterCombatWiringTestDependencies deps, string npcId) + { + EncounterCombatWiring.TryProcessCastOutcome( + PlayerId, + npcId, + damageDealt: 25, + targetDefeated: true, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + } + + private static void DefeatAllRequiredTargets(EncounterCombatWiringTestDependencies deps) + { + DefeatNpc(deps, MeleeNpc); + DefeatNpc(deps, RangedNpc); + DefeatNpc(deps, EliteNpc); + } + + private static EncounterCombatWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new EncounterCombatWiringTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + 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 sealed record EncounterCombatWiringTestDependencies( + IEncounterDefinitionRegistry EncounterRegistry, + IRewardTableDefinitionRegistry RewardTableRegistry, + IEncounterProgressStore ProgressStore, + IEncounterCompletionStore CompletionStore, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore); +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index cb94318..7d3f255 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -1,6 +1,8 @@ using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Gigs; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; @@ -18,6 +20,7 @@ namespace NeonSprawl.Server.Game.AbilityInput; /// NEO-32: JSON deny on_cooldown when the slot is inside the server cooldown window. /// NEO-82: after E1 gates pass, invokes ; per-ability catalog cooldown on successful resolve. /// NEO-44: on accept when is true, best-effort gig XP grant via (not E2.M2 skill XP). +/// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via . /// public static class AbilityCastApi { @@ -68,6 +71,12 @@ public static class AbilityCastApi INpcBehaviorDefinitionRegistry behaviorRegistry, INpcRuntimeStateStore npcRuntimeStore, IPlayerGigProgressionStore gigStore, + IEncounterDefinitionRegistry encounterRegistry, + IRewardTableDefinitionRegistry rewardTableRegistry, + IEncounterProgressStore encounterProgressStore, + IEncounterCompletionStore encounterCompletionStore, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, ILoggerFactory loggerFactory, TimeProvider clock) => { @@ -255,6 +264,20 @@ public static class AbilityCastApi loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant))); } + EncounterCombatWiring.TryProcessCastOutcome( + id.Trim(), + lookupKey, + combatResult.DamageDealt, + combatResult.TargetDefeated, + encounterRegistry, + rewardTableRegistry, + encounterProgressStore, + encounterCompletionStore, + itemRegistry, + inventoryStore, + clock, + loggerFactory.CreateLogger(nameof(EncounterCombatWiring))); + // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). return JsonAbilityCast( diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs new file mode 100644 index 0000000..5579776 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -0,0 +1,91 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// NEO-106: wires ability-cast combat outcomes to encounter activation, defeat progress, and completion grants. +/// Best-effort on the cast accept path — outcome does not change AbilityCastResponse JSON. +/// +public static class EncounterCombatWiring +{ + /// + /// On damaging hits, activates encounter progress; on lethal hits, records defeat and may grant rewards. + /// + public static void TryProcessCastOutcome( + string playerId, + string targetNpcInstanceId, + int damageDealt, + bool targetDefeated, + IEncounterDefinitionRegistry encounterRegistry, + IRewardTableDefinitionRegistry rewardTableRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + TimeProvider timeProvider, + ILogger? logger = null) + { + if (damageDealt > 0) + { + EncounterProgressOperations.TryActivateOnFirstEngagement( + playerId, + targetNpcInstanceId, + encounterRegistry, + progressStore, + completionStore); + } + + if (!targetDefeated) + { + return; + } + + if (!EncounterProgressOperations.TryMarkTargetDefeated( + playerId, + targetNpcInstanceId, + encounterRegistry, + progressStore, + completionStore)) + { + return; + } + + if (!EncounterProgressOperations.TryResolveEncounterForNpc( + targetNpcInstanceId, + encounterRegistry, + out var encounterId, + out _)) + { + return; + } + + if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated( + playerId, + encounterId, + encounterRegistry, + progressStore)) + { + return; + } + + var completion = EncounterCompletionOperations.TryCompleteAndGrant( + playerId, + encounterId, + encounterRegistry, + rewardTableRegistry, + progressStore, + completionStore, + itemRegistry, + inventoryStore, + timeProvider); + + if (!completion.Success) + { + logger?.LogDebug( + "Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})", + playerId, + encounterId, + completion.ReasonCode); + } + } +}