NEO-129: add completionRewardSummary to quest-progress GET.

Project IRewardDeliveryStore grant snapshots onto completed quest rows;
extend tests, Bruno gather-intro spine, and docs.
pull/168/head
VinPropane 2026-06-07 18:38:37 -04:00
parent 2b06eb2a1e
commit 78e805e428
11 changed files with 251 additions and 16 deletions

View File

@ -0,0 +1,75 @@
meta {
name: GET quest progress after gather intro complete
type: http
seq: 8
}
docs {
NEO-129: accept gather intro, complete via three alpha gathers, GET quest-progress asserts completionRewardSummary.
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const headers = { "Content-Type": "application/json" };
await axios.post(
`${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`,
{ schemaVersion: 1 },
{ headers, validateStatus: () => true },
);
await axios.post(
`${baseUrl}/game/players/${playerId}/move`,
{
schemaVersion: 1,
target: { x: 10, y: 0.9, z: -6 },
},
{ headers },
);
for (let i = 0; i < 3; i += 1) {
const interact = await axios.post(
`${baseUrl}/game/players/${playerId}/interact`,
{
schemaVersion: 1,
interactableId: "prototype_resource_node_alpha",
},
{ headers, validateStatus: () => true },
);
if (interact.status !== 200 || !interact.data?.success) {
throw new Error(`expected gather interact to succeed, got ${JSON.stringify(interact.data)}`);
}
}
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
body: none
auth: none
}
tests {
test("gather intro row is completed with completionRewardSummary", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_gather_intro");
expect(row).to.be.an("object");
expect(row.status).to.equal("completed");
expect(row.completionRewardSummary).to.be.an("object");
expect(row.completionRewardSummary.itemGrants).to.eql([]);
expect(row.completionRewardSummary.skillXpGrants).to.eql([
{ skillId: "salvage", amount: 25 },
]);
});
test("active and not_started rows omit completionRewardSummary", function () {
const body = res.getBody();
for (const row of body.quests) {
if (row.status === "not_started" || row.status === "active") {
expect(row.completionRewardSummary).to.equal(undefined);
}
}
});
}

View File

@ -39,6 +39,7 @@ tests {
expect(row.currentStepIndex).to.equal(0);
expect(row.objectiveCounters).to.eql({});
expect(row.completedAt).to.equal(undefined);
expect(row.completionRewardSummary).to.equal(undefined);
}
});
}

View File

@ -72,4 +72,6 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).
**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. HTTP **`completionRewardSummary`** in NEO-129. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
**NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.

File diff suppressed because one or more lines are too long

View File

@ -218,8 +218,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [ ] Completed quest rows include summary when delivery recorded.
- [ ] `not_started` / `active` rows omit summary.
- [x] Completed quest rows include summary when delivery recorded.
- [x] `not_started` / `active` rows omit summary.
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131).

View File

@ -49,8 +49,17 @@
## Acceptance criteria checklist
- [ ] Completed quest rows include **`completionRewardSummary`** when delivery recorded.
- [ ] **`not_started`** / **`active`** rows omit summary.
- [x] Completed quest rows include **`completionRewardSummary`** when delivery recorded.
- [x] **`not_started`** / **`active`** rows omit summary.
## Implementation reconciliation (shipped)
- **DTOs:** **`QuestCompletionRewardSummaryJson`** with nested **`itemGrants`** + **`skillXpGrants`**; optional **`completionRewardSummary`** on **`QuestProgressRowJson`**.
- **Projection:** **`QuestProgressApi.BuildSnapshot`** / **`MapQuestProgressRow`** read **`IRewardDeliveryStore.TryGet`**; omit summary when no delivery event (kickoff).
- **Accept:** **`QuestAcceptApi.MapResponse`** threads **`playerId`** + **`deliveryStore`** into row mapper.
- **Tests:** eight AAA tests in **`QuestProgressApiTests`** — omit on default/active; gather complete salvage **25**; operator chain item + XP; idempotent GET replay.
- **Bruno:** **`Get quest progress after gather intro complete.bru`**; default GET asserts summary omitted on **`not_started`**.
- **Docs:** **`server/README.md`**; E7.M2 module anchor; alignment register; E7M2-06 backlog AC.
## Technical approach

View File

@ -79,6 +79,7 @@ public sealed class QuestProgressApiTests
Assert.Equal(0, row.CurrentStepIndex);
Assert.Empty(row.ObjectiveCounters);
Assert.Null(row.CompletedAt);
Assert.Null(row.CompletionRewardSummary);
}
}
@ -109,6 +110,7 @@ public sealed class QuestProgressApiTests
Assert.Equal(0, row.CurrentStepIndex);
Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]);
Assert.Null(row.CompletedAt);
Assert.Null(row.CompletionRewardSummary);
}
[Fact]
@ -135,6 +137,43 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(0, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
Assert.NotNull(row.CompletionRewardSummary);
Assert.Empty(row.CompletionRewardSummary!.ItemGrants);
Assert.Single(row.CompletionRewardSummary.SkillXpGrants);
Assert.Equal("salvage", row.CompletionRewardSummary.SkillXpGrants[0].SkillId);
Assert.Equal(25, row.CompletionRewardSummary.SkillXpGrants[0].Amount);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice()
{
// 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 firstResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
var secondResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var firstBody = await firstResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(firstBody);
Assert.NotNull(secondBody);
var firstRow = Assert.Single(firstBody!.Quests, static r => r.QuestId == GatherQuestId);
var secondRow = Assert.Single(secondBody!.Quests, static r => r.QuestId == GatherQuestId);
Assert.NotNull(firstRow.CompletionRewardSummary);
Assert.NotNull(secondRow.CompletionRewardSummary);
Assert.Equal(firstRow.CompletionRewardSummary!.SkillXpGrants[0].SkillId, secondRow.CompletionRewardSummary!.SkillXpGrants[0].SkillId);
Assert.Equal(firstRow.CompletionRewardSummary.SkillXpGrants[0].Amount, secondRow.CompletionRewardSummary.SkillXpGrants[0].Amount);
}
[Fact]
@ -157,6 +196,13 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(3, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
Assert.NotNull(row.CompletionRewardSummary);
Assert.Single(row.CompletionRewardSummary!.ItemGrants);
Assert.Equal("survey_drone_kit", row.CompletionRewardSummary.ItemGrants[0].ItemId);
Assert.Equal(1, row.CompletionRewardSummary.ItemGrants[0].Quantity);
Assert.Single(row.CompletionRewardSummary.SkillXpGrants);
Assert.Equal("salvage", row.CompletionRewardSummary.SkillXpGrants[0].SkillId);
Assert.Equal(50, row.CompletionRewardSummary.SkillXpGrants[0].Amount);
}
[Fact]

View File

@ -48,18 +48,21 @@ public static class QuestAcceptApi
deliveryStore,
timeProvider);
return Results.Json(MapResponse(result));
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
});
return app;
}
internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result)
internal static QuestAcceptResponse MapResponse(
string playerId,
IRewardDeliveryStore deliveryStore,
QuestStateOperationResult result)
{
QuestProgressRowJson? quest = null;
if (result.Snapshot is { } snapshot)
{
quest = QuestProgressApi.MapQuestProgressRow(snapshot);
quest = QuestProgressApi.MapQuestProgressRow(playerId, snapshot, deliveryStore);
}
return new QuestAcceptResponse

View File

@ -45,7 +45,7 @@ public static class QuestProgressApi
timeProvider,
logger);
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore));
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore, deliveryStore));
});
return app;
@ -54,13 +54,14 @@ public static class QuestProgressApi
internal static QuestProgressListResponse BuildSnapshot(
string playerId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore)
IPlayerQuestStateStore progressStore,
IRewardDeliveryStore deliveryStore)
{
var defs = questRegistry.GetDefinitionsInIdOrder();
var rows = new List<QuestProgressRowJson>(defs.Count);
foreach (var def in defs)
{
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore));
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore, deliveryStore));
}
return new QuestProgressListResponse
@ -74,17 +75,21 @@ public static class QuestProgressApi
internal static QuestProgressRowJson MapQuestProgressRow(
string playerId,
string questId,
IPlayerQuestStateStore progressStore)
IPlayerQuestStateStore progressStore,
IRewardDeliveryStore deliveryStore)
{
if (!progressStore.TryGetProgress(playerId, questId, out var snapshot))
{
return NotStartedRow(questId);
}
return MapQuestProgressRow(snapshot);
return MapQuestProgressRow(playerId, snapshot, deliveryStore);
}
internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot)
internal static QuestProgressRowJson MapQuestProgressRow(
string playerId,
QuestStepState snapshot,
IRewardDeliveryStore deliveryStore)
{
var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal);
return snapshot.Status switch
@ -103,11 +108,63 @@ public static class QuestProgressApi
CurrentStepIndex = snapshot.CurrentStepIndex,
ObjectiveCounters = counters,
CompletedAt = snapshot.CompletedAt,
CompletionRewardSummary = MapCompletionRewardSummary(playerId, snapshot.QuestId, deliveryStore),
},
_ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."),
};
}
private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
string playerId,
string questId,
IRewardDeliveryStore deliveryStore)
{
if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent))
{
return null;
}
return new QuestCompletionRewardSummaryJson
{
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
};
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)
{
var summary = new List<QuestItemGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestItemGrantJson
{
ItemId = grant.ItemId,
Quantity = grant.Quantity,
});
}
return summary;
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedSkillXp"/>.</summary>
private static List<QuestSkillXpGrantJson> MapSkillXpGrants(IReadOnlyList<RewardSkillXpGrantApplied> grants)
{
var summary = new List<QuestSkillXpGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestSkillXpGrantJson
{
SkillId = grant.SkillId,
Amount = grant.Amount,
});
}
return summary;
}
private static QuestProgressRowJson NotStartedRow(string questId) =>
new()
{

View File

@ -38,4 +38,39 @@ public sealed class QuestProgressRowJson
[JsonPropertyName("completedAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>Grant snapshot when <see cref="Status"/> is <c>completed</c> and delivery was recorded (NEO-129).</summary>
[JsonPropertyName("completionRewardSummary")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
}
/// <summary>Item + skill XP grants applied on first-time quest completion (NEO-129).</summary>
public sealed class QuestCompletionRewardSummaryJson
{
[JsonPropertyName("itemGrants")]
public required IReadOnlyList<QuestItemGrantJson> ItemGrants { get; init; }
[JsonPropertyName("skillXpGrants")]
public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; }
}
/// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
public sealed class QuestItemGrantJson
{
[JsonPropertyName("itemId")]
public required string ItemId { get; init; }
[JsonPropertyName("quantity")]
public required int Quantity { get; init; }
}
/// <summary>One skill XP grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
public sealed class QuestSkillXpGrantJson
{
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("amount")]
public required int Amount { get; init; }
}

View File

@ -212,7 +212,14 @@ Completed rows cannot regress to active without a reset API (none in prototype).
### Per-player quest progress (NEO-119)
**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), and optional **`completedAt`** when **`completed`**. Quests are ordered by catalog **`id`** (ordinal).
**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), optional **`completedAt`** when **`completed`**, and optional **`completionRewardSummary`** when **`completed`** and a delivery event exists in **`IRewardDeliveryStore`**. Quests are ordered by catalog **`id`** (ordinal).
| Field | When present |
|-------|----------------|
| **`completedAt`** | **`status: completed`** |
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`) and **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`). Omitted when not completed or when no delivery record exists. |
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`** and empty **`itemGrants`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).