using System.Net; using System.Net.Http.Json; using System.Text; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; using Xunit; namespace NeonSprawl.Server.Tests.Game.AbilityInput; /// HTTP integration tests for . /// /// NEO-30: every JSON deny ( false) asserts a non-whitespace /// — matches Slice 3 ability_cast_denied payload expectations; /// authoritative hook-site comments live on (cast route: MapAbilityCastApi extension in the same source file). /// Optional dev: NEON_SPRAWL_API_LOG=1 enables one-line ability_cast_* stdout per JSON cast response (see ). /// public sealed class AbilityCastApiTests { private static async Task BindSlot0PulseAsync(HttpClient client) { var post = await client.PostAsJsonAsync( "/game/players/dev-local-1/hotbar-loadout", new HotbarLoadoutUpdateRequest { SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], }); post.EnsureSuccessStatusCode(); } 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 = targetId, }); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); 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( "/game/players/dev-local-1/hotbar-loadout", new HotbarLoadoutUpdateRequest { SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard }], }); post.EnsureSuccessStatusCode(); } private static AbilityCastRequest PulseCastRequest() => new() { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) { var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/move-stream", new MoveStreamRequest { SchemaVersion = MoveStreamRequest.CurrentSchemaVersion, Targets = [new PositionVector { X = x, Y = y, Z = z }], }); response.EnsureSuccessStatusCode(); } [Fact] public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatchesAndLockInRange() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.True(body!.Accepted); Assert.Null(body.ReasonCode); Assert.NotNull(body.CombatResolution); Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId); Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.CombatResolution.TargetId); Assert.Equal(25, body.CombatResolution.DamageDealt); Assert.Equal(75, body.CombatResolution.TargetRemainingHp); Assert.False(body.CombatResolution.TargetDefeated); } [Fact] public async Task PostAbilityCast_ShouldAccept_WithTargetId_WhenLoadoutMatches() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = " Prototype_Npc_Melee ", }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.True(body!.Accepted); } [Fact] public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetIdMissing() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = null, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenUnknownTargetId() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = "not_a_registered_combat_target", }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetDoesNotMatchServerLock() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenNoServerLock() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenyOutOfRange_WhenAuthoritativePositionTooFarFromLock() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); await TeleportDevPlayerAsync(client, 50.0, 0.9, 50.0); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonOutOfRange, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenySlotUnbound_WhenSlotEmpty() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 2, AbilityId = PrototypeAbilityRegistry.PrototypeGuard, TargetId = null, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonSlotUnbound, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenyLoadoutMismatch_WhenAbilityDoesNotMatchSlot() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeBurst, TargetId = null, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonLoadoutMismatch, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenySlotOutOfBounds() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 8, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = null, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonSlotOutOfBounds, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldDenyUnknownAbility_WhenAbilityIdNotInRegistry() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = "not_a_real_ability", TargetId = null, }); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.False(body!.Accepted); Assert.Equal(AbilityCastApi.ReasonUnknownAbility, body.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldReturnBadRequest_WhenBodyMalformed() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var malformed = new StringContent( "{\"schemaVersion\":1,\"slotIndex\":", Encoding.UTF8, "application/json"); // Act var response = await client.PostAsync("/game/players/dev-local-1/ability-cast", malformed); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostAbilityCast_ShouldReturnBadRequest_WhenSchemaVersionWrong() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var request = new AbilityCastRequest { SchemaVersion = 999, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, }; // Act var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", request); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostAbilityCast_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var request = new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, }; // Act var response = await client.PostAsJsonAsync("/game/players/missing-player/ability-cast", request); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task PostAbilityCast_ShouldStartCatalogCooldown_WhenResolveSucceedsAfterDefinitionGate() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = PulseCastRequest(); // Act var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); var snapshotResponse = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot"); // Assert var body = await castResponse.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, castResponse.StatusCode); Assert.NotNull(body); Assert.True(body!.Accepted); Assert.NotNull(body.CombatResolution); var snapshot = await snapshotResponse.Content.ReadFromJsonAsync(); Assert.NotNull(snapshot); var slot0 = snapshot!.Slots.Single(s => s.SlotIndex == 0); Assert.NotNull(slot0.CooldownEndsAtUtc); } [Fact] public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert var body1 = await first.Content.ReadFromJsonAsync(); var body2 = await second.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); Assert.NotNull(body1); Assert.NotNull(body2); Assert.True(body1!.Accepted); Assert.False(body2!.Accepted); Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode); } [Fact] public async Task PostAbilityCast_ShouldAcceptAgain_WhenCooldownElapsed() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert var body1 = await first.Content.ReadFromJsonAsync(); var body2 = await second.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); Assert.NotNull(body1); Assert.NotNull(body2); Assert.True(body1!.Accepted); Assert.True(body2!.Accepted); Assert.NotNull(body2.CombatResolution); Assert.Equal(50, body2.CombatResolution!.TargetRemainingHp); } [Fact] public async Task PostAbilityCast_ShouldReturnCombatResolution_WhenZeroDamageUtilityAccepted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0GuardAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); Assert.True(body!.Accepted); Assert.NotNull(body.CombatResolution); Assert.Equal(0, body.CombatResolution!.DamageDealt); Assert.Equal(100, body.CombatResolution.TargetRemainingHp); Assert.False(body.CombatResolution.TargetDefeated); var threatStore = factory.Services.GetRequiredService(); threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); Assert.Null(threat.AggroHolderPlayerId); } [Fact] public async Task PostAbilityCast_ShouldSetAggroHolder_WhenDamagingCastAccepted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var threatStore = factory.Services.GetRequiredService(); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", PulseCastRequest()); response.EnsureSuccessStatusCode(); threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); // Assert Assert.Equal("dev-local-1", threat.AggroHolderPlayerId); } [Fact] public async Task PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = PulseCastRequest(); AbilityCastResponse? first = null; AbilityCastResponse? second = null; AbilityCastResponse? third = null; for (var i = 0; i < 3; i++) { var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); if (i == 0) { first = body; } else if (i == 1) { second = body; } else { third = body; } factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); } // Act var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert Assert.NotNull(first); Assert.Equal(75, first!.CombatResolution!.TargetRemainingHp); Assert.NotNull(second); Assert.Equal(50, second!.CombatResolution!.TargetRemainingHp); Assert.NotNull(third); Assert.Equal(25, third!.CombatResolution!.TargetRemainingHp); var fourth = await fourthResponse.Content.ReadFromJsonAsync(); Assert.NotNull(fourth); Assert.True(fourth!.Accepted); Assert.NotNull(fourth.CombatResolution); Assert.Equal(0, fourth.CombatResolution!.TargetRemainingHp); Assert.True(fourth.CombatResolution.TargetDefeated); var fifth = await fifthResponse.Content.ReadFromJsonAsync(); Assert.NotNull(fifth); Assert.False(fifth!.Accepted); Assert.Equal(CombatReasonCodes.TargetDefeated, fifth.ReasonCode); Assert.Null(fifth.CombatResolution); } [Fact] public async Task PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = PulseCastRequest(); for (var i = 0; i < 3; i++) { var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); response.EnsureSuccessStatusCode(); factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); } // Act var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); fourthResponse.EnsureSuccessStatusCode(); var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot"); factory.FakeClock.Advance(TimeSpan.FromSeconds(30)); _ = await client.GetAsync("/game/world/npc-runtime-snapshot"); var healthAfterWaitResponse = await client.GetAsync("/game/players/dev-local-1/combat-health"); // Assert var fourth = await fourthResponse.Content.ReadFromJsonAsync(); Assert.NotNull(fourth); Assert.True(fourth!.CombatResolution!.TargetDefeated); snapshotResponse.EnsureSuccessStatusCode(); var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync(); Assert.NotNull(snapshotBody); var melee = snapshotBody!.NpcInstances.Single( row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId); Assert.Equal("idle", melee.State); Assert.Null(melee.AggroHolderPlayerId); Assert.Null(melee.ActiveTelegraph); healthAfterWaitResponse.EnsureSuccessStatusCode(); var healthBody = await healthAfterWaitResponse.Content.ReadFromJsonAsync(); Assert.NotNull(healthBody); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp); } [Fact] public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var skillBefore = await client.GetFromJsonAsync( "/game/players/dev-local-1/skill-progression"); Assert.NotNull(skillBefore); var salvageBefore = skillBefore!.Skills!.Single(static s => s.Id == "salvage"); var refineBefore = skillBefore.Skills!.Single(static s => s.Id == "refine"); var cast = PulseCastRequest(); // Act for (var i = 0; i < 3; i++) { var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); response.EnsureSuccessStatusCode(); factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); } var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); var gigSnapshot = await client.GetFromJsonAsync( "/game/players/dev-local-1/gig-progression"); var skillAfter = await client.GetFromJsonAsync( "/game/players/dev-local-1/skill-progression"); // Assert var fourth = await fourthResponse.Content.ReadFromJsonAsync(); Assert.NotNull(fourth); Assert.True(fourth!.CombatResolution!.TargetDefeated); var fifth = await fifthResponse.Content.ReadFromJsonAsync(); Assert.NotNull(fifth); Assert.False(fifth!.Accepted); Assert.NotNull(gigSnapshot); var breach = Assert.Single(gigSnapshot!.Gigs!); Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, breach.Xp); Assert.NotNull(skillAfter); var salvageAfter = skillAfter!.Skills!.Single(static s => s.Id == "salvage"); var refineAfter = skillAfter.Skills!.Single(static s => s.Id == "refine"); Assert.Equal(salvageBefore.Xp, salvageAfter.Xp); Assert.Equal(refineBefore.Xp, refineAfter.Xp); } [Fact] public async Task PostAbilityCast_ShouldUseCatalogCooldown_WhenGuardCooldownLongerThanPulse() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); await BindSlot0GuardAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = new AbilityCastRequest { SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); factory.FakeClock.Advance(TimeSpan.FromSeconds(3.5)); var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); factory.FakeClock.Advance(TimeSpan.FromSeconds(3)); var third = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert var body1 = await first.Content.ReadFromJsonAsync(); var body2 = await second.Content.ReadFromJsonAsync(); var body3 = await third.Content.ReadFromJsonAsync(); Assert.True(body1!.Accepted); Assert.False(body2!.Accepted); 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); // Assert var inventoryAfter = await client.GetFromJsonAsync( "/game/players/dev-local-1/inventory"); var completionStore = factory.Services.GetRequiredService(); 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); // Assert var inventoryAfter = await client.GetFromJsonAsync( "/game/players/dev-local-1/inventory"); var completionStore = factory.Services.GetRequiredService(); 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); } }