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

What it claims

KindTokens
Tablesquests, quest_progress
RPC prefixquests
Lua namespacequests
Job queuequests

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.

GapState
1. rpc/0 and lua/0 read by nothingclosed - widgrensit/asobi#365
2. Handler signatures undefinedclosed - widgrensit/asobi#365
3. args length never checked against mfa arityclosed - widgrensit/asobi#361
4. An extension cannot mint an error codeclosed - widgrensit/asobi#360
5. asobi_repo has no raw-query escape hatchclosed for the counter - widgrensit/asobi#362
6. No on_player_deleted hookclosed - erase_player/1
7. sup/0 gives no core-readiness dependencyclosed - readiness gate
8. No operator-only methodclosed - ops/0
9. owns.queues is inert twice overclosed - queues derived
11. Generated down/0 cannot runclosed - 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

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

GapTicket
Extensions cannot mint an error codewidgrensit/asobi#360
args length is never checked against mfa aritywidgrensit/asobi#361
asobi_repo has no raw-query escape hatchwidgrensit/asobi#362
Generated down/0 cannot runTaure/rebar3_kura#43

Licence

Apache-2.0.