From 9b48ec5f3955099a70f043a1dc3c622bfc42189d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 15:40:12 -0400 Subject: [PATCH] NEO-103: Add GET /game/world/encounter-definitions API. Expose schema v1 encounter list with nested completionCriteria and rewardTable summary via encounter and reward-table registries. --- .../EncounterDefinitionsWorldApiTests.cs | 50 ++++++++++++++ .../EncounterDefinitionsListDtos.cs | 65 ++++++++++++++++++ .../EncounterDefinitionsWorldApi.cs | 68 +++++++++++++++++++ server/NeonSprawl.Server/Program.cs | 1 + 4 files changed, 184 insertions(+) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs new file mode 100644 index 0000000..1ada5af --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Encounters; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public class EncounterDefinitionsWorldApiTests +{ + /// Frozen prototype encounter id. Keep in sync with Bruno. + public const string PrototypeCombatPocket = "prototype_combat_pocket"; + + [Fact] + public async Task GetEncounterDefinitions_ShouldReturnSchemaV1_WithFrozenEncounterAndNestedRewardTable() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/game/world/encounter-definitions"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(EncounterDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.NotNull(body.Encounters); + Assert.Single(body.Encounters); + + var encounter = body.Encounters[0]; + Assert.Equal(PrototypeCombatPocket, encounter.Id); + Assert.Equal("Prototype Combat Pocket", encounter.DisplayName); + Assert.NotNull(encounter.CompletionCriteria); + Assert.Equal("defeat_all_targets", encounter.CompletionCriteria.Kind); + Assert.Equal( + ["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"], + encounter.RequiredNpcInstanceIds); + + Assert.NotNull(encounter.RewardTable); + Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTable.Id); + Assert.Equal("Prototype Combat Pocket Clear", encounter.RewardTable.DisplayName); + Assert.Equal(2, encounter.RewardTable.FixedGrants.Count); + + var scrapGrant = encounter.RewardTable.FixedGrants.Single(g => g.ItemId == "scrap_metal_bulk"); + Assert.Equal(10, scrapGrant.Quantity); + + var tokenGrant = encounter.RewardTable.FixedGrants.Single(g => g.ItemId == "contract_handoff_token"); + Assert.Equal(1, tokenGrant.Quantity); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs new file mode 100644 index 0000000..d8bb908 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Encounters; + +/// JSON body for GET /game/world/encounter-definitions (NEO-103). +public sealed class EncounterDefinitionsListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Loaded encounters ordered by stable id (ordinal), matching . + [JsonPropertyName("encounters")] + public required IReadOnlyList Encounters { get; init; } +} + +/// One row in the read-only encounter definition projection. +public sealed class EncounterDefinitionJson +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("displayName")] + public required string DisplayName { get; init; } + + [JsonPropertyName("completionCriteria")] + public required EncounterCompletionCriteriaJson CompletionCriteria { get; init; } + + [JsonPropertyName("requiredNpcInstanceIds")] + public required IReadOnlyList RequiredNpcInstanceIds { get; init; } + + [JsonPropertyName("rewardTable")] + public required EncounterRewardTableJson RewardTable { get; init; } +} + +/// Completion rule summary for one encounter row. +public sealed class EncounterCompletionCriteriaJson +{ + [JsonPropertyName("kind")] + public required string Kind { get; init; } +} + +/// Nested reward table summary linked from an encounter row. +public sealed class EncounterRewardTableJson +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("displayName")] + public required string DisplayName { get; init; } + + [JsonPropertyName("fixedGrants")] + public required IReadOnlyList FixedGrants { get; init; } +} + +/// One deterministic item grant in a nested reward table summary. +public sealed class EncounterRewardGrantJson +{ + [JsonPropertyName("itemId")] + public required string ItemId { get; init; } + + [JsonPropertyName("quantity")] + public required int Quantity { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs new file mode 100644 index 0000000..a7ab60a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs @@ -0,0 +1,68 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Maps GET /game/world/encounter-definitions (NEO-103). +public static class EncounterDefinitionsWorldApi +{ + public static WebApplication MapEncounterDefinitionsWorldApi(this WebApplication app) + { + app.MapGet( + "/game/world/encounter-definitions", + (IEncounterDefinitionRegistry encounterRegistry, IRewardTableDefinitionRegistry rewardTableRegistry) => + { + var defs = encounterRegistry.GetDefinitionsInIdOrder(); + var encounters = new List(defs.Count); + foreach (var d in defs) + { + if (!rewardTableRegistry.TryGetDefinition(d.RewardTableId, out var rewardTable) || + rewardTable is null) + { + throw new InvalidOperationException( + $"Encounter definition '{d.Id}' references reward table '{d.RewardTableId}' that is not in the loaded catalog."); + } + + encounters.Add( + new EncounterDefinitionJson + { + Id = d.Id, + DisplayName = d.DisplayName, + CompletionCriteria = new EncounterCompletionCriteriaJson + { + Kind = d.CompletionCriteriaKind, + }, + RequiredNpcInstanceIds = d.RequiredNpcInstanceIds, + RewardTable = MapRewardTable(rewardTable), + }); + } + + return Results.Json( + new EncounterDefinitionsListResponse + { + SchemaVersion = EncounterDefinitionsListResponse.CurrentSchemaVersion, + Encounters = encounters, + }); + }); + + return app; + } + + private static EncounterRewardTableJson MapRewardTable(RewardTableDefRow rewardTable) + { + var grants = new List(rewardTable.FixedGrants.Count); + foreach (var grant in rewardTable.FixedGrants) + { + grants.Add( + new EncounterRewardGrantJson + { + ItemId = grant.ItemId, + Quantity = grant.Quantity, + }); + } + + return new EncounterRewardTableJson + { + Id = rewardTable.Id, + DisplayName = rewardTable.DisplayName, + FixedGrants = grants, + }; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 27e062d..7b3c050 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -66,6 +66,7 @@ app.MapCombatTargetsWorldApi(); app.MapNpcRuntimeSnapshotWorldApi(); app.MapPlayerCombatHealthApi(); app.MapResourceNodeDefinitionsWorldApi(); +app.MapEncounterDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi();