asobi_quests
Quests and progress counters for asobi,
and the first real asobi_extension.
A quest names a counter, a target and a window. A game script reports raw gameplay events and never mentions a quest:
game.quests.progress(player_id, "kills", 1)
One report advances every active quest listening for that counter - a daily, a weekly and a lifetime quest at once - and tells the caller which ones just completed. asobi has no counter primitive, so this ships one.
Install
%% your_game/rebar.config
{deps, [
{asobi, {git, "https://github.com/widgrensit/asobi.git", {branch, "main"}}},
{asobi_quests, {git, "https://github.com/widgrensit/asobi_quests.git", {branch, "main"}}}
]}.
{project_plugins, [{asobi, {git, "https://github.com/widgrensit/asobi.git", {branch, "main"}}}]}.
{relx, [{release, {your_game, "1.0.0"}, [your_game, asobi_quests, asobi, sasl]}]}.
rebar3 asobi check
Its two tables are created by its own migrations, discovered through kura
2.20's migration_apps/0 and applied inside core's migration transaction.
Using it
- The quests guide - Lua, RPC, periods, rewards, operations.
What it claims
| Kind | Tokens |
|---|---|
| Tables | quests, quest_progress |
| RPC prefix | quests |
| Lua namespace | quests |
| Job queue | quests |
Development
docker compose up -d
rebar3 fmt --check && rebar3 xref && rebar3 dialyzer && rebar3 eunit && rebar3 ct
rebar3 asobi check
The CT suite runs against real PostgreSQL. It boots nothing: it loads asobi
and asobi_quests, runs kura_migrator:migrate/1 and exercises the domain, so
what it proves about migration discovery is what a release would do.
What the extension contract could not express
This extension was built to find that out, so this section is the deliverable. It is ordered by how much it would cost to get wrong.
Every gap here is now closed, one of them differently from how it was asked. Each is left as it was written, with a Closed by line, because the report is worth more as the record of what a first extension actually hit than as a to-do list. This repo has since been converted onto every one of the answers, which is the second half of the same exercise: a contract nobody consumed would prove nothing.
| Gap | State |
|---|---|
1. rpc/0 and lua/0 read by nothing | closed - widgrensit/asobi#365 |
| 2. Handler signatures undefined | closed - widgrensit/asobi#365 |
3. args length never checked against mfa arity | closed - widgrensit/asobi#361 |
| 4. An extension cannot mint an error code | closed - widgrensit/asobi#360 |
5. asobi_repo has no raw-query escape hatch | closed for the counter - widgrensit/asobi#362 |
6. No on_player_deleted hook | closed - erase_player/1 |
7. sup/0 gives no core-readiness dependency | closed - readiness gate |
| 8. No operator-only method | closed - ops/0 |
9. owns.queues is inert twice over | closed - queues derived |
11. Generated down/0 cannot run | closed - rebar3_kura v0.16.1 |
1. rpc/0 and lua/0 are declarations that nothing reads
Closed by widgrensit/asobi#365. Both are dispatched now: rpc.call over
the socket, and the Lua namespace injector at install time.
There is no RPC dispatcher and no Lua injector in core. asobi_extensions:resolve/0
is called from three places - asobi_router:routes/1, asobi_app:start/2 and
asobi_repo:migration_apps/0 - and none of them looks at rpc or lua.
asobi_lua_api:install/2 installs exactly asobi_lua_surface:reserved_namespaces/0,
a hardcoded list with no extension hook, and asobi_readiness' own moduledoc
says the dispatcher "is Wave 2b and does not exist yet".
So today a game script calling game.quests.progress(...) gets
attempt to index a nil value, and a client calling asobi.rpc("quests.claim")
reaches nothing. Both consumption paths the architecture calls mandatory are
unbuilt, and the machinery shipped ahead of them. Everything below is
downstream of this.
2. The handler signatures are undefined
Closed by widgrensit/asobi#365, which specified the shape this repo
invented - (Params, Ctx) -> {ok, map()} | {error, Code} | {error, Code, Details} -
including the rule that a handler never reads a player id out of Params.
rpc/0 maps a method to an mfa() with arity 2, and nothing says what the two
arguments are. lua/0 gives mfa, args, effects and vms, and nothing
says whether the function is called with the Luerl state or with decoded
arguments. This extension had to invent both:
%% RPC
handler(Params :: map(), Ctx :: #{player_id := binary()}) ->
{ok, map()} | {error, Status :: pos_integer(), asobi_error:object()}
%% Lua
binding(Arg1, ..., ArgN) -> {ok, term()} | {error, binary()}
A second extension will invent something else. The security-relevant half -
a handler must never read a player id out of Params - is exactly the kind
of rule the contract should carry and does not.
3. args and mfa arity are never checked against each other
Closed by widgrensit/asobi#361: the guard compares them.
asobi_extensions' internal is_lua_binding/2 validates that args is a list
and mfa an {M, F, A}, and never compares length(Args) with A. Core's own fixture,
asobi_fixture_quests_extension, ships status with args => [binary] and
mfa => {..., status, 2}. If the injector applies mfa to the decoded args,
that fixture cannot work - and today nothing notices, because nothing applies
anything. One line in that guard closes it.
4. An extension cannot mint an error code
Closed by widgrensit/asobi#360.codes/0 on the manifest declares them,
and asobi_quests_error - the module that duplicated core's table - is gone.
asobi_error's code set is a closed ?CODES macro with no registration seam.
asobi_error:status/1 answers 500 for quests.already_claimed, and
asobi_error:handle/3 calls log_undefined/1, which logs undefined_error_code
at ERROR level for any code outside the set. So a player claiming a quest
twice - an ordinary outcome - would return 500 and page somebody.
Meanwhile asobi_extension_reserved derives the reserved RPC prefixes fromasobi_error:codes/0, on the stated grounds that "an RPC prefix and an error
domain are the same token by construction". The contract already assumes
extensions mint codes in their own domain. Nothing lets them.
The workaround here is asobi_quests_error, which duplicates core's
three-column table and calls asobi_error:object/3 for the shape. Core needs a
register_codes/1, or its codes/0 needs to consult the extension set.
5. asobi_repo has no raw-query escape hatch
Closed by widgrensit/asobi#362, though not the way this asked. Core added
asobi_repo:increment/3 - a schema-checked accumulating upsert - rather than a
general query/2, on the grounds that every identifier in it is a field of the
schema and every value is a bound parameter, which raw SQL through the seam
could not promise.
That covers the counter. It does not cover the rest of this extension's
statement, which also stamps completed_at with a COALESCE in the same
upsert so a quest completes exactly once under concurrent reports. So
asobi_quests still issues one statement of its own, and that is the right
outcome: the alternative was core growing a conditional-timestamp feature for
one consumer.
The counter is INSERT ... ON CONFLICT DO UPDATE SET counter = counter + EXCLUDED.counter, one statement, so concurrent reports cannot lose an
increment. It cannot be written through asobi_repo: update_all/2 sets
literal values only, and kura's on_conflict offers nothing, replace_all
and {replace, Fields}, all of which overwrite with the excluded value.
kura_repo_worker:query/3 exists and does exactly this, but asobi_repo does
not re-export it, so the seam an extension is told to use has a hole and the
extension reaches past it. Core has the same problem and solves it the same
way: asobi_economy's acquire_wallet_lock/2 calls kura_db:query/3 directly.
Adding query/2 to asobi_repo's exports is a two-line change and makes the
documented seam sufficient.
6. There is no on_player_deleted hook, and no way to ask for one
Closed by widgrensit/asobi#372: the optional erase_player/1 callback,
run inside core's own transaction, extensions before core.
The architecture says an extension foreign key into players.id must "cascade
or declare an erase path", and owns/0 has no key for either. quest_progress
cascades, which is right for quests and wrong for anything holding a financial
or audit record - the exact case the architecture uses to reject a blanket
cascade. Such an extension has no way to register the alternative, so it would
have to block player deletion and hope somebody noticed.
7. sup/0 gives no ordering and no dependency on core readiness
Closed by widgrensit/asobi#372: extension children start in sup/0 order
after migrations, so init/1 may query, and if migrations did not complete no
extension starts at all.
asobi_extension_sup starts extension sub-supervisors as siblings under
asobi_sup, and an extension child that queries at init/1 may run before or
after anything else. asobi_quests_cache therefore cannot assume the pool is
usable, cannot crash if it is not - a crash loop under
asobi_extension_child_sup ends with the extension dark and staying dark - and
has to publish a "not loaded yet" state so readers fall back to the database.
That is three pieces of defensive machinery for what would be one line in a
start/2 the library-application shape deliberately removes. It is a real
cost, and the guide's "ETS tables and config validation move into init/1"
undersells it.
8. An extension cannot declare an operator-only method
Closed by widgrensit/asobi#373.ops/0 declares an action, its method and
its ADR 0007 capability class; core owns one route,
/api/v1/ops/ext/:extension/:action, and dispatches it. define/2 moved to
asobi_quests_ops and is now reachable, audited and operator-only, instead of
exported and left off the manifest.
quests.define is an operator action. rpc/0 is a flat method-to-mfa() map
with no capability class, while ADR 0007 already gave the ops plane
read | player_data | config. So define/2 is exported and deliberately left
out of the manifest, reachable only by a host that routes to it itself. Any
extension with an admin surface hits this immediately.
9. owns/0 earned nothing, exactly as predicted - but queues is inert twice over
Closed by widgrensit/asobi#372 for the second half: queue claims are derived from the extension's own workers, the same way core's are.
With one extension installed there is nothing to collide with. Worth noting
that owns.queues is never derived from anything: rpc and lua claims are
inferred from the manifest, but a shigoto queue is only ever what owns/0
says, so a typo there is invisible and a queue claimed by nobody is
unenforceable. Queues could be derived the same way core's are, from
queue/0 + perform/1 on the extension's own modules.
10. Things that worked, and are worth not breaking
- Migration discovery.
migration_apps/0returned[asobi, asobi_quests]in dependency order and the extension's tables were created inside core's transaction, first try, with no host-side delegating file. Both gaps the guide still warns about here are closed. on_deleteon#kura_assoc.cascadereached the generated DDL and deleting a player removes its progress rows. The guide's "your foreign key will not cascade" is stale.- Foreign keys into a dependency's schema.
#kura_assoc{schema = asobi_player}resolved from asobi's ebin and emittedREFERENCES players(id). - Table ordering.
quest_progressis emitted beforequestsand references it; the migration applies anyway. rebar3 asobi check. Zero configuration beyond adding asobi toproject_plugins, and it names what it found.- Background jobs. A shigoto worker needed no manifest entry at all, as documented.
11. One dependency bug worth a ticket
The generated down/0 cannot run.rebar3_kura v0.16.0 emits every
drop_table before every drop_index, and dropping a table already drops its
indexes:
rollback: {error, {migration_failed, 20260804083021,
{pgsql_error, #{code => <<"42704">>,
message => <<"index \"quest_progress_player_id_quest_id_period_key_index\" does not exist">>}}}}
Reproduced against PostgreSQL 17. Every generated migration that creates a
table with an index has an unusable rollback. src/migrations/ is left exactly
as generated rather than hand-corrected, because hiding it in this repo would
not fix it in anyone else's.
Closed by Taure/rebar3_kura#43 in v0.16.1: an index of a table the same
down/0 drops is no longer emitted at all. This repo now pins v0.16.1, and its
one migration is corrected in place - regenerating would only ever produce a
second file.
Filed
| Gap | Ticket |
|---|---|
| Extensions cannot mint an error code | widgrensit/asobi#360 |
args length is never checked against mfa arity | widgrensit/asobi#361 |
asobi_repo has no raw-query escape hatch | widgrensit/asobi#362 |
Generated down/0 cannot run | Taure/rebar3_kura#43 |
Licence
Apache-2.0.