NEO-115: add GET /game/world/quest-definitions API and tests.

Expose read-only v1 quest projection from IQuestDefinitionRegistry with
flat objective fields, Bruno collection, and integration coverage.
pull/154/head
VinPropane 2026-06-03 22:14:37 -04:00
parent a5be355a22
commit 31cd67725d
7 changed files with 316 additions and 0 deletions

View File

@ -0,0 +1,57 @@
meta {
name: GET quest definitions
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/world/quest-definitions
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and quests array", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.quests).to.be.an("array");
expect(body.quests.length).to.equal(4);
});
test("quests are ascending by id (ordinal)", function () {
const body = res.getBody();
const ids = body.quests.map((x) => x.id);
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
expect(ids).to.eql(sorted);
});
test("prototype_quest_gather_intro row matches catalog", function () {
const body = res.getBody();
const row = body.quests.find((x) => x.id === "prototype_quest_gather_intro");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Intro: Salvage Run");
expect(row.prerequisiteQuestIds).to.eql([]);
expect(row.steps).to.have.length(1);
expect(row.steps[0].objectives[0]).to.eql({
id: "gather_intro_obj_scrap",
kind: "gather_item",
itemId: "scrap_metal_bulk",
quantity: 3,
});
});
test("prototype_quest_operator_chain has four steps and terminal token objective", function () {
const body = res.getBody();
const row = body.quests.find((x) => x.id === "prototype_quest_operator_chain");
expect(row).to.be.an("object");
expect(row.steps).to.have.length(4);
expect(row.steps[3].objectives[0]).to.eql({
id: "chain_obj_token",
kind: "inventory_has_item",
itemId: "contract_handoff_token",
quantity: 1,
});
});
}

View File

@ -0,0 +1,3 @@
meta {
name: quest-definitions
}

View File

@ -0,0 +1,78 @@
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Quests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionsWorldApiTests
{
/// <summary>Frozen prototype quest ids in ascending ordinal order. Keep in sync with Bruno.</summary>
public static readonly string[] FrozenQuestIdsInOrdinalOrder =
[
"prototype_quest_combat_intro",
"prototype_quest_gather_intro",
"prototype_quest_operator_chain",
"prototype_quest_refine_intro",
];
[Fact]
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/quest-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestDefinitionsListResponse>();
Assert.NotNull(body);
Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Quests);
Assert.Equal(4, body.Quests.Count);
Assert.Equal(FrozenQuestIdsInOrdinalOrder, body.Quests.Select(q => q.Id).ToArray());
var gatherIntro = body.Quests.Single(q => q.Id == "prototype_quest_gather_intro");
Assert.Equal("Intro: Salvage Run", gatherIntro.DisplayName);
Assert.Empty(gatherIntro.PrerequisiteQuestIds);
Assert.Single(gatherIntro.Steps);
var gatherStep = gatherIntro.Steps[0];
Assert.Equal("gather_intro_step_salvage", gatherStep.Id);
Assert.Equal("Gather scrap metal", gatherStep.DisplayName);
Assert.Single(gatherStep.Objectives);
var gatherObjective = gatherStep.Objectives[0];
Assert.Equal("gather_intro_obj_scrap", gatherObjective.Id);
Assert.Equal("gather_item", gatherObjective.Kind);
Assert.Equal("scrap_metal_bulk", gatherObjective.ItemId);
Assert.Equal(3, gatherObjective.Quantity);
Assert.Null(gatherObjective.RecipeId);
Assert.Null(gatherObjective.EncounterId);
var refineIntro = body.Quests.Single(q => q.Id == "prototype_quest_refine_intro");
Assert.Equal(["prototype_quest_gather_intro"], refineIntro.PrerequisiteQuestIds);
var combatIntro = body.Quests.Single(q => q.Id == "prototype_quest_combat_intro");
Assert.Single(combatIntro.Steps);
var combatObjective = combatIntro.Steps[0].Objectives[0];
Assert.Equal("encounter_complete", combatObjective.Kind);
Assert.Equal("prototype_combat_pocket", combatObjective.EncounterId);
Assert.Null(combatObjective.ItemId);
Assert.Null(combatObjective.Quantity);
Assert.Null(combatObjective.RecipeId);
var operatorChain = body.Quests.Single(q => q.Id == PrototypeE7M1QuestCatalogRules.ChainQuestId);
Assert.Equal(4, operatorChain.Steps.Count);
Assert.Equal(
[
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro",
],
operatorChain.PrerequisiteQuestIds);
var terminalObjective = operatorChain.Steps[3].Objectives[0];
Assert.Equal("inventory_has_item", terminalObjective.Kind);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId);
Assert.Equal(1, terminalObjective.Quantity);
}
}

View File

@ -0,0 +1,71 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>JSON body for <c>GET /game/world/quest-definitions</c> (NEO-115).</summary>
public sealed class QuestDefinitionsListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Loaded quests ordered by stable <c>id</c> (ordinal), matching <see cref="IQuestDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
[JsonPropertyName("quests")]
public required IReadOnlyList<QuestDefinitionJson> Quests { get; init; }
}
/// <summary>One row in the read-only quest definition projection.</summary>
public sealed class QuestDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("prerequisiteQuestIds")]
public required IReadOnlyList<string> PrerequisiteQuestIds { get; init; }
[JsonPropertyName("steps")]
public required IReadOnlyList<QuestStepDefinitionJson> Steps { get; init; }
}
/// <summary>One step in the read-only quest definition projection.</summary>
public sealed class QuestStepDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("objectives")]
public required IReadOnlyList<QuestObjectiveDefinitionJson> Objectives { get; init; }
}
/// <summary>One objective in the read-only quest definition projection (flat mirror of content).</summary>
public sealed class QuestObjectiveDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("itemId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ItemId { get; init; }
[JsonPropertyName("quantity")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Quantity { get; init; }
[JsonPropertyName("recipeId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RecipeId { get; init; }
[JsonPropertyName("encounterId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? EncounterId { get; init; }
}

View File

@ -0,0 +1,98 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Maps <c>GET /game/world/quest-definitions</c> (NEO-115).</summary>
public static class QuestDefinitionsWorldApi
{
public static WebApplication MapQuestDefinitionsWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/quest-definitions",
(IQuestDefinitionRegistry questRegistry) =>
{
var defs = questRegistry.GetDefinitionsInIdOrder();
var quests = new List<QuestDefinitionJson>(defs.Count);
foreach (var d in defs)
{
quests.Add(
new QuestDefinitionJson
{
Id = d.Id,
DisplayName = d.DisplayName,
PrerequisiteQuestIds = d.PrerequisiteQuestIds,
Steps = MapSteps(d.Steps),
});
}
return Results.Json(
new QuestDefinitionsListResponse
{
SchemaVersion = QuestDefinitionsListResponse.CurrentSchemaVersion,
Quests = quests,
});
});
return app;
}
private static List<QuestStepDefinitionJson> MapSteps(IReadOnlyList<QuestStepDefRow> steps)
{
var list = new List<QuestStepDefinitionJson>(steps.Count);
foreach (var step in steps)
{
list.Add(
new QuestStepDefinitionJson
{
Id = step.Id,
DisplayName = step.DisplayName,
Objectives = MapObjectives(step.Objectives),
});
}
return list;
}
private static List<QuestObjectiveDefinitionJson> MapObjectives(IReadOnlyList<QuestObjectiveDefRow> objectives)
{
var list = new List<QuestObjectiveDefinitionJson>(objectives.Count);
foreach (var objective in objectives)
{
list.Add(MapObjective(objective));
}
return list;
}
private static QuestObjectiveDefinitionJson MapObjective(QuestObjectiveDefRow objective) =>
objective.Kind switch
{
"gather_item" or "inventory_has_item" => new QuestObjectiveDefinitionJson
{
Id = objective.Id,
Kind = objective.Kind,
ItemId = objective.ItemId,
Quantity = objective.Quantity,
},
"craft_recipe" => new QuestObjectiveDefinitionJson
{
Id = objective.Id,
Kind = objective.Kind,
RecipeId = objective.RecipeId,
Quantity = objective.Quantity,
},
"encounter_complete" => new QuestObjectiveDefinitionJson
{
Id = objective.Id,
Kind = objective.Kind,
EncounterId = objective.EncounterId,
},
_ => new QuestObjectiveDefinitionJson
{
Id = objective.Id,
Kind = objective.Kind,
ItemId = objective.ItemId,
Quantity = objective.Quantity,
RecipeId = objective.RecipeId,
EncounterId = objective.EncounterId,
},
};
}

View File

@ -70,6 +70,7 @@ app.MapNpcRuntimeSnapshotWorldApi();
app.MapPlayerCombatHealthApi();
app.MapResourceNodeDefinitionsWorldApi();
app.MapEncounterDefinitionsWorldApi();
app.MapQuestDefinitionsWorldApi();
app.MapPlayerInventoryApi();
app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();

View File

@ -148,6 +148,14 @@ On startup the host loads every **`*_quests.json`** under the quests directory *
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
## Quest definitions (NEO-115)
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/quest-definitions"
```
## Encounter definitions (NEO-103)
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.