Current section
Files
Jump to
Current section
Files
asobi_quests
README.md
README.md
# asobi_quests
Quests and progress counters for [asobi](https://github.com/widgrensit/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:
```lua
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
```erlang
%% 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]}]}.
```
```sh
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](guides/quests.md) - Lua, RPC, periods, rewards, operations.
## What it claims
| Kind | Tokens |
|---|---|
| Tables | `quests`, `quest_progress` |
| RPC prefix | `quests` |
| Lua namespace | `quests` |
| Job queue | `quests` |
## Development
```sh
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:
```erlang
%% 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 *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.
## 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/0` returned `[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_delete` on `#kura_assoc`.** `cascade` reached 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 emitted `REFERENCES players(id)`.
- **Table ordering.** `quest_progress` is emitted before `quests` and references
it; the migration applies anyway.
- **`rebar3 asobi check`.** Zero configuration beyond adding asobi to
`project_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.