From 066d1ff9812d5bee484bbe2220a062b4d59e816c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 11:56:28 -0400 Subject: [PATCH] NEO-119: Add GET quest-progress API with inventory refresh hook. --- .../Get quest progress default.bru | 44 +++ .../Game/Quests/QuestProgressApiTests.cs | 357 ++++++++++++++++++ .../Game/Quests/QuestObjectiveWiring.cs | 36 ++ .../Game/Quests/QuestProgressApi.cs | 99 +++++ .../Game/Quests/QuestProgressListDtos.cs | 41 ++ server/NeonSprawl.Server/Program.cs | 1 + 6 files changed, 578 insertions(+) create mode 100644 bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru new file mode 100644 index 0000000..25cc15f --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru @@ -0,0 +1,44 @@ +meta { + name: GET quest progress default + type: http + seq: 2 +} + +docs { + NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started. +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/quest-progress + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + expect(body.quests).to.be.an("array"); + expect(body.quests.length).to.equal(4); + }); + + test("all prototype quests are not_started with empty counters", function () { + const body = res.getBody(); + const expectedIds = [ + "prototype_quest_combat_intro", + "prototype_quest_gather_intro", + "prototype_quest_operator_chain", + "prototype_quest_refine_intro", + ]; + const actualIds = body.quests.map((row) => row.questId).sort(); + expect(actualIds).to.eql(expectedIds); + for (const row of body.quests) { + expect(row.status).to.equal("not_started"); + expect(row.currentStepIndex).to.equal(0); + expect(row.objectiveCounters).to.eql({}); + expect(row.completedAt).to.equal(undefined); + } + }); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs new file mode 100644 index 0000000..0e37ca2 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -0,0 +1,357 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public sealed class QuestProgressApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string AlphaNodeId = "prototype_resource_node_alpha"; + private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; + private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; + private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; + private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; + private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId; + private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; + + [Fact] + public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/%20/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(QuestProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal(PlayerId, body.PlayerId); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, body.Quests.Count); + Assert.Equal( + PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(), + body.Quests.Select(static row => row.QuestId).Order(StringComparer.Ordinal).ToArray()); + foreach (var row in body.Quests) + { + Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status); + Assert.Equal(0, row.CurrentStepIndex); + Assert.Empty(row.ObjectiveCounters); + Assert.Null(row.CompletedAt); + } + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnActivePartialCounters_WhenGatherIntroInProgress() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + Assert.True(TryAccept(deps, GatherQuestId).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter( + PlayerId, + GatherQuestId, + GatherObjectiveId, + 2, + out _)); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId); + Assert.Equal(QuestProgressApi.StatusActive, row.Status); + Assert.Equal(0, row.CurrentStepIndex); + Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]); + Assert.Null(row.CompletedAt); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnCompletedGatherIntro_WhenObjectivesSatisfied() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + Assert.True(TryAccept(deps, GatherQuestId).Success); + for (var i = 0; i < 3; i++) + { + Assert.True(TryGather(deps, AlphaNodeId).Success); + } + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.Equal(0, row.CurrentStepIndex); + Assert.NotNull(row.CompletedAt); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnCompletedChainAtTerminalStepIndex_WhenOperatorChainFinished() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + CompleteOperatorChain(deps); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.Equal(3, row.CurrentStepIndex); + Assert.NotNull(row.CompletedAt); + } + + [Fact] + public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + AcceptAndMarkComplete(deps, GatherQuestId); + AcceptAndMarkComplete(deps, RefineQuestId); + AcceptAndMarkComplete(deps, CombatQuestId); + SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1); + Assert.True(TryAccept(deps, ChainQuestId).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _)); + Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 1, out _)); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _)); + Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 2, out _)); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _)); + Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 3, out _)); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var beforeGet)); + Assert.Equal(QuestProgressStatus.Active, beforeGet.Status); + Assert.Equal(3, beforeGet.CurrentStepIndex); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.Equal(3, row.CurrentStepIndex); + Assert.NotNull(row.CompletedAt); + } + + private static void CompleteOperatorChain(QuestWiringTestDependencies deps) + { + AcceptAndMarkComplete(deps, GatherQuestId); + AcceptAndMarkComplete(deps, RefineQuestId); + CompleteCombatEncounter(deps); + Assert.True(TryAccept(deps, ChainQuestId).Success); + Assert.True(TryGather(deps, "prototype_urban_bulk_delta").Success); + SeedStack(deps, "scrap_metal_bulk", 5); + Assert.True(TryCraft(deps, "refine_scrap_standard").Success); + SeedStack(deps, "refined_plate_stock", 2); + SeedStack(deps, "scrap_metal_bulk", 1); + Assert.True(TryCraft(deps, "make_field_stim_mk0").Success); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress)); + Assert.Equal(QuestProgressStatus.Completed, progress.Status); + Assert.Equal(3, progress.CurrentStepIndex); + } + + private static void CompleteCombatEncounter(QuestWiringTestDependencies deps) + { + Assert.True(TryAccept(deps, CombatQuestId).Success); + DefeatAllRequiredTargets(deps); + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + "prototype_combat_pocket", + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.EncounterProgressStore, + deps.CompletionStore, + deps.CompleteEventStore, + deps.ItemRegistry, + deps.InventoryStore, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider); + Assert.True(result.Success); + } + + private static void DefeatAllRequiredTargets(QuestWiringTestDependencies deps) + { + foreach (var npcId in new[] { "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" }) + { + _ = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + npcId, + deps.EncounterRegistry, + deps.EncounterProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + npcId, + deps.EncounterRegistry, + deps.EncounterProgressStore, + deps.CompletionStore); + } + } + + private static QuestStateOperationResult TryAccept(QuestWiringTestDependencies deps, string questId) => + QuestStateOperations.TryAccept( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + deps.InventoryStore, + deps.ItemRegistry, + deps.TimeProvider); + + private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) + { + Assert.True(TryAccept(deps, questId).Success); + Assert.True(QuestStateOperations.TryMarkComplete( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider).Success); + } + + private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) => + GatherOperations.TryGather( + PlayerId, + nodeId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider); + + private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => + CraftOperations.TryCraft( + PlayerId, + recipeId, + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider); + + private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) + { + var outcome = PlayerInventoryOperations.TryAddStack( + PlayerId, + itemId, + quantity, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind); + } + + private static QuestWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new QuestWiringTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + TimeProvider.System); + } + + private readonly record struct QuestWiringTestDependencies( + IQuestDefinitionRegistry QuestRegistry, + IPlayerQuestStateStore ProgressStore, + IPlayerInventoryStore InventoryStore, + IItemDefinitionRegistry ItemRegistry, + IResourceNodeDefinitionRegistry NodeRegistry, + IResourceNodeInstanceStore InstanceStore, + IRecipeDefinitionRegistry RecipeRegistry, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore XpStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IEncounterDefinitionRegistry EncounterRegistry, + IRewardTableDefinitionRegistry RewardTableRegistry, + IEncounterProgressStore EncounterProgressStore, + IEncounterCompletionStore CompletionStore, + IEncounterCompleteEventStore CompleteEventStore, + TimeProvider TimeProvider); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs index 09da6da..1113ab1 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs @@ -199,6 +199,42 @@ public static class QuestObjectiveWiring } } + /// + /// GET polling hook (NEO-119): refresh counters and + /// auto-advance/complete active quests before a progress snapshot is returned. + /// + public static void TryRefreshInventoryProgressForPlayer( + string playerId, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore, + IPlayerInventoryStore inventoryStore, + IItemDefinitionRegistry itemRegistry, + TimeProvider timeProvider, + ILogger? logger = null) + { + try + { + RefreshInventoryHasItemCountersForPlayer( + playerId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry); + + TryCompleteAllActiveQuests( + playerId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry, + timeProvider); + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Quest progress GET refresh skipped for player {PlayerId}", playerId); + } + } + /// Re-evaluates on the active step for one quest. public static void TryProcessInventoryHasItemForQuest( string playerId, diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs new file mode 100644 index 0000000..6403e75 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -0,0 +1,99 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Quests; + +/// Maps GET /game/players/{{id}}/quest-progress (NEO-119). +public static class QuestProgressApi +{ + public const string StatusNotStarted = "not_started"; + public const string StatusActive = "active"; + public const string StatusCompleted = "completed"; + + public static WebApplication MapQuestProgressApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/quest-progress", + (string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, + IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger? logger) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer( + trimmedId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry, + timeProvider, + logger); + + return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore)); + }); + + return app; + } + + internal static QuestProgressListResponse BuildSnapshot( + string playerId, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore) + { + var defs = questRegistry.GetDefinitionsInIdOrder(); + var rows = new List(defs.Count); + foreach (var def in defs) + { + rows.Add(MapRow(playerId, def.Id, progressStore)); + } + + return new QuestProgressListResponse + { + PlayerId = playerId, + Quests = rows, + SchemaVersion = QuestProgressListResponse.CurrentSchemaVersion, + }; + } + + private static QuestProgressRowJson MapRow( + string playerId, + string questId, + IPlayerQuestStateStore progressStore) + { + if (!progressStore.TryGetProgress(playerId, questId, out var snapshot)) + { + return new QuestProgressRowJson + { + QuestId = questId, + Status = StatusNotStarted, + CurrentStepIndex = 0, + ObjectiveCounters = new Dictionary(StringComparer.Ordinal), + }; + } + + var counters = new Dictionary(snapshot.ObjectiveCounters, StringComparer.Ordinal); + return snapshot.Status switch + { + QuestProgressStatus.Active => new QuestProgressRowJson + { + QuestId = questId, + Status = StatusActive, + CurrentStepIndex = snapshot.CurrentStepIndex, + ObjectiveCounters = counters, + }, + QuestProgressStatus.Completed => new QuestProgressRowJson + { + QuestId = questId, + Status = StatusCompleted, + CurrentStepIndex = snapshot.CurrentStepIndex, + ObjectiveCounters = counters, + CompletedAt = snapshot.CompletedAt, + }, + _ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."), + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs new file mode 100644 index 0000000..a0f8803 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Quests; + +/// JSON body for GET /game/players/{{id}}/quest-progress (NEO-119). +public sealed class QuestProgressListResponse +{ + 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 quest id (ordinal). + [JsonPropertyName("quests")] + public required IReadOnlyList Quests { get; init; } +} + +/// Authoritative per-player progress for one quest definition. +public sealed class QuestProgressRowJson +{ + [JsonPropertyName("questId")] + public required string QuestId { get; init; } + + /// not_started, active, or completed. + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("currentStepIndex")] + public int CurrentStepIndex { get; init; } + + /// Objective id → accumulated count for the current step only. + [JsonPropertyName("objectiveCounters")] + public required IReadOnlyDictionary ObjectiveCounters { get; init; } + + [JsonPropertyName("completedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CompletedAt { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 70118b9..9c65166 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -77,6 +77,7 @@ app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi(); app.MapEncounterProgressApi(); +app.MapQuestProgressApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") ||