Gamend.Quests (gamend_sdk v1.0.1124)

Copy Markdown View Source

Event-driven quest/progression engine.

One engine, three independent dimensions: a reset cycle (never / daily / weekly / monthly / every N days / repeat-on-claim), an optional window (starts_at/ends_at), and an optional prerequisite (prerequisite_quest_key). Any combination works — a biweekly quest inside a seasonal window that also requires an earlier quest is just those three fields set. Rewards pay into Gamend.Economy / Gamend.Inventory exactly once. category is a free-form label for your UI only.

Reporting progress (server-side / hooks)

Quests.report_event(user_id, "enemy_killed", 1, %{"map" => "desert"})

Every active quest with an objective on "enemy_killed" (whose params all match the meta) advances; a quest completes when every objective meets its target. There is deliberately no public endpoint for this — clients cannot advance their own quests. Core wires common events; games call it from their hooks for custom events.

Claiming

{:ok, %{progress: progress, rewards: rewards}} = Quests.claim(user_id, "daily_win_3")

Claiming is gated by an atomic completed → claimed status transition, so a double-tap or a concurrent claim can't double-pay. Rewards are granted after the transition with a per-entry idempotency key ("quest:<progress_id>:<i>", or "quest:<progress_id>:<claim_count>:<i>" once a repeat quest has been claimed before — the row alone would dedupe its own second payout), so a crashed or retried grant can't double-apply either; rows that claimed but never finished granting are healed by recover_pending_rewards/1. Quests with auto_claim grant immediately on completion (skipping the before_quest_claim hook — there is no player request to veto).

Resets

period_key is derived from UTC time by the quest's reset (daily → "2026-07-22", weekly → "2026-W30", monthly → "2026-07", interval → "I14-1436", never → "static"). A new period simply means a new progress row on the next reported event — nothing needs to fire at midnight, and state resolves correctly even if no job ever runs.

repeat is the exception: it has no clock at all. The row is re-armed the moment its reward is paid, so the quest is available again immediately and as often as the player can finish it — for an endless objective, a calendar reset would cap the payout at once per period. It reuses "static" as its period and counts claims on the row instead (see claim_count).

UTC means one global rollover instant rather than one per player: a daily turns over at noon in New Zealand and mid-afternoon the day before on the US west coast. That is deliberate — everyone races the same clock — but it is why the API exposes the remaining time on a period and never a reset timestamp, and why clients should show a countdown rather than an hour.

Note: This is an SDK stub. Calling these functions will raise an error. The actual implementation runs on the Gamend.

Summary

Functions

All active quest definitions (cached — this backs event dispatch).

Claim on a user's behalf, skipping the before_quest_claim veto (admin).

Force-complete a quest for a user (admin grant): every objective jumps to its target and the normal completion side effects fire (hooks, auto-claim).

Delete a user's current-period progress row for a quest (admin reset).

Every quest in quest_key's prerequisite chain, in tier order, each with the user's current-period progress.

Returns a changeset for tracking quest changes (used by forms).

Claim a completed quest's rewards for the current period.

Number of completed-but-unclaimed quests for a user (badge count).

Count progress rows (same filters as list_progress/1).

Count quest definitions (same filters as list_quests/1).

Count of a user's completed quests (same filters as list_user_completions/2).

Count of quests visible to the user (same filters as list_user_quests/2).

Creates a quest definition. Capped by the max_quests limit.

Quest statistics for the admin dashboard.

Deletes a quest definition and all related progress.

Per-status progress counts for one quest (admin completion funnel).

Get a user's progress row for a quest's current period.

Get a quest by ID.

Get a quest by key.

Every member of a group, with the viewer's progress — what a UI shows when the player opens the one entry the group collapsed into.

Lists progress rows (admin viewer).

Lists quest definitions (admin view — no per-user state).

A user's completed quests, newest first — the public-profile view ("their achievements"). Hidden quests appear once earned.

Lists quests as seen by one user: active definitions in-window with the user's current-period progress and a claimable flag.

The reset bucket a quest is in at now (UTC).

True when the quest's prerequisite (if any) is completed by the user.

Deletes daily/weekly progress rows whose period ended more than max_quest_period_history days ago (called from Gamend.Retention).

Re-open repeat quests whose reward was paid but which never re-armed.

Re-runs reward grants for rows that claimed but never finished granting (e.g. the process died mid-grant). Safe to run anywhere, any time — the per-entry idempotency keys dedupe. Pass :user_id to heal one user (done lazily when they list their quests). Returns the number of rows retried.

The quest with %{n} replaced by its resolved counter, untranslated.

Report a gameplay event for a user, advancing every matching active quest.

Works out which run of a repeat quest a player is on, into :counter.

Aggregate quest progress counts for the public stats endpoint.

Subscribe to global quest events (definition changes, completions).

Updates a quest definition.

The categories that actually have something behind them for this viewer.

Types

user_id()

@type user_id() :: Ecto.UUID.t()

Functions

active_quests()

@spec active_quests() :: [Gamend.Quests.Quest.t()]

All active quest definitions (cached — this backs event dispatch).

admin_claim(user_id, quest_key)

@spec admin_claim(user_id(), String.t()) ::
  {:ok, %{progress: Gamend.Quests.QuestProgress.t(), rewards: [map()]}}
  | {:error, term()}

Claim on a user's behalf, skipping the before_quest_claim veto (admin).

admin_complete(user_id, quest_key)

@spec admin_complete(user_id(), String.t()) ::
  {:ok, Gamend.Quests.QuestProgress.t()} | {:error, term()}

Force-complete a quest for a user (admin grant): every objective jumps to its target and the normal completion side effects fire (hooks, auto-claim).

admin_reset(user_id, quest_key)

@spec admin_reset(user_id(), String.t()) ::
  {:ok, Gamend.Quests.QuestProgress.t() | :not_found} | {:error, term()}

Delete a user's current-period progress row for a quest (admin reset).

chain(user_id, quest_key)

@spec chain(user_id() | nil, String.t()) :: [
  %{
    quest: Gamend.Quests.Quest.t(),
    progress: Gamend.Quests.QuestProgress.t() | nil,
    claimable: boolean(),
    locked: boolean(),
    tier: pos_integer()
  }
]

Every quest in quest_key's prerequisite chain, in tier order, each with the user's current-period progress.

The quest list hides a tier until its prerequisite is done; this is the one read that shows a whole chain — earlier tiers and the ones still ahead. Each entry carries :tier (1-based), :locked (prerequisite not yet done for this user) and the usual :progress/:claimable. With a nil user every tier after the first is locked and progress is nil.

Returns [] for an unknown or inactive key. A quest with no chain links returns just its own entry.

change_quest(quest, attrs)

@spec change_quest(Gamend.Quests.Quest.t(), map()) :: Ecto.Changeset.t()

Returns a changeset for tracking quest changes (used by forms).

claim(user_id, quest_key, opts)

@spec claim(user_id(), String.t(), keyword()) ::
  {:ok, %{progress: Gamend.Quests.QuestProgress.t(), rewards: [map()]}}
  | {:error, term()}

Claim a completed quest's rewards for the current period.

Runs the before_quest_claim pipeline hook (veto), then transitions completed → claimed atomically — only the winner grants rewards.

Returns {:ok, %{progress: progress, rewards: rewards}} or {:error, :quest_not_found | :not_completed | :already_claimed | term()}.

claimable_count(user_id)

@spec claimable_count(user_id()) :: non_neg_integer()

Number of completed-but-unclaimed quests for a user (badge count).

count_progress(opts)

@spec count_progress(keyword()) :: non_neg_integer()

Count progress rows (same filters as list_progress/1).

count_quests(opts)

@spec count_quests(keyword()) :: non_neg_integer()

Count quest definitions (same filters as list_quests/1).

count_user_completions(user_id, opts)

@spec count_user_completions(
  user_id(),
  keyword()
) :: non_neg_integer()

Count of a user's completed quests (same filters as list_user_completions/2).

count_user_quests(user_id, opts)

@spec count_user_quests(
  user_id(),
  keyword()
) :: non_neg_integer()

Count of quests visible to the user (same filters as list_user_quests/2).

create_quest(attrs)

@spec create_quest(map()) :: {:ok, Gamend.Quests.Quest.t()} | {:error, term()}

Creates a quest definition. Capped by the max_quests limit.

dashboard_stats()

@spec dashboard_stats() :: map()

Quest statistics for the admin dashboard.

delete_quest(quest)

@spec delete_quest(Gamend.Quests.Quest.t()) ::
  {:ok, Gamend.Quests.Quest.t()} | {:error, Ecto.Changeset.t()}

Deletes a quest definition and all related progress.

funnel(quest_key)

@spec funnel(String.t()) :: %{required(String.t()) => non_neg_integer()}

Per-status progress counts for one quest (admin completion funnel).

get_progress(user_id, quest_key)

@spec get_progress(user_id(), String.t()) :: Gamend.Quests.QuestProgress.t() | nil

Get a user's progress row for a quest's current period.

get_quest(id)

@spec get_quest(Ecto.UUID.t()) :: Gamend.Quests.Quest.t() | nil

Get a quest by ID.

get_quest_by_key(key)

@spec get_quest_by_key(String.t()) :: Gamend.Quests.Quest.t() | nil

Get a quest by key.

group(user_id, group_key)

@spec group(user_id() | nil, String.t()) :: [
  %{
    quest: Gamend.Quests.Quest.t(),
    progress: Gamend.Quests.QuestProgress.t() | nil,
    claimable: boolean()
  }
]

Every member of a group, with the viewer's progress — what a UI shows when the player opens the one entry the group collapsed into.

Ordered by sort_order like any list; a group has no tiers, so unlike chain/2 there is nothing locked and nothing to number. Members the quest list would not show this viewer (out of window, prerequisite unmet) are left out too, so the count on the collapsed entry matches what opening it reveals.

Returns [] for a group key nothing carries.

list_progress(opts)

@spec list_progress(keyword()) :: [Gamend.Quests.QuestProgress.t()]

Lists progress rows (admin viewer).

## Options

  • :user_id — exact UUID or username/display-name substring
  • :quest_key, :status
  • :page / :page_size

list_quests(opts)

@spec list_quests(keyword()) :: [Gamend.Quests.Quest.t()]

Lists quest definitions (admin view — no per-user state).

## Options

  • :category — filter by category
  • :active — filter by active flag
  • :search — substring match on key/title
  • :page / :page_size

list_user_completions(user_id, opts)

@spec list_user_completions(
  user_id(),
  keyword()
) :: [
  %{quest: Gamend.Quests.Quest.t(), progress: Gamend.Quests.QuestProgress.t()}
]

A user's completed quests, newest first — the public-profile view ("their achievements"). Hidden quests appear once earned.

## Options

  • :category — filter by category (a profile typically wants "achievement")
  • :page / :page_size

list_user_quests(user_id, opts)

@spec list_user_quests(
  user_id(),
  keyword()
) :: [
  %{
    quest: Gamend.Quests.Quest.t(),
    progress: Gamend.Quests.QuestProgress.t() | nil,
    claimable: boolean()
  }
]

Lists quests as seen by one user: active definitions in-window with the user's current-period progress and a claimable flag.

Hidden quests are listed but carry no details until earned (callers obscure them). Chain quests only appear once their prerequisite is met. Grouped quests collapse to one entry carrying :group_size.

## Options

  • :category — filter by category
  • :group — expand this one group's members; every other group stays collapsed
  • :status"in_progress" (not yet completed), "claimable" (completed, waiting to be claimed) or "done" (completed or claimed)
  • :page / :page_size

period_key(quest_or_reset, now)

@spec period_key(Gamend.Quests.Quest.t() | String.t(), DateTime.t()) :: String.t()

The reset bucket a quest is in at now (UTC).

"static" when it never resets, else the current day ("2026-07-22"), ISO week ("2026-W30"), month ("2026-07"), or interval bucket ("I14-1436" — the 1436th 14-day window since the epoch). Derived purely from the clock, so a reset needs nothing to fire at midnight.

prerequisite_met?(user_id, quest)

@spec prerequisite_met?(user_id(), Gamend.Quests.Quest.t()) :: boolean()

True when the quest's prerequisite (if any) is completed by the user.

prune_old_periods()

@spec prune_old_periods() :: non_neg_integer()

Deletes daily/weekly progress rows whose period ended more than max_quest_period_history days ago (called from Gamend.Retention).

rearm_repeat_quests(opts)

@spec rearm_repeat_quests(keyword()) :: non_neg_integer()

Re-open repeat quests whose reward was paid but which never re-armed.

Only reachable by crashing between the grant and the re-arm. Healing on read keeps that window from stranding a player on a quest that will never come back, without a job that has to be running for the feature to work.

recover_pending_rewards(opts)

@spec recover_pending_rewards(keyword()) :: non_neg_integer()

Re-runs reward grants for rows that claimed but never finished granting (e.g. the process died mid-grant). Safe to run anywhere, any time — the per-entry idempotency keys dedupe. Pass :user_id to heal one user (done lazily when they list their quests). Returns the number of rows retried.

render_counter(quest)

@spec render_counter(Gamend.Quests.Quest.t()) :: Gamend.Quests.Quest.t()

The quest with %{n} replaced by its resolved counter, untranslated.

For consumers that emit the stored string as-is. Anything that translates interpolates through Gettext instead, so the placeholder survives long enough to be looked up. An unresolved counter reads as run 1 — an anonymous visitor browsing the catalog is looking at the run they would start.

report_event(user_id, event, amount, meta)

@spec report_event(user_id(), String.t(), pos_integer(), map()) ::
  {:ok, [Gamend.Quests.QuestProgress.t()]}

Report a gameplay event for a user, advancing every matching active quest.

meta narrows objective matching: an objective with params only advances when every param key/value is present in meta.

Returns {:ok, progress_rows} for the quests that advanced.

resolve_counter(quest, progress)

Works out which run of a repeat quest a player is on, into :counter.

A repeat quest is one definition and one row that re-arms forever, so it has no natural way to say which run the player is on — the card reads the same the tenth time as the first. "Treasures x %{n}" renders "Treasures x 1" before the first claim and "Treasures x 2" after it.

Resolved here, at the point a definition is paired with a player's row, because the definition is global and the count is not: writing the number into the stored title would show every player the same one.

The number lands on the virtual :counter field and %{n} stays in the title, because the title is also a msgid: GamendWeb.ContentText looks the translation up by it and hands n to Gettext as a binding, so a German card reads "Schätze x 3" instead of falling back to English. Filling it in here would leave "Treasures x 3", which matches no msgid in any locale. Callers that render the title without translating it — the JSON API — call render_counter/1 to collapse the placeholder.

Non-repeat quests and titles without the placeholder pass through untouched, so this is invisible to everything that does not opt in.

stats()

@spec stats() :: %{
  quests_total: non_neg_integer(),
  completed: non_neg_integer(),
  claimed: non_neg_integer()
}

Aggregate quest progress counts for the public stats endpoint.

Grouped by status in one query — quest_progress carries a partial index on completed rows, and the group-by reads the same rows the listing already does.

subscribe_quests()

@spec subscribe_quests() :: :ok | {:error, term()}

Subscribe to global quest events (definition changes, completions).

update_quest(quest, attrs)

@spec update_quest(Gamend.Quests.Quest.t(), map()) ::
  {:ok, Gamend.Quests.Quest.t()} | {:error, Ecto.Changeset.t()}

Updates a quest definition.

visible_categories(user_id)

@spec visible_categories(user_id() | nil) :: [String.t()]

The categories that actually have something behind them for this viewer.

Derived from the same visibility rule as list_user_quests/2 rather than from every definition: a chain's later tiers are hidden until unlocked, so listing their category gives a tab that opens onto nothing. Pass nil for the signed-out catalog.