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.
%% 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 checkIts two tables are created by its own migrations, discovered through kura
2.20's migration_apps/0 and applied inside core's migration transaction.
- The quests guide - Lua, RPC, periods, rewards, operations.
| Kind | Tokens |
|---|---|
| Tables | quests, quest_progress |
| RPC prefix | quests |
| Lua namespace | quests |
| Job queue | quests |
docker compose up -d
rebar3 fmt --check && rebar3 xref && rebar3 dialyzer && rebar3 eunit && rebar3 ct
rebar3 asobi checkThe 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.
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 |
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.
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.
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.
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 from
asobi_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.
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.
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.
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.
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.
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.
- 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.
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.
| 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 |
Apache-2.0.