NEO-117: Add QuestStateOperations with reason codes and tests.

Server-authoritative accept, step advance, and idempotent complete
wrapping IPlayerQuestStateStore with prerequisite enforcement.
pull/156/head
VinPropane 2026-06-06 19:26:37 -04:00
parent 44ef5f2484
commit f053ffbb39
6 changed files with 586 additions and 7 deletions

View File

@ -50,9 +50,15 @@
## Acceptance criteria checklist
- [ ] Accept fails when prerequisite quest not **completed** (`prerequisite_incomplete`).
- [ ] Complete is idempotent per player+quest (`TryMarkComplete` second call → `Success: true`, same `CompletedAt`).
- [ ] Reason codes documented in `server/README.md`.
- [x] Accept fails when prerequisite quest not **completed** (`prerequisite_incomplete`).
- [x] Complete is idempotent per player+quest (`TryMarkComplete` second call → `Success: true`, same `CompletedAt`).
- [x] Reason codes documented in `server/README.md`.
## Implementation reconciliation (shipped)
- **Types:** `QuestStateReasonCodes`, `QuestStateOperationResult`, `QuestStateOperations` (`TryAccept`, `TryAdvanceStep`, `TryMarkComplete`).
- **Tests:** `QuestStateOperationsTests` — 15 AAA cases (accept prerequisites, denials, advance bounds, idempotent complete, host DI smoke).
- **Docs:** `server/README.md` quest operations + reason-code table.
## Technical approach

View File

@ -0,0 +1,378 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public sealed class QuestStateOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
private const string RefineQuestId = "prototype_quest_refine_intro";
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
[Fact]
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
Assert.Equal(0, result.Snapshot.CurrentStepIndex);
}
[Fact]
public async Task TryAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public async Task TryAccept_ShouldSucceedRefineIntro_WhenGatherIntroCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
}
[Fact]
public async Task TryAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
"prototype_quest_missing",
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode);
}
[Fact]
public async Task TryAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.AlreadyActive, result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
}
[Fact]
public async Task TryAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
}
[Fact]
public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPlayerNotInStore()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
UnknownPlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode);
}
[Fact]
public async Task TryAdvanceStep_ShouldAdvanceChainQuestAndClearCounters()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
AcceptAndComplete(deps, GatherQuestId, timeProvider);
AcceptAndComplete(deps, RefineQuestId, timeProvider);
AcceptAndComplete(deps, "prototype_quest_combat_intro", timeProvider);
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainObjectiveId, 5, out _));
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(1, result.Snapshot!.CurrentStepIndex);
Assert.Empty(result.Snapshot.ObjectiveCounters);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
public async Task TryAdvanceStep_ShouldDenyInvalidStepIndex_WhenIndexNotIncreasingOrPastLastStep(int newStepIndex)
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
AcceptAndComplete(deps, GatherQuestId, timeProvider);
AcceptAndComplete(deps, RefineQuestId, timeProvider);
AcceptAndComplete(deps, "prototype_quest_combat_intro", timeProvider);
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
newStepIndex,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.InvalidStepIndex, result.ReasonCode);
}
[Fact]
public async Task TryAdvanceStep_ShouldDenyNotActive_WhenQuestNotAccepted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
GatherQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode);
}
[Fact]
public async Task TryAdvanceStep_ShouldDenyAlreadyCompleted_WhenQuestCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
GatherQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode);
}
[Fact]
public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
// Act
var first = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
var second = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
// Assert
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.NotNull(first.Snapshot!.CompletedAt);
Assert.True(second.Success);
Assert.Null(second.ReasonCode);
Assert.Equal(first.Snapshot.CompletedAt, second.Snapshot!.CompletedAt);
Assert.Equal(QuestProgressStatus.Completed, second.Snapshot.Status);
}
[Fact]
public async Task TryMarkComplete_ShouldDenyNotActive_WhenQuestNeverAccepted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
// Act
var result = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode);
}
[Fact]
public async Task Host_ShouldResolveRegistryAndStore_ForQuestStateOperations()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var scope = factory.Services.CreateScope();
var questRegistry = scope.ServiceProvider.GetRequiredService<IQuestDefinitionRegistry>();
var progressStore = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
// Act
var accept = QuestStateOperations.TryAccept(PlayerId, GatherQuestId, questRegistry, progressStore);
var complete = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
questRegistry,
progressStore,
TimeProvider.System);
// Assert
Assert.True(accept.Success);
Assert.True(complete.Success);
Assert.Equal(QuestProgressStatus.Completed, complete.Snapshot!.Status);
}
private static void AcceptAndComplete(
(IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) deps,
string questId,
TimeProvider timeProvider)
{
Assert.True(QuestStateOperations.TryAccept(PlayerId, questId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
}
private static (IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) ResolveDependencies(
InMemoryWebApplicationFactory factory)
{
var scope = factory.Services.CreateScope();
return (
scope.ServiceProvider.GetRequiredService<IQuestDefinitionRegistry>(),
scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>());
}
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Server-internal quest state resolution envelope (NEO-117).
/// HTTP DTOs via E7M1-08/09 (NEO-119/NEO-120).
/// </summary>
public readonly record struct QuestStateOperationResult(
bool Success,
string? ReasonCode,
QuestStepState? Snapshot);

View File

@ -0,0 +1,142 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Server-authoritative quest accept, step advance, and complete with reason codes (NEO-117).
/// Objective wiring: E7M1-07 (NEO-118). HTTP: E7M1-08/09 (NEO-119/NEO-120).
/// </summary>
public static class QuestStateOperations
{
/// <summary>
/// Validates quest id and prerequisites, then activates at step 0 when not started.
/// </summary>
public static QuestStateOperationResult TryAccept(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
{
return existing.Status switch
{
QuestProgressStatus.Completed => Deny(QuestStateReasonCodes.AlreadyCompleted, existing),
QuestProgressStatus.Active => Deny(QuestStateReasonCodes.AlreadyActive, existing),
_ => Deny(QuestStateReasonCodes.UnknownQuest),
};
}
foreach (var prerequisiteId in definition.PrerequisiteQuestIds)
{
if (!progressStore.TryGetProgress(playerId, prerequisiteId, out var prerequisite) ||
prerequisite.Status != QuestProgressStatus.Completed)
{
return Deny(QuestStateReasonCodes.PrerequisiteIncomplete);
}
}
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
{
return Success(snapshot);
}
return Deny(QuestStateReasonCodes.UnknownPlayer);
}
/// <summary>
/// Advances an active quest to <paramref name="newStepIndex"/> when within catalog bounds.
/// </summary>
public static QuestStateOperationResult TryAdvanceStep(
string playerId,
string questId,
int newStepIndex,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (existing.Status == QuestProgressStatus.Completed)
{
return Deny(QuestStateReasonCodes.AlreadyCompleted, existing);
}
if (existing.Status != QuestProgressStatus.Active)
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (newStepIndex <= existing.CurrentStepIndex || newStepIndex >= definition.Steps.Count)
{
return Deny(QuestStateReasonCodes.InvalidStepIndex, existing);
}
if (progressStore.TryAdvanceStep(playerId, normalizedQuestId, newStepIndex, out var snapshot))
{
return Success(snapshot);
}
return Deny(QuestStateReasonCodes.UnknownPlayer, existing);
}
/// <summary>
/// Marks a quest complete. Idempotent: replays return success with the existing snapshot.
/// </summary>
public static QuestStateOperationResult TryMarkComplete(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out _))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing) &&
existing.Status == QuestProgressStatus.Completed)
{
return Success(existing);
}
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var active) ||
active.Status != QuestProgressStatus.Active)
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot))
{
return Success(snapshot);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
afterAttempt.Status == QuestProgressStatus.Completed)
{
return Success(afterAttempt);
}
return Deny(QuestStateReasonCodes.UnknownPlayer);
}
private static QuestStateOperationResult Success(QuestStepState snapshot) =>
new(Success: true, ReasonCode: null, Snapshot: snapshot);
private static QuestStateOperationResult Deny(string reasonCode, QuestStepState? snapshot = null) =>
new(Success: false, ReasonCode: reasonCode, Snapshot: snapshot);
}

View File

@ -0,0 +1,19 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Stable deny reason codes for <see cref="QuestStateOperations"/> (NEO-117).</summary>
public static class QuestStateReasonCodes
{
public const string UnknownQuest = "unknown_quest";
public const string PrerequisiteIncomplete = "prerequisite_incomplete";
public const string AlreadyCompleted = "already_completed";
public const string AlreadyActive = "already_active";
public const string UnknownPlayer = "unknown_player";
public const string NotActive = "not_active";
public const string InvalidStepIndex = "invalid_step_index";
}

View File

@ -158,20 +158,44 @@ curl -sS -i "http://localhost:5253/game/world/quest-definitions"
## Quest progress store (NEO-116)
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Mutations are server-authoritative — HTTP read/accept routes land in E7M1-08/09 ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)+ for **`QuestStateOperations`**).
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP read/accept routes land in E7M1-08/09 (NEO-119/NEO-120).
**Interface methods:**
**Store interface methods:**
- **`TryGetProgress`** — read one row; false when not started.
- **`TryActivate`** — first accept (`not_started` → `active`).
- **`TryAdvanceStep`** — bump step index and clear counters for the new step (denies when completed or index does not increase).
- **`TryUpdateObjectiveCounter`** — set one objective counter on the current step (non-negative).
- **`TryMarkComplete`** — idempotent completion flag + **`completedAt`** timestamp.
- **`TryMarkComplete`** — first completion returns `true`; store replay returns `false` without changing **`completedAt`**.
Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids should be validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** at operation/HTTP layers (NEO-117+).
Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids are validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** in **`QuestStateOperations`**.
**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP).
### Quest state operations (NEO-117)
**`QuestStateOperations`** (static) wraps the store with catalog validation and structured **`reasonCode`** denials. Returns **`QuestStateOperationResult`** (`success`, optional `reasonCode`, optional progress snapshot).
| Method | Role |
|--------|------|
| **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. |
| **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. |
| **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). |
**Reason codes:**
| Code | When |
|------|------|
| **`unknown_quest`** | Quest id not in catalog (accept, advance, complete). |
| **`prerequisite_incomplete`** | Accept when a listed prerequisite is not **completed** (implicit `not_started` fails). |
| **`already_completed`** | Accept or advance when row is already **completed**. |
| **`already_active`** | Accept when row is already **active**. |
| **`unknown_player`** | Player id cannot be written (no dev bucket / missing from Postgres `player_position`). |
| **`not_active`** | Advance or complete when quest was never accepted or is not **active**. |
| **`invalid_step_index`** | Advance when index does not increase or is past the last step. |
Objective wiring (gather/craft/encounter hooks) is E7M1-07 ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)). Plan: [NEO-117 implementation plan](../../docs/plans/NEO-117-implementation-plan.md).
## 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/`.