diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs new file mode 100644 index 0000000..f68851c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs @@ -0,0 +1,251 @@ +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); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs new file mode 100644 index 0000000..47d62a6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs @@ -0,0 +1,138 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Maps GET /game/players/{{id}}/encounter-progress (NEO-108). +public static class EncounterProgressApi +{ + public const string StateInactive = "inactive"; + public const string StateActive = "active"; + public const string StateCompleted = "completed"; + + public static WebApplication MapEncounterProgressApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/encounter-progress", + (string id, IPositionStateStore positions, IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + return Results.Json( + BuildSnapshot( + trimmedId, + encounterRegistry, + progressStore, + completionStore, + completeEventStore)); + }); + + return app; + } + + internal static EncounterProgressListResponse BuildSnapshot( + string playerId, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore) + { + var defs = encounterRegistry.GetDefinitionsInIdOrder(); + var rows = new List(defs.Count); + foreach (var def in defs) + { + rows.Add( + MapRow( + playerId, + def.Id, + progressStore, + completionStore, + completeEventStore)); + } + + return new EncounterProgressListResponse + { + PlayerId = playerId, + Encounters = rows, + SchemaVersion = EncounterProgressListResponse.CurrentSchemaVersion, + }; + } + + private static EncounterProgressRowJson MapRow( + string playerId, + string encounterId, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore) + { + var defeatedTargetIds = Array.Empty(); + if (progressStore.TryGetProgress(playerId, encounterId, out var progress)) + { + defeatedTargetIds = progress.DefeatedNpcInstanceIds + .OrderBy(static id => id, StringComparer.Ordinal) + .ToArray(); + } + + if (completionStore.IsCompleted(playerId, encounterId)) + { + if (!completionStore.TryGetCompletedAt(playerId, encounterId, out var completedAt)) + { + throw new InvalidOperationException( + $"Encounter '{encounterId}' is completed for player '{playerId}' but completedAt is missing."); + } + + if (!completeEventStore.TryGet(playerId, encounterId, out var completeEvent)) + { + throw new InvalidOperationException( + $"Encounter '{encounterId}' is completed for player '{playerId}' but EncounterCompleteEvent is missing."); + } + + return new EncounterProgressRowJson + { + EncounterId = encounterId, + State = StateCompleted, + DefeatedTargetIds = defeatedTargetIds, + CompletedAt = completedAt, + RewardGrantSummary = MapGrantSummary(completeEvent.GrantedItems), + }; + } + + if (progressStore.TryGetProgress(playerId, encounterId, out progress) && progress.Started) + { + return new EncounterProgressRowJson + { + EncounterId = encounterId, + State = StateActive, + DefeatedTargetIds = defeatedTargetIds, + }; + } + + return new EncounterProgressRowJson + { + EncounterId = encounterId, + State = StateInactive, + DefeatedTargetIds = defeatedTargetIds, + }; + } + + private static List MapGrantSummary(IReadOnlyList grants) + { + var summary = new List(grants.Count); + foreach (var grant in grants) + { + summary.Add( + new EncounterRewardGrantJson + { + ItemId = grant.ItemId, + Quantity = grant.Quantity, + }); + } + + return summary; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs new file mode 100644 index 0000000..9d789dc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Encounters; + +/// JSON body for GET /game/players/{{id}}/encounter-progress (NEO-108). +public sealed class EncounterProgressListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// Per-player rows ordered by catalog encounter id (ordinal). + [JsonPropertyName("encounters")] + public required IReadOnlyList Encounters { get; init; } +} + +/// Authoritative per-player progress for one encounter definition. +public sealed class EncounterProgressRowJson +{ + [JsonPropertyName("encounterId")] + public required string EncounterId { get; init; } + + /// inactive, active, or completed. + [JsonPropertyName("state")] + public required string State { get; init; } + + [JsonPropertyName("defeatedTargetIds")] + public required IReadOnlyList DefeatedTargetIds { get; init; } + + [JsonPropertyName("completedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CompletedAt { get; init; } + + [JsonPropertyName("rewardGrantSummary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? RewardGrantSummary { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 7b3c050..7bf916a 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -71,6 +71,7 @@ app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi(); +app.MapEncounterProgressApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") ||