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.
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.
Why this viewer cannot claim this quest yet, as a short label to draw — or
nil when they can. Set config :gamend_core, :quest_lock_filter, {Module, :function}; it is called as function(user_id | nil, Quest.t()).
The host's say on which quest definitions one viewer may see at all — a
premium-only daily, a quest for a country the player has not unlocked. Set
config :gamend_core, :quest_visibility_filter, {Module, :function}; it is
called as function(user_id | nil, [Quest.t()]) and returns the quests to
keep. Applied to every per-user listing (list_user_quests/2,
count_user_quests/2, visible_categories/1) and to the signed-out catalog
with nil. Progress is NOT filtered: an event still advances a quest the
viewer cannot see, so a player who gains access later finds it where they
left it; veto the claim in before_quest_claim if that must not pay.
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
@type user_id() :: Ecto.UUID.t()
Functions
@spec active_quests() :: [Gamend.Quests.Quest.t()]
All active quest definitions (cached — this backs event dispatch).
@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).
@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).
@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).
@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.
@spec change_quest(Gamend.Quests.Quest.t(), map()) :: Ecto.Changeset.t()
Returns a changeset for tracking quest changes (used by forms).
@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()}.
@spec claimable_count(user_id()) :: non_neg_integer()
Number of completed-but-unclaimed quests for a user (badge count).
@spec count_progress(keyword()) :: non_neg_integer()
Count progress rows (same filters as list_progress/1).
@spec count_quests(keyword()) :: non_neg_integer()
Count quest definitions (same filters as list_quests/1).
@spec count_user_completions( user_id(), keyword() ) :: non_neg_integer()
Count of a user's completed quests (same filters as list_user_completions/2).
@spec count_user_quests( user_id(), keyword() ) :: non_neg_integer()
Count of quests visible to the user (same filters as list_user_quests/2).
@spec create_quest(map()) :: {:ok, Gamend.Quests.Quest.t()} | {:error, term()}
Creates a quest definition. Capped by the max_quests limit.
@spec dashboard_stats() :: map()
Quest statistics for the admin dashboard.
@spec delete_quest(Gamend.Quests.Quest.t()) :: {:ok, Gamend.Quests.Quest.t()} | {:error, Ecto.Changeset.t()}
Deletes a quest definition and all related progress.
@spec funnel(String.t()) :: %{required(String.t()) => non_neg_integer()}
Per-status progress counts for one quest (admin completion funnel).
@spec get_progress(user_id(), String.t()) :: Gamend.Quests.QuestProgress.t() | nil
Get a user's progress row for a quest's current period.
@spec get_quest(Ecto.UUID.t()) :: Gamend.Quests.Quest.t() | nil
Get a quest by ID.
@spec get_quest_by_key(String.t()) :: Gamend.Quests.Quest.t() | nil
Get a quest by 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.
@spec host_lock_label(Gamend.Quests.Quest.t(), user_id() | nil) :: String.t() | nil
Why this viewer cannot claim this quest yet, as a short label to draw — or
nil when they can. Set config :gamend_core, :quest_lock_filter, {Module, :function}; it is called as function(user_id | nil, Quest.t()).
The display counterpart of before_quest_claim. A quest can be worth showing
and still not claimable — a premium tier a player has not bought is an offer,
and hiding it means only the people who already took the offer ever see it.
host_visible/2 cannot express that: it keeps a quest or drops it, and
hidden is a column, the same for everyone, that draws "???" over the very
title the offer is made of.
A label here changes nothing about what pays: the veto in before_quest_claim
is still the authority, and a lock filter that disagrees with it only makes
the page lie. Return the reason from the same condition the veto tests.
@spec host_visible([Gamend.Quests.Quest.t()], user_id() | nil) :: [ Gamend.Quests.Quest.t() ]
The host's say on which quest definitions one viewer may see at all — a
premium-only daily, a quest for a country the player has not unlocked. Set
config :gamend_core, :quest_visibility_filter, {Module, :function}; it is
called as function(user_id | nil, [Quest.t()]) and returns the quests to
keep. Applied to every per-user listing (list_user_quests/2,
count_user_quests/2, visible_categories/1) and to the signed-out catalog
with nil. Progress is NOT filtered: an event still advances a quest the
viewer cannot see, so a player who gains access later finds it where they
left it; veto the claim in before_quest_claim if that must not pay.
@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
@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
@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
@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
@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.
@spec prerequisite_met?(user_id(), Gamend.Quests.Quest.t()) :: boolean()
True when the quest's prerequisite (if any) is completed by the user.
@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).
@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.
@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.
@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.
@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.
@spec resolve_counter(Gamend.Quests.Quest.t(), Gamend.Quests.QuestProgress.t() | nil) :: Gamend.Quests.Quest.t()
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.
@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.
@spec subscribe_quests() :: :ok | {:error, term()}
Subscribe to global quest events (definition changes, completions).
@spec update_quest(Gamend.Quests.Quest.t(), map()) :: {:ok, Gamend.Quests.Quest.t()} | {:error, Ecto.Changeset.t()}
Updates a quest definition.
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.