NEO-119: Add GET quest-progress API with inventory refresh hook.

pull/158/head
VinPropane 2026-06-07 11:56:28 -04:00
parent 78e5de3338
commit 066d1ff981
6 changed files with 578 additions and 0 deletions

View File

@ -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);
}
});
}

View File

@ -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<QuestProgressListResponse>();
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<QuestProgressListResponse>();
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<QuestProgressListResponse>();
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<QuestProgressListResponse>();
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<QuestProgressListResponse>();
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<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IResourceNodeDefinitionRegistry>(),
services.GetRequiredService<IResourceNodeInstanceStore>(),
services.GetRequiredService<IRecipeDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
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);
}

View File

@ -199,6 +199,42 @@ public static class QuestObjectiveWiring
}
}
/// <summary>
/// GET polling hook (NEO-119): refresh <see cref="QuestObjectiveKinds.InventoryHasItem"/> counters and
/// auto-advance/complete active quests before a progress snapshot is returned.
/// </summary>
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);
}
}
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> on the active step for one quest.</summary>
public static void TryProcessInventoryHasItemForQuest(
string playerId,

View File

@ -0,0 +1,99 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Maps <c>GET /game/players/{{id}}/quest-progress</c> (NEO-119).</summary>
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<Program>? 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<QuestProgressRowJson>(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<string, int>(StringComparer.Ordinal),
};
}
var counters = new Dictionary<string, int>(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}'."),
};
}
}

View File

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>JSON body for <c>GET /game/players/{{id}}/quest-progress</c> (NEO-119).</summary>
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; }
/// <summary>Per-player rows ordered by catalog quest <c>id</c> (ordinal).</summary>
[JsonPropertyName("quests")]
public required IReadOnlyList<QuestProgressRowJson> Quests { get; init; }
}
/// <summary>Authoritative per-player progress for one quest definition.</summary>
public sealed class QuestProgressRowJson
{
[JsonPropertyName("questId")]
public required string QuestId { get; init; }
/// <summary><c>not_started</c>, <c>active</c>, or <c>completed</c>.</summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
[JsonPropertyName("currentStepIndex")]
public int CurrentStepIndex { get; init; }
/// <summary>Objective id → accumulated count for the current step only.</summary>
[JsonPropertyName("objectiveCounters")]
public required IReadOnlyDictionary<string, int> ObjectiveCounters { get; init; }
[JsonPropertyName("completedAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
}

View File

@ -77,6 +77,7 @@ app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();
app.MapGigProgressionSnapshotApi();
app.MapEncounterProgressApi();
app.MapQuestProgressApi();
app.MapPerkStateApi();
if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") ||