WorldForge Studio

09 — Gameplay

Campaigns, quests, encounters and their play sessions, plus in-world gameplay routes (dialogue, inventory, spawns, items, POIs, renown).

Campaigns & quests

MethodPathNotes
GET/campaignsList
POST/campaignsCreate
GET/campaigns/:idFetch
PATCH/campaigns/:idUpdate
GET/questsList
POST/questsCreate
GET/quests/:idFetch
PATCH/quests/:idUpdate

Scopes: campaign:* / quest:* (read/write split, PATCH decides).

Encounters & sessions

MethodPathNotes
GET/encountersList
POST/encountersCreate
GET/encounters/:idFetch
PATCH/encounters/:idUpdate
GET/encounters/:id/sessionsList play sessions
POST/encounters/:id/sessionsStart a session
GET/encounters/:id/sessions/:sidSession state
POST/encounters/:id/sessions/:sid/actTake an action
POST/encounters/:id/sessions/:sid/advanceAdvance the clock/turn
POST/encounters/:id/sessions/:sid/endEnd 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) gets 404 { error: 'not_found' }, so a world id cannot be probed. Any member role passes, including observer.
  • Role-checked routes (spawns, items, pois/search, pois/:poiId/discover): a missing world is 404 { error: 'not_found' }; a caller with no membership row who is not the owner gets 403 { 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[].

MethodPathNotes
GET/worlds/:id/spawnsAny member (including players and observers). { spawns: Spawn[] }; soft-deleted spawns are hidden.
POST/worlds/:id/spawnsGM only. Create; 201 with the new spawn.
PATCH/worlds/:id/spawns/:spawnIdGM only. Partial update; 200 with the full spawn.
DELETE/worlds/:id/spawns/:spawnIdGM only. Soft delete; 204, no body.

POST body (validated by Zod; an invalid body is a 400 in the zValidator default shape):

FieldTypeNotes
x, yfinite numberRequired.
encounterstringRequired, must match gas://<non-whitespace>.
schedule{ from: 'HH:MM', to: 'HH:MM' }Optional; both must match ^\d{2}:\d{2}$ (format only, not range).
densitynumber 0..1Optional, default 0.5.
poiIdstring (1..256) or nullOptional. Must be the id of an entry in the world's namedSlots (not a POI id), else 400 { error: 'invalid_poi' }.
clusterBiasboolean or nullOptional. true requires a non-null poiId, else 400 { error: 'cluster_bias_requires_poi' }.
base_versionpositive intOptional 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.

MethodPathNotes
GET/worlds/:id/inventoryThe caller's inventory; an empty one if the caller has never held anything.
POST/worlds/:id/inventory/pickupTake one catalog item from an adjacent POI.
POST/worlds/:id/inventory/dropRemove 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 x and y (else 400 { error: 'bad_request', reason: 'cell_required' }); players may pass them only while the env flag PICKUP_CLIENT_POSITION_FALLBACK is '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 get 409 { 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_found if the POI id is not in the document).
  • The POI's category must allow pickup: 400 { error: 'not_pickable' }.
  • itemId must 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, and itemId is not checked against what that POI actually offers: any catalog item may be named, and whether the category is pickable is the only gate. Audit inventory: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.

MethodPathNotes
GET/worlds/:id/itemsAny member. A GM sees every item in the world; other members see only items they own. { items: Item[] }.
POST/worlds/:id/itemsGM only. Grant an item to a user; 201 with the item.
POST/worlds/:id/items/:itemId/activateThe 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.

MethodPathNotes
GET/worlds/:id/questsQuest templates and/or the caller's active quests.
POST/worlds/:id/quests/acceptAccept a quest offered in a dialogue session.
POST/worlds/:id/quests/declineDecline the pending offer on a dialogue session.
POST/worlds/:id/quests/completeComplete one of the caller's active quests; grants rewards.
POST/worlds/:id/quests/abandonAbandon 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' }.
  • abandon success: 200 { ok: true }; status becomes abandoned. Audit quest:abandon.
  • complete success: 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_rep rewards add renown to a faction (granted entry { kind: 'faction_rep', orgId, amount, total }); item rewards are appended to the caller's inventory ({ kind: 'item', itemId }). Rewards that cannot land never block completion; they are listed in skipped as { kind, target?, reason } with reasons no_target, no_amount, unknown_item, inventory_full, and no_character_sheet for xp / 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> }. Audit quest: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.

MethodPathNotes
POST/worlds/:id/dialogue/openStart a conversation by POI / NPC / dialogue id.
GET/worlds/:id/dialogue/meThe caller's currently open session, or null.
GET/worlds/:id/dialogue/sessions/:sessionIdOne of the caller's sessions.
POST/worlds/:id/dialogue/sessions/:sessionId/choosePick a choice in a tree dialogue.
POST/worlds/:id/dialogue/sessions/:sessionId/closeAbandon a session.
POST/worlds/:id/dialogue/advanceAdvance 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).

MethodPathNotes
POST/worlds/:id/pois/searchSearch POIs. Read-only despite the POST.
POST/worlds/:id/pois/:poiId/discoverGM only. Mark a POI discovered and reveal fog around it.
POST/worlds/:id/pois/:poiId/openPlayer 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

MethodPathNotes
GET/worlds/:id/renownParty 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.