09 — Gameplay
Campaigns, quests, encounters and their play sessions, plus in-world gameplay routes (dialogue, inventory, spawns, items, POIs, renown).
Campaigns & quests
| Method | Path | Notes |
|---|---|---|
GET | /campaigns | List |
POST | /campaigns | Create |
GET | /campaigns/:id | Fetch |
PATCH | /campaigns/:id | Update |
GET | /quests | List |
POST | /quests | Create |
GET | /quests/:id | Fetch |
PATCH | /quests/:id | Update |
Scopes: campaign:* / quest:* (read/write split, PATCH decides).
Encounters & sessions
| Method | Path | Notes |
|---|---|---|
GET | /encounters | List |
POST | /encounters | Create |
GET | /encounters/:id | Fetch |
PATCH | /encounters/:id | Update |
GET | /encounters/:id/sessions | List play sessions |
POST | /encounters/:id/sessions | Start a session |
GET | /encounters/:id/sessions/:sid | Session state |
POST | /encounters/:id/sessions/:sid/act | Take an action |
POST | /encounters/:id/sessions/:sid/advance | Advance the clock/turn |
POST | /encounters/:id/sessions/:sid/end | End the session |
Scope: encounter:read / encounter:write.
In-world gameplay — /worlds/:id/…
These live under /worlds/:id/, not at the top level: /worlds/:id/quests
is the per-world quest log and is unrelated to the top-level /quests
resource above.
Scopes. For API keys the central gate maps these prefixes to
world:read (GET) / world:write (everything else): spawns, inventory,
items, quests, dialogue, pois. POST …/pois/search only reads, so it
needs world:read. GET …/renown is not in the gate's prefix list, so
no scope is checked for it (membership still is). Session JWTs pass every
scope.
Who the caller is. Every route below resolves the caller to a user id: a
user actor is itself, an API-key (agent) actor is its owning human
(userId). An agent without a userId, or any other actor kind, gets
403 { error: 'forbidden', reason: 'unresolved_caller' }. Membership
failures differ by route family, and that difference is real:
- Player routes (inventory, quests, dialogue, renown,
pois/:poiId/open): a non-member (or a missing world) gets404 { error: 'not_found' }, so a world id cannot be probed. Any member role passes, includingobserver. - Role-checked routes (spawns, items,
pois/search,pois/:poiId/discover): a missing world is404 { error: 'not_found' }; a caller with no membership row who is not the owner gets403 { error: 'forbidden', reason: 'no_membership' }.
"GM" below means the world owner or a member whose role is gm.
Spawns
Encounter/resource/patrol anchors stored in the world document at
gameplay.spawns[].
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/spawns | Any member (including players and observers). { spawns: Spawn[] }; soft-deleted spawns are hidden. |
POST | /worlds/:id/spawns | GM only. Create; 201 with the new spawn. |
PATCH | /worlds/:id/spawns/:spawnId | GM only. Partial update; 200 with the full spawn. |
DELETE | /worlds/:id/spawns/:spawnId | GM only. Soft delete; 204, no body. |
POST body (validated by Zod; an invalid body is a 400 in the
zValidator default shape):
| Field | Type | Notes |
|---|---|---|
x, y | finite number | Required. |
encounter | string | Required, must match gas://<non-whitespace>. |
schedule | { from: 'HH:MM', to: 'HH:MM' } | Optional; both must match ^\d{2}:\d{2}$ (format only, not range). |
density | number 0..1 | Optional, default 0.5. |
poiId | string (1..256) or null | Optional. Must be the id of an entry in the world's namedSlots (not a POI id), else 400 { error: 'invalid_poi' }. |
clusterBias | boolean or null | Optional. true requires a non-null poiId, else 400 { error: 'cluster_bias_requires_poi' }. |
base_version | positive int | Optional optimistic-concurrency pin against the world's version; a mismatch is 409 { error: 'version_conflict' }. |
The server assigns id (spawn_<12 chars>). The spawn schema also has a
kind (encounter / resource / npc_patrol), but neither body schema
accepts it, so these routes can neither set nor change it.
PATCH takes the same fields, all optional. null clears poiId,
clusterBias and schedule; omitting a field leaves it as-is. The
cluster-bias rule and the poiId check are applied to the post-patch
state. An unknown or soft-deleted spawnId is 404 { error: 'not_found' }.
A PATCH that changes nothing returns the current spawn without a version
bump, audit row or feed event.
DELETE sets deletedAt on the stored spawn rather than removing it; a
second DELETE on an already-deleted spawn is 204 again and keeps the
original deletedAt. PATCH on a soft-deleted spawn is 404.
Errors on the writes: 403 { error: 'forbidden', reason: 'gm_only' } for a
player or observer, reason: 'no_membership' for a non-member.
Side effects: each write goes through the world write path (bumps
worlds.version) and records an audit row (spawn:create / spawn:update
/ spawn:delete) plus a party-audience feed event of the same kind, in the
same transaction. One event per mutation.
Inventory
Per-player, per-world inventory ({ worldId, playerId, entries[] }; each
entry is { item, originPoiId, acquiredAt }). There is no way to read
someone else's inventory.
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/inventory | The caller's inventory; an empty one if the caller has never held anything. |
POST | /worlds/:id/inventory/pickup | Take one catalog item from an adjacent POI. |
POST | /worlds/:id/inventory/drop | Remove one item by id. |
POST …/inventory/pickup, body { poiId: string (1..256), itemId: string (1..64) },
optional query x, y (non-negative integers, the player's cell):
- The player's position is the server-tracked presence cell when there is one
(the query cell is then ignored). Without presence, owners/GMs must pass
xandy(else400 { error: 'bad_request', reason: 'cell_required' }); players may pass them only while the env flagPICKUP_CLIENT_POSITION_FALLBACKis'true'. That flag defaults to'true'in the env schema, so unless a deployment sets it to'false'a player without presence is trusted on the cell they send; with it off they get409 { error: 'bad_request', reason: 'no_presence' }. - The cell must be within Chebyshev distance 1 of the POI's named slot, else
400 { error: 'too_far' }(poi_not_foundif the POI id is not in the document). - The POI's category must allow pickup:
400 { error: 'not_pickable' }. itemIdmust be in the item catalog:400 { error: 'unknown_item' }.- Full inventory:
409 { error: 'inventory_full' }. - Success:
200 { ok: true, inventory }. The POI is not consumed, anditemIdis not checked against what that POI actually offers: any catalog item may be named, and whether the category ispickableis the only gate. Auditinventory:pickup.
POST …/inventory/drop, body { itemId: string (1..64) }: removes one entry
with that item id. 404 { error: 'not_in_inventory' } if the caller holds
none. Success 200 { ok: true, inventory }. Audit inventory:drop. The
dropped item is simply removed; it is not placed in the world.
The inventory write path rejects a list that reaches 256 entries
(MAX_INVENTORY_SIZE) with 400 { error: 'inventory_full' }; the check uses
>=, so the effective cap looks like 255 (read from code, not exercised).
Items (map items)
GM-granted, one-shot "map" items that reveal regions or areas when activated. Not the same as inventory items.
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/items | Any member. A GM sees every item in the world; other members see only items they own. { items: Item[] }. |
POST | /worlds/:id/items | GM only. Grant an item to a user; 201 with the item. |
POST | /worlds/:id/items/:itemId/activate | The item's owner, or a GM. One-shot. 200 { ok: true, activatedAt }. |
Item shape: { id, worldId, ownerId, kind, name, revealTargets, createdAt, activatedAt }
(activatedAt is null until used).
POST body: ownerId (string, required; must be an existing user, else
400 { error: 'invalid_owner' }; it is not checked to be a member of the
world), name (string 1..120), kind (the literal 'map'), revealTargets
(array, default []) of either { type: 'region', regionId } or
{ type: 'area', nodeId, position: { x, y }, radius >= 0 }. A non-GM member
gets 403 { error: 'forbidden', reason: 'gm_only' }. Audit item:grant.
regionId / nodeId are not validated at grant time.
POST …/activate applies every reveal target in one transaction: a region
target flips the region to revealed for the party, an area target ORs the disc
into the node's party fog-of-war bitmap; then it stamps activatedAt, appends
one map_item_activated feed event (party audience) and writes audit
item:activate. Errors: item missing or from another world
404 { error: 'not_found' }; caller neither owner nor GM
403 { error: 'forbidden', reason: 'not_owner' }; already used
409 { error: 'already_activated' }. Any member of the world (including an
observer) can activate an item they own.
Quests (per world)
Quest templates come from the world document (top-level quests[] plus the
legacy world.quests[]); a player's progress is a player_quests row. All
five routes are player routes (member, else 404) and only ever touch the
caller's own rows.
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/quests | Quest templates and/or the caller's active quests. |
POST | /worlds/:id/quests/accept | Accept a quest offered in a dialogue session. |
POST | /worlds/:id/quests/decline | Decline the pending offer on a dialogue session. |
POST | /worlds/:id/quests/complete | Complete one of the caller's active quests; grants rewards. |
POST | /worlds/:id/quests/abandon | Abandon one of the caller's active quests. |
GET: query scope = templates | active | both (default both);
campaign_id is accepted and ignored. Response { templates, active }
(the array not requested is []). A template is
{ id, name, title, description, rewards, template } (name equals title,
falling back to the id; template is true only when the quest is marked as
a template). An active entry is
{ id, templateId, playerId, status, progressJson, acceptedAt, completedAt }
with status = 'active'. Players see only their own active rows; there is no
way to list completed or abandoned ones.
POST …/accept, body { sessionId: string, templateId: string } (each
1..256): the dialogue session must belong to the caller in this world and
the template must exist in the document. Success 200 { ok: true }. Already
holding the quest is not an error: 200 { ok: false, reason: 'already_active' }.
Failures: 403 { error: 'session_mismatch' } (here error itself is the
reason), 404 { error: 'template_not_found' }, other commit failures 400.
Whether the template must have been offered on that session is decided in
the commit helper (commitAcceptedQuest), which was not read in full.
POST …/decline, body { sessionId }: clears the pending offer on the
caller's session. 200 { ok: true }. If the session is not the caller's, or
is in another world: 403 { error: 'forbidden', reason: 'session_mismatch' }.
POST …/complete and POST …/abandon, body { templateId: string (1..256) }:
- No row for that template and caller:
404 { error: 'not_found', reason: 'no_active_quest' }. - Row exists but is not
active(already completed or abandoned, or a concurrent call won the race):409 { error: 'not_active' }. abandonsuccess:200 { ok: true }; status becomesabandoned. Auditquest:abandon.completesuccess:200 { ok: true, granted, skipped }. Rewards are re-read from the authored quest at completion time (a quest since removed from the document completes with no rewards).faction_reprewards add renown to a faction (grantedentry{ kind: 'faction_rep', orgId, amount, total });itemrewards are appended to the caller's inventory ({ kind: 'item', itemId }). Rewards that cannot land never block completion; they are listed inskippedas{ kind, target?, reason }with reasonsno_target,no_amount,unknown_item,inventory_full, andno_character_sheetforxp/gold(recorded but not granted anywhere). If the inventory write itself is rejected the whole completion rolls back (quest stays active, nothing granted):400 { error: <reason> }. Auditquest:complete(written after the transaction commits).
Dialogue
Player-private conversation sessions. At most one open session per player per
world: opening a new one closes the previous with reason replaced. All
routes are player routes (member, else 404), and a session is visible only
to the player who owns it.
| Method | Path | Notes |
|---|---|---|
POST | /worlds/:id/dialogue/open | Start a conversation by POI / NPC / dialogue id. |
GET | /worlds/:id/dialogue/me | The caller's currently open session, or null. |
GET | /worlds/:id/dialogue/sessions/:sessionId | One of the caller's sessions. |
POST | /worlds/:id/dialogue/sessions/:sessionId/choose | Pick a choice in a tree dialogue. |
POST | /worlds/:id/dialogue/sessions/:sessionId/close | Abandon a session. |
POST | /worlds/:id/dialogue/advance | Advance an AI (NPC) dialogue by a choice and/or free text. |
POST …/open, body (all fields optional, but at least one of dialogueId,
poiId, npcId is required; otherwise
400 { error: 'invalid_payload', reason: 'invalid_payload', issues }):
dialogueId, poiId, npcId, entryNode (each a non-empty string).
Which dialogue entry opens is decided by the shared resolver (the same code
as the run_dialogue trigger effect). Response 200 { ok: true, sessionId }.
For an asset-kind dialogue (single-shot, no session row) sessionId is
'' and the content is delivered over the feed/SSE stream only; tree and AI
dialogues also push dialogue:open / dialogue:advance events (player
audience) over the stream. Any failure other than a bad payload (no matching
dialogue, bad entryNode, broken chain, ...) comes back as
404 { error: <reason> }, for example dialogue_not_found,
invalid_entry_node, dialogue_chain_broken.
GET …/dialogue/me: 200 { session: Session | null }. A status query is
read but has no effect: the route always returns the open session
(closedAt is null), whatever the value.
GET …/dialogue/sessions/:sessionId: 200 Session, or 404 { error: 'not_found' }
when the session does not exist, is in another world, or is owned by someone
else. Session is
{ id, worldId, playerId, treeId, currentNodeId, currentNode, tree, choiceHistory, openedAt, closedAt, closedReason }
where currentNode and tree are resolved from the world document (null
when the session is at a terminal node, or when treeId does not name a tree
entry, for example an AI dialogue).
POST …/sessions/:sessionId/choose, body { choiceId: string } (non-empty):
validates the choice against the current node's choices (choices whose
requires gate fails for the caller are not valid), records it in
choiceHistory, walks the linear chain to the next node, and if that node is
terminal closes the session with reason completed. Response
200 { ok: true, sessionId, currentNode: { id, speaker, text }, isTerminal }.
Errors: 404 { error: 'not_found' } (unknown session, other world, or the
node is no longer addressable), 404 { error: 'dialogue_not_found' } (tree
gone from the document), 403 { error: 'forbidden', reason: 'not_owner' },
409 { error: 'already_closed' }, and
400 { error: 'invalid_choice', validChoiceIds }.
POST …/sessions/:sessionId/close: no body. Closes with reason abandoned.
200 { ok: true, sessionId, closedReason: 'abandoned' }; 404 { error: 'not_found' };
403 { error: 'forbidden', reason: 'not_owner' } (session exists in this world
but belongs to another player); 409 { error: 'already_closed' }. Emits a
dialogue:close feed event to the player and an audit row.
POST …/advance, body { sessionId: string (1..256), choiceId: string (1..256), freeText?: string (1..2000) }.
A choiceId of the form quest:accept:<templateId> or quest:decline:<templateId>
resolves a pending quest offer without calling the model. Any other choice
drives an LLM turn. Success 200 { ok: true, proposal: <object | null> }
(the proposal shape comes from the AI dialogue module and is not documented
here). Errors, all { error: <reason> }: 400 invalid_payload (with
issues), 404 for session_not_found / dialogue_not_found /
npc_not_found (a session in another world is session_not_found), 403 forbidden for a session owned by another player, 402 for no_byok /
plan_blocked / budget_exhausted / llm_error, 500 for any other
reason. The LLM path (provider selection, budget accounting) was not traced.
POIs
Points of interest (world.world.pois[], each tied to a namedSlot that
gives its position).
| Method | Path | Notes |
|---|---|---|
POST | /worlds/:id/pois/search | Search POIs. Read-only despite the POST. |
POST | /worlds/:id/pois/:poiId/discover | GM only. Mark a POI discovered and reveal fog around it. |
POST | /worlds/:id/pois/:poiId/open | Player opens an adjacent lootable POI; consumes it. |
POST …/pois/search, body (all optional): name (string 1..256,
case-insensitive substring), category (one of the curated categories, for
example tavern, shop, shrine, castle, lair, stable, or a
plugin:<slug> string), tags (up to 32 strings; a POI must have all of
them), center ({ x, y }), radiusPx (number >= 0), limit (int
1..200, default 50). Response 200 { results: [{ poi, namedSlot, distancePx }] }.
Owners and GMs get the full result set. Players and observers always get
{ results: [] } today: the mounted route supplies no reveal-visibility
predicate, so non-GM callers fail closed. POIs whose named slot is missing
are skipped. A world whose stored JSON no longer parses is reported as 404.
How center without radiusPx (or the reverse) combines, and how results
are ordered, were not read.
POST …/pois/:poiId/discover: no body. GM only (403 { error: 'forbidden', reason: 'not_owner' }
for a plain member, despite the name). Sets the POI's discoveredAt, reveals
the POI's reveal radius (category default unless the POI overrides it) in the
party fog-of-war bitmap of the world's oldest atlas node, and records a
poi_discovered audit row and feed event, all in one transaction. Response
200 { alreadyDiscovered, radiusTiles, cellsRevealed, discoveredAt, revealedNodeId }.
Idempotent: a repeat call returns alreadyDiscovered: true with zeros and
revealedNodeId: null and writes nothing. 404 { error: 'not_found', reason }
for a missing world, POI or named slot. When the world has no atlas node (or
the radius is 0) the POI is still marked discovered, with revealedNodeId: null.
POST …/pois/:poiId/open: no body; optional query x, y as for pickup
(same presence / PICKUP_CLIENT_POSITION_FALLBACK rules and the same
bad_request cell_required / no_presence errors). The player must be
adjacent (400 { error: 'too_far' | 'poi_not_found' }), the POI's category
must be openable with a loot table (400 { error: 'not_openable' }), and it
must not already be consumed (409 { error: 'already_consumed' }, also when a
concurrent open wins). Loot is rolled deterministically from the world id,
POI id, loot table and open time, appended to the caller's inventory, and the
POI is marked consumed (world-wide, for all players) in one transaction.
Response 200 { ok: true, picked: Item[], inventory }. If the inventory
write is rejected the open rolls back: 400 { error: <reason> }. Audit
poi:open. What happens to rolled items that do not fit a nearly full
inventory (appendEntry behaviour) was not verified.
Renown
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/renown | Party renown for every organisation in the world. |
Member only (else 404). 200 { renown: [{ orgKind, orgId, points, updatedAt, updatedBy }] };
one row per organisation that has ever been granted renown, so an
organisation with no row has no points yet. The value is world-wide (per
party), not per player, even though updatedBy records the granting user.
There is no write route: renown changes only through the grant_renown
trigger effect and faction_rep quest rewards (POST …/quests/complete).
orgKind values come from RENOWN_ORG_KINDS in the DB schema and are not
listed here.