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_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync("/game/players/%20/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.True(row.DefeatedTargetIds.SequenceEqual( row.DefeatedTargetIds.OrderBy(static id => id, StringComparer.Ordinal))); 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"); var secondResponse = await client.GetAsync("/game/players/dev-local-1/encounter-progress"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); var body = await response.Content.ReadFromJsonAsync(); var secondBody = await secondResponse.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.NotNull(secondBody); var row = Assert.Single(body!.Encounters); var secondRow = Assert.Single(secondBody!.Encounters); Assert.Equal(EncounterProgressApi.StateCompleted, row.State); Assert.Equal(EncounterProgressApi.StateCompleted, secondRow.State); Assert.Equal( [ PrototypeNpcRegistry.PrototypeNpcEliteId, PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcRangedId, ], row.DefeatedTargetIds); Assert.Equal(row.DefeatedTargetIds, secondRow.DefeatedTargetIds); Assert.NotNull(row.CompletedAt); Assert.NotNull(row.RewardGrantSummary); Assert.Equal(row.CompletedAt, secondRow.CompletedAt); Assert.Equal(2, row.RewardGrantSummary!.Count); Assert.Equal("scrap_metal_bulk", row.RewardGrantSummary[0].ItemId); Assert.Equal(10, row.RewardGrantSummary[0].Quantity); Assert.Equal("contract_handoff_token", row.RewardGrantSummary[1].ItemId); Assert.Equal(1, row.RewardGrantSummary[1].Quantity); Assert.Equal(row.RewardGrantSummary[0].ItemId, secondRow.RewardGrantSummary![0].ItemId); Assert.Equal(row.RewardGrantSummary[1].ItemId, secondRow.RewardGrantSummary[1].ItemId); 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 _)); } [Fact] public async Task BuildSnapshot_ShouldThrow_WhenCompletedButCompleteEventMissing() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var registry = factory.Services.GetRequiredService(); var progressStore = factory.Services.GetRequiredService(); var completionStore = factory.Services.GetRequiredService(); var eventStore = factory.Services.GetRequiredService(); Assert.True( completionStore.TryMarkCompleted( "dev-local-1", PrototypeEncounterId, DateTimeOffset.UtcNow)); // Act var ex = Record.Exception( () => EncounterProgressApi.BuildSnapshot( "dev-local-1", registry, progressStore, completionStore, eventStore)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("EncounterCompleteEvent is missing", ioe.Message, StringComparison.Ordinal); } [Fact] public void BuildSnapshot_ShouldThrow_WhenCompletedButCompletedAtMissing() { // Arrange var registry = CreatePrototypeEncounterRegistry(); var progressStore = new InMemoryEncounterProgressStore(); var completionStore = new CompletedWithoutTimestampStore(); var eventStore = new InMemoryEncounterCompleteEventStore(); // Act var ex = Record.Exception( () => EncounterProgressApi.BuildSnapshot( "dev-local-1", registry, progressStore, completionStore, eventStore)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("completedAt is missing", ioe.Message, StringComparison.Ordinal); } private static EncounterDefinitionRegistry CreatePrototypeEncounterRegistry() { var row = new EncounterDefRow( PrototypeEncounterId, "Prototype Combat Pocket", "defeat_all_targets", ["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"], "prototype_combat_pocket_clear"); var catalog = new EncounterDefinitionCatalog( "/tmp/encounters", new Dictionary(StringComparer.Ordinal) { [PrototypeEncounterId] = row }, catalogJsonFileCount: 1); return new EncounterDefinitionRegistry(catalog); } private sealed class CompletedWithoutTimestampStore : IEncounterCompletionStore { public bool IsCompleted(string playerId, string encounterId) => string.Equals(playerId, "dev-local-1", StringComparison.Ordinal) && string.Equals(encounterId, PrototypeEncounterId, StringComparison.Ordinal); public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => throw new NotSupportedException(); public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) { completedAt = default; return false; } } 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); } }