Skip to content

feat(world): dispatch on_zone_loaded/2 and on_zone_unloaded/2 - #575

Merged
Taure merged 2 commits into
mainfrom
feat/zone-lifecycle-hooks
Aug 30, 2026
Merged

Taure merged 2 commits into
mainfrom
feat/zone-lifecycle-hooks

Conversation

@Taure

@Taure Taure commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #574.

Both callbacks were declared, bridged into asobi_lua_world, documented in three guides, and never dispatched from src/. The tests did not catch it because they asserted membership in asobi_world:behaviour_info(callbacks) - a tautology over the declaration - and called the bridge function directly.

What now happens

on_zone_loaded/2 runs when asobi_zone_manager starts a zone on demand and that zone has nothing to restore:

  • a pre-warmed zone already carries the zone_state generate_world/2 built for it;
  • a persistent zone with a snapshot has already loaded it, and seeding it would discard what it was holding.

on_zone_unloaded/2 runs from the manager's cleanup_zone/2 - the one place that sees a reaped zone and a crashed one alike, since terminate/2 does not run on a kill. It does not fire on world teardown: the instance supervisor stops the manager before the zone supervisor, so the zones' DOWNs are never processed.

Why it is a two-cast round trip

The callback runs against the world's game state, which only the world server holds - for asobi_lua_world that is the world VM, and the 200 ms budget in guides/security-trust-model.md is a world-VM budget. So the zone cannot run it itself.

It cannot call the world server either: the world server synchronously calls into a zone it has just had created (asobi_zone:sync/1 in bind_player_zone/4), so a blocking call the other way deadlocks both for two timeouts on every join that opens a zone.

So it is two casts, and the zone declines to tick until the answer lands - a tick before the seed would run the spawner and handle_effects against exactly the blank zone_state the hook exists to fill in. ?ZONE_SEED_DEADLINE (1s) bounds it: a zone that is never answered starts blank, logs zone_seed_timeout, and ticks. A zone that never ticks is worse than a blank one.

The Lua shape

init_zone_state/2 builds the zone VM's game_state out of the game_state key of the zone_state it is handed. A flat table from the world script would reach the zone and then be invisible to it, so the bridge nests the return under that key. An empty return is still no seed, so if zone_state == nil still fires on the zone's first tick.

Tests

  • asobi_zone_lifecycle_hooks_tests drives a real asobi_world_instance: the hook fires on a lazy load, what it returned is in the zone's zone_state, a pre-warmed zone is not seeded, a stopped zone reports unloaded, and a re-created zone is seeded again.
  • asobi_lua_world_integration_tests runs the whole chain through a Lua world and asserts on what the zone script sees on its first tick, not on the bridge function.
  • One eqwalizer-driven hardening on the way past: handle_call({ensure_zone, ...}) is now guarded like revive_zone is, so a malformed coords answers {error, invalid_coords} instead of function_clausing a transient child of a one_for_all supervisor.

Checks

fmt --check, xref, dialyzer, elp eqwalize-all (157 errors, unchanged from origin/main), elp lint, ex_doc, and the full eunit suite (2413 tests, 0 failures) all run locally.

Both callbacks were declared in asobi_world, bridged in asobi_lua_world,
documented in three guides, and never called from anywhere in src. A game
relying on the documented hook to seed a lazily-created zone did not fail:
it got a blank zone_state and went looking for the bug in its own script.

on_zone_loaded/2 now runs when the zone manager creates a zone on demand
and that zone has nothing to restore. The callback belongs to the world's
game state - the world VM, for a Lua game - so the zone asks the world
server for it rather than running it itself, and it declines to tick until
the answer lands. Both directions are casts: the world server calls into a
zone it has just had created (asobi_zone:sync/1 on the join path), so a
blocking call the other way would deadlock every join that opens a zone.
A zone whose seed does not arrive within a second starts blank and logs.

The Lua bridge nests what the world script returns under game_state, which
is the key init_zone_state/2 builds the zone VM's game_state from - a flat
table would reach the zone and then be invisible to its script. An empty
return stays no seed at all, so the usual `if zone_state == nil` guard
still fires.

on_zone_unloaded/2 is dispatched from the manager's cleanup rather than
the zone's terminate/2, so a crashed zone reports as well as a reaped one.
Nothing fires on world teardown, where the manager stops before the zones.

Closes #574
@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

🟡 Code Coverage — 76.8%

9455 of 12315 lines covered.


🟡 ELP Lint — 192 warnings

212 diagnostics found. See job logs for details.


✅ ELP eqWAlize

No diagnostics.

@Taure
Taure merged commit 5a44be7 into main Aug 30, 2026
18 checks passed
@Taure
Taure deleted the feat/zone-lifecycle-hooks branch August 30, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_zone_loaded/2 and on_zone_unloaded/2 are declared, bridged, documented and never called

1 participant