asobi_quests (asobi_quests v0.2.2)

View Source

The domain. Plain Erlang over asobi_repo, knowing nothing about Lua, RPC or HTTP - each of those is a thin adapter over this module.

The counter

asobi has no counter primitive, so quests ships one. A quest names a counter and a target; a game script reports raw events ("kills", +1) and never mentions a quest. progress/3 fans one report out to every active quest listening for that counter, in the caller's current period.

The increment is a single INSERT ... ON CONFLICT DO UPDATE SET counter = counter + EXCLUDED.counter statement, so two match processes reporting a kill in the same millisecond cannot lose one. It cannot be expressed through asobi_repo: update_all/2 sets literal values and kura's on_conflict replaces rather than accumulates, so the statement goes through kura_repo_worker:query/3. See the gap note in the README.

Completion and claiming

completed_at is stamped by the same statement that crosses the target, and COALESCE keeps the first stamp: a quest completes once even if the counter keeps climbing.

claim/2 is a guarded UPDATE ... WHERE completed_at IS NOT NULL AND claimed_at IS NULL. Postgres decides the winner of a double-claim race, so the reward is granted by whichever call updated a row and the other is told already_claimed without reading anything first.

Summary

Functions

Every active definition.

Every active definition listening for Counter.

Claim a completed quest's reward.

Deactivate a definition. Progress rows survive; a deactivated quest simply stops advancing.

Create or update a definition, keyed on quest_key.

One definition by key. Reads the cache, falling back to the database.

Report Amount of Counter for a player, advancing every quest listening.

Delete progress rows from periods that closed more than Days ago.

Every active quest's state for a player, in the player's current period.

Types

advance()

-type advance() ::
          #{quest_key := binary(),
            counter := non_neg_integer(),
            target := pos_integer(),
            completed := boolean(),
            newly_completed := boolean(),
            claimed := boolean()}.

quest()

-type quest() :: map().

state()

-type state() ::
          #{quest_key := binary(),
            title := binary(),
            counter_name := binary(),
            period := asobi_quests_period:period(),
            period_key := binary(),
            counter := non_neg_integer(),
            target := pos_integer(),
            completed := boolean(),
            claimed := boolean(),
            reward_currency := binary() | undefined,
            reward_amount := non_neg_integer()}.

Functions

active()

-spec active() -> {ok, [quest()]} | {error, term()}.

Every active definition.

active_for(Counter)

-spec active_for(binary()) -> {ok, [quest()]} | {error, term()}.

Every active definition listening for Counter.

claim(PlayerId, QuestKey)

-spec claim(binary(), binary()) ->
               {ok,
                #{quest_key := binary(), currency := binary() | undefined, amount := non_neg_integer()}} |
               {error, quest_not_found | not_completed | already_claimed | term()}.

Claim a completed quest's reward.

The whole thing is one transaction: the guarded claim update and the economy grant commit together or not at all, so a grant that fails cannot leave a quest marked claimed and unpaid.

deactivate(QuestKey)

-spec deactivate(binary()) -> ok | {error, term()}.

Deactivate a definition. Progress rows survive; a deactivated quest simply stops advancing.

define(Params)

-spec define(map()) -> {ok, quest()} | {error, term()}.

Create or update a definition, keyed on quest_key.

Idempotent on purpose: a game script declares its quests from init(), which runs on every match or world boot, so calling this a thousand times has to converge on one row.

fetch(QuestKey)

-spec fetch(binary()) -> {ok, quest()} | {error, not_found}.

One definition by key. Reads the cache, falling back to the database.

progress/3

-spec progress(binary(), binary(), pos_integer()) -> {ok, [advance()]} | {error, term()}.

Report Amount of Counter for a player, advancing every quest listening.

Returns one entry per quest advanced, so a caller that wants to announce a completion reads newly_completed rather than diffing anything itself. An unknown counter is not an error: it means no quest cares yet.

purge_expired(Days)

-spec purge_expired(non_neg_integer()) -> {ok, non_neg_integer()} | {error, term()}.

Delete progress rows from periods that closed more than Days ago.

Only recurring quests produce dead rows: a none quest's single row is its lifetime record and is never expired. Called by asobi_quests_rollover_worker.

status(PlayerId)

-spec status(binary()) -> {ok, [state()]} | {error, term()}.

Every active quest's state for a player, in the player's current period.

One query for the definitions and one for the progress rows, composed in Erlang. A join would have to express "the row whose period_key is the one this quest's period resolves to right now", which is per-quest and not a constant.