NEO-119: Add GET quest-progress API with inventory refresh hook.
parent
78e5de3338
commit
066d1ff981
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> on the active step for one quest.</summary>
|
||||||
public static void TryProcessInventoryHasItemForQuest(
|
public static void TryProcessInventoryHasItemForQuest(
|
||||||
string playerId,
|
string playerId,
|
||||||
|
|
|
||||||
|
|
@ -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}'."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,7 @@ app.MapPlayerCraftApi();
|
||||||
app.MapSkillProgressionSnapshotApi();
|
app.MapSkillProgressionSnapshotApi();
|
||||||
app.MapGigProgressionSnapshotApi();
|
app.MapGigProgressionSnapshotApi();
|
||||||
app.MapEncounterProgressApi();
|
app.MapEncounterProgressApi();
|
||||||
|
app.MapQuestProgressApi();
|
||||||
app.MapPerkStateApi();
|
app.MapPerkStateApi();
|
||||||
if (app.Environment.IsDevelopment() ||
|
if (app.Environment.IsDevelopment() ||
|
||||||
app.Environment.IsEnvironment("Testing") ||
|
app.Environment.IsEnvironment("Testing") ||
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue