Skip to content

feat(world): let a game stop a zone that still holds entities - #576

Merged
Taure merged 1 commit into
mainfrom
feat/zone-park
Aug 30, 2026
Merged

Taure merged 1 commit into
mainfrom
feat/zone-park

Conversation

@Taure

@Taure Taure commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #573.

handle_cast(reap, ...) accepted only on map_size(Entities) =:= 0, and the snapshot machinery wrote everything a zone held. The two halves never met: the only zone allowed to stop was the one whose snapshot had nothing in it.

Who is asking

The manager's reap is a proposal from an idle stamp that can lag real occupancy, so a populated zone declines it. That refusal is correct and stays. What was missing is a way for the game module to say "this zone is done", which is a different question:

  • asobi_zone:park/1, bound as game.zone.park() in the zone VM. A cast, like every game.zone.* call, because it runs inside the zone's own tick.
  • zone_park_on_idle = true, which lets the idle reaper take a zone that still holds entities - so zone_idle_timeout means something for a world whose zones contain scenery, instead of being dead for any zone that has ever been visited.

Neither can take a zone out from under players: a zone with subscribers declines a park and logs it (rate-limited), and a script_busy zone still declines an idle reap, so a wave spawner is not reaped mid-countdown.

The restore was only half there

maybe_restore_from_snapshot/1 loaded zone_state, spawner, entity_timers and tick - but not entities. It never had to: the only zone that could stop held none, and the world server's restore_entities/4 covered the world-restart case. With a populated zone now able to stop, the zone that next occupies those coords has to bring them back, or the snapshot is written and never read. The ETS crash backup still wins where it has anything, since it is fresher.

Whatever a zone starts life holding is also indexed into the spatial grid before its first tick. sync_spatial_grid/3 is a diff against the previous tick, so an entity that was there at start and never moves was never added - restored scenery would have been invisible to every game.spatial query for as long as it stayed still. That applied to the existing ETS-recovery path too.

Non-persistent worlds

The zone hands its dumped zone_state to the manager before stopping and the next zone at those coords starts from it - the "somewhere to leave what was true here" that games otherwise build a gen_server + named ETS extension for. Only for non-persistent worlds: a persistent one has the whole zone in its snapshot, and a stashed zone_state would actively suppress the snapshot load, since that path only runs for a blank zone_state.

Sent before the stop, not from terminate/2, so it is ordered ahead of the manager's DOWN for the same zone. Capped at max_active_zones entries, because a 2000x2000 grid has four million coords; past the cap the zone comes back blank and asobi logs parked_zone_state_dropped.

Docs

guides/large-worlds.md had a "Zone state is not snapshotted on the way out / no configuration path reaches it" paragraph that stopped being true when #543 wired persistence through. It is replaced with what actually survives a park, per persistent.

Tests

asobi_zone_park_tests (park accepted where reap is refused, declined with subscribers, park_on_idle for reap / subscribers / busy, snapshot-and-restore of a populated zone, the non-persistent stash, and no stash when persistent), plus manager tests for the parked set seeding / being consumed / respecting its cap, and two asobi_lua_api_tests for the binding.

Checks

fmt --check, xref, dialyzer, elp eqwalize-all (156 errors, one fewer than origin/main's 157), elp lint, ex_doc, and the full eunit suite (2421 tests, 0 failures).

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

🟡 Code Coverage — 76.8%

9486 of 12355 lines covered.


🟡 ELP Lint — 192 warnings

212 diagnostics found. See job logs for details.


✅ ELP eqWAlize

No diagnostics.

A zone could only be stopped when its entity map was empty, so the
snapshot that exists to preserve a populated zone never had anything to
preserve. The only lever a script had was to empty its own entity map so
the reaper would take it - which costs exactly the thing the snapshot
protects. Deleting the entities was the precondition for the teardown that
would have persisted them.

Two ways to stop a populated zone, both of them the game module's own
decision rather than the manager's idle stamp:

- asobi_zone:park/1, bound as game.zone.park(), for a script that knows
  this zone is done;
- zone_park_on_idle, which lets the idle reaper take a zone that still
  holds entities, so zone_idle_timeout means something for a world whose
  zones contain scenery.

Neither takes a zone out from under players: a zone with subscribers
declines and logs, and a script-busy zone still declines an idle reap.

The zone-side restore is the other half. maybe_restore_from_snapshot/1
loaded zone_state, spawner, timers and tick but never entities - it never
had to, because the only zone that could stop held none. It does now, and
whatever a zone starts life holding is indexed into the spatial grid
before its first tick, since sync_spatial_grid/3 is a diff and would never
add scenery that has not moved.

For a non-persistent world the zone hands its dumped zone_state to the
manager on the way out and the next zone at those coords starts from it.
The set is capped at max_active_zones, because a 2000x2000 grid has more
coords than a node has memory.

Closes #573
@Taure
Taure merged commit 817fd85 into main Aug 30, 2026
18 checks passed
@Taure
Taure deleted the feat/zone-park branch August 30, 2026 22:48
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.

A zone can only be reaped when its entity map is empty, so the snapshot that would have preserved it never has anything to preserve

1 participant