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
parent
2b06eb2a1e
commit
78e805e428
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue