ServicesQuest

Quest

An engine for assigned, tracked, turn-in goals: definitions with ordered or parallel steps, progress driven by posted events, prerequisites and branching chains, daily/weekly repeats, expiry windows, and rewards paid out on turn-in through Treasury, Item Engine, and Achievements. Three genres share one model: objective quests, timed expeditions, and puzzle chains.

When to reach for it: MMO-style quest lines and dailies, SaaS onboarding checklists, idle-game expeditions, compliance training with deadlines, escape rooms and ARGs with hint economies.

When not to: passive recognition of what already happened - a badge with no accept/turn-in loop - belongs to Achievements; background work queues with retries belong to Job Queue.

Concepts

  • Definitions vs states - an admin authors a QuestDefinition; each user gets their own QuestState per quest per cycle. Updates bump the definition version; in-flight states pin the version they accepted.
  • Three kinds - objective (steps advanced by events), expedition (dispatch a party, resolve on return), puzzle (steps completed by validated answers) - the latter two have their own page.
  • Lifecycle - accepting an offered quest moves it straight to in_progress; it ends as turned_in, expired, or abandoned, and rewards only ever pay out on an explicit turn-in.
  • Events drive progress - one ingestion endpoint matches posted events against all of the caller's active quests by event_type plus exact-equality payload predicates; idempotency keys make replays no-ops.
  • Cycle keys make repeats work - a non-repeatable quest lives in cycle static; a daily/weekly schedule keys cycles by UTC date or ISO week. Each quest is acceptable once per cycle.
  • Prerequisites and branching - prerequisites.all_of hides a quest until the listed quests are turned in; branching entries with on: "turned_in" report follow-up unlocks in the turn-in response.

Authoring definitions

Creating, updating, and deleting definitions requires a platform admin token; listing and getting are open to any authenticated user.

snug quest definitions create --file ./forge.json
snug quest definitions list --kind objective --query forge
snug quest definitions get --quest-id forge
snug quest definitions update --quest-id forge --file ./patch.json   # partial; bumps version
snug quest definitions delete --quest-id forge

A minimal objective definition - two ordered steps, a reward, a deadline:

{
  "quest_id": "forge",
  "name": "Forge Apprentice",
  "kind": "objective",
  "step_mode": "ordered",
  "steps": [
    { "id": "gather", "name": "Gather iron ore", "objective": { "event_type": "item.acquired", "match": { "item_id": "iron_ore" }, "target_count": 3 } },
    { "id": "smelt", "name": "Smelt an iron bar", "objective": { "event_type": "item.crafted", "match": { "item_id": "iron_bar" }, "target_count": 1 } }
  ],
  "rewards": [ { "type": "treasury", "currency": "gold", "amount": 50 } ],
  "expiry": { "complete_window_seconds": 3600 }
}

Offered, accept, log

offered returns every quest whose prerequisites you satisfy and which you have not yet accepted this cycle. Accepting seeds per-step progress and stamps expires_at; the raw POST returns the enveloped state (captured live, trimmed):

curl -s -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  http://localhost:4000/api/v1/quests/forge/accept
{
  "status": 200,
  "msg": "OK",
  "data": {
    "quest_id": "forge",
    "status": "in_progress",
    "expires_at": 1787927328,
    "step_progress": {
      "gather": { "count": 0, "completed": false },
      "smelt": { "count": 0, "completed": false }
    },
    "current_step": "gather",
    "cycle_key": "static"
  }
}

Re-accepting in the same cycle is 409 already_accepted - and so is accepting again after abandoning: the cycle's state record remains, so abandonment is final for the cycle (a daily comes back tomorrow). Unmet prerequisites are 403 prerequisites_not_met.

snug quest log --status in_progress shows your states with per-step counts; statuses are in_progress, turned_in, expired, abandoned.

Progress events

snug quest events emit --type item.acquired \
  --payload '{"item_id":"iron_ore"}' --idempotency-key evt-1
{ "deltas": [ { "quest_id": "forge", "step_id": "gather", "count": 1,
               "step_completed": false, "ready_for_turn_in": false } ] }

The response lists only the steps the event actually advanced. Verified:

  • Ordered gating - in step_mode: ordered only the current step matches; an item.crafted posted while still on gather returns empty deltas. parallel steps all match at once.
  • Match predicates are exact equality - {"item_id": "copper_ore"} does not advance a step matching on iron_ore.
  • Idempotency - re-emitting a seen idempotency_key returns empty deltas and changes nothing.
  • Lazy expiry - no scheduler; a quest past expires_at flips to expired when an event or turn-in touches it, and stops matching.

The HTTP endpoint (POST /api/v1/quests/events) takes an array of up to 50 events. With a platform admin token, subject_user_id applies the events to another user - the service-to-service path; non-admins get 403.

Turn-in and rewards

snug quest turn-in --quest-id forge
{
  "quest_id": "forge",
  "status": "turned_in",
  "rewards": [
    { "reward": { "type": "treasury", "currency": "gold", "amount": 50 }, "status": "granted" }
  ],
  "unlocked": []
}

Turn-in is atomic and exactly-once: incomplete steps are 409 steps_incomplete, a second turn-in is 409 already_turned_in, and past the deadline it is 409 quest_expired. Rewards fan out to Treasury (type: treasury), Item Engine (type: item), and Achievements (type: achievement) through a durable per-line intent - each line comes back granted, failed, or pending; if the fanout is interrupted, snug quest resume-rewards --quest-id forge re-drives the pending lines. unlocked lists quest ids from branching rules with on: "turned_in" - pair them with prerequisites.all_of on the next act to build chains.

snug quest analytics --quest-id forge returns accepted / turned_in / expired / abandoned counts and the turn-in rate (requires a platform admin token).

Limits and configuration

Definitions are capped at 50 steps and 100000 total, event idempotency keys live 24 hours, and puzzle regex patterns are bounded at 256 characters by default - tunable via the QUEST_* variables in the Quest CONFIG reference.

Reference

On this page