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
parent
a5be355a22
commit
31cd67725d
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: quest-definitions
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ app.MapNpcRuntimeSnapshotWorldApi();
|
|||
app.MapPlayerCombatHealthApi();
|
||||
app.MapResourceNodeDefinitionsWorldApi();
|
||||
app.MapEncounterDefinitionsWorldApi();
|
||||
app.MapQuestDefinitionsWorldApi();
|
||||
app.MapPlayerInventoryApi();
|
||||
app.MapPlayerCraftApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue