using System.Net; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using Xunit; namespace NeonSprawl.Server.Tests.Game.Encounters; public sealed class EncounterProgressApiTests { private const string PrototypeEncounterId = "prototype_combat_pocket"; [Fact] public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync("/game/players/missing-player/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task GetEncounterProgress_ShouldReturnInactive_WhenNoEngagement() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(EncounterProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.Equal("dev-local-1", body.PlayerId); var row = Assert.Single(body.Encounters); Assert.Equal(PrototypeEncounterId, row.EncounterId); Assert.Equal(EncounterProgressApi.StateInactive, row.State); Assert.Empty(row.DefeatedTargetIds); Assert.Null(row.CompletedAt); Assert.Null(row.RewardGrantSummary); } [Fact] public async Task GetEncounterProgress_ShouldReturnActiveWithOneDefeat_WhenFirstNpcDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); // Act await DefeatLockedTargetWithPulseAsync( client, factory, PrototypeNpcRegistry.PrototypeNpcMeleeId); var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); var row = Assert.Single(body!.Encounters); Assert.Equal(EncounterProgressApi.StateActive, row.State); Assert.Equal([PrototypeNpcRegistry.PrototypeNpcMeleeId], row.DefeatedTargetIds); Assert.Null(row.CompletedAt); Assert.Null(row.RewardGrantSummary); } [Fact] public async Task GetEncounterProgress_ShouldReturnActiveWithTwoDefeats_WhenTwoNpcDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); // Act await DefeatLockedTargetWithPulseAsync( client, factory, PrototypeNpcRegistry.PrototypeNpcMeleeId); await DefeatLockedTargetWithPulseAsync( client, factory, PrototypeNpcRegistry.PrototypeNpcRangedId); var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); var row = Assert.Single(body!.Encounters); Assert.Equal(EncounterProgressApi.StateActive, row.State); Assert.Equal( [ PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcRangedId, ], row.DefeatedTargetIds); Assert.Null(row.CompletedAt); Assert.Null(row.RewardGrantSummary); var completionStore = factory.Services.GetRequiredService(); Assert.False(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId)); } [Fact] public async Task GetEncounterProgress_ShouldReturnCompletedWithGrantSummary_WhenAllThreeNpcDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); // Act await DefeatLockedTargetWithPulseAsync( client, factory, PrototypeNpcRegistry.PrototypeNpcMeleeId); await DefeatLockedTargetWithPulseAsync( client, factory, PrototypeNpcRegistry.PrototypeNpcRangedId); await DefeatLockedTargetWithPulseAsync( client, factory, PrototypeNpcRegistry.PrototypeNpcEliteId); var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); var row = Assert.Single(body!.Encounters); Assert.Equal(EncounterProgressApi.StateCompleted, row.State); Assert.Equal( [ PrototypeNpcRegistry.PrototypeNpcEliteId, PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcRangedId, ], row.DefeatedTargetIds); Assert.NotNull(row.CompletedAt); Assert.NotNull(row.RewardGrantSummary); Assert.Equal(10, row.RewardGrantSummary!.Single(g => g.ItemId == "scrap_metal_bulk").Quantity); Assert.Equal(1, row.RewardGrantSummary.Single(g => g.ItemId == "contract_handoff_token").Quantity); var completionStore = factory.Services.GetRequiredService(); var eventStore = factory.Services.GetRequiredService(); Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId)); Assert.True(eventStore.TryGet("dev-local-1", PrototypeEncounterId, out _)); } 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 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); } }