From 0c11f97aff6c82617ac32be241f2698360c0b235 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 29 Jun 2026 17:25:59 +0200 Subject: [PATCH 1/2] fix(world): build per-zone Lua VM in the zone process Implement the new asobi_world init_zone_state/2 and dump_zone_state/1 callbacks. Each zone now builds its own Luerl VM from the script in its own process, bound to the zone pid, so game.zone.spawn (and zone-based game.spatial / game.terrain) resolve on lazy and snapshot-recovered zones, not just pre-spawned ones. - init_zone_state/2 constructs the VM with zone_pid => self() and match_pid => world_server_pid, and re-encodes gameplay state restored from a snapshot. The VM is never persisted, only rebuilt here. - dump_zone_state/1 decodes the Luerl game_state ref to a plain map for jsonb (null when never seeded, so a script's `game_state == nil` guard still fires after recovery) and drops the VM. generate_world no longer injects per-zone VMs; it returns plain zone states and each zone builds its own. Requires asobi with init_zone_state/2 + dump_zone_state/1 (widgrensit/asobi#131); the asobi pin bump follows once that merges. --- src/lua/asobi_lua_world.erl | 105 +++++++++++----- test/asobi_lua_resource_limits_tests.erl | 2 +- test/asobi_lua_world_tests.erl | 19 +-- test/asobi_lua_zone_spawn_tests.erl | 149 +++++++++++++++++++++++ test/fixtures/lua/spawn_world.lua | 58 +++++++++ test/prop_lua_bridge_input_threading.erl | 3 +- 6 files changed, 298 insertions(+), 38 deletions(-) create mode 100644 test/asobi_lua_zone_spawn_tests.erl create mode 100644 test/fixtures/lua/spawn_world.lua diff --git a/src/lua/asobi_lua_world.erl b/src/lua/asobi_lua_world.erl index 19ddab6..29bbf3b 100644 --- a/src/lua/asobi_lua_world.erl +++ b/src/lua/asobi_lua_world.erl @@ -38,6 +38,7 @@ function on_zone_unloaded(cx, cy, state) -- return state -export([phases/1, on_phase_started/2, on_phase_ended/2]). -export([spawn_templates/1, on_world_recovered/2]). -export([terrain_provider/1, on_zone_loaded/2, on_zone_unloaded/2]). +-export([init_zone_state/2, dump_zone_state/1]). %% Wall-clock budgets for Lua callbacks. Init-time callbacks %% (`init`, `generate_world`, `phases`, `spawn_templates`, @@ -250,8 +251,10 @@ generate_world(Seed, Config) when is_map(Config) -> PreInstall = fun(St) -> asobi_lua_api:install(make_ctx(Config), St) end, case asobi_lua_loader:new(ScriptPath, ?GENERATE_TIMEOUT, PreInstall) of {ok, LuaSt} -> - {ok, ZoneStates} = generate_world(Seed, #{lua_state => LuaSt}), - {ok, inject_per_zone_lua(ZoneStates, ScriptPath, PreInstall)}; + %% Only used to ask the script for zone coords + initial + %% per-zone state. Each zone builds its own VM later, in its + %% own process, via init_zone_state/2. + generate_world(Seed, #{lua_state => LuaSt}); {error, Reason} -> ?LOG_ERROR(#{ msg => @@ -263,32 +266,6 @@ generate_world(Seed, Config) when is_map(Config) -> end end. --spec inject_per_zone_lua( - map(), file:filename_all(), asobi_lua_loader:pre_install() -) -> map(). -inject_per_zone_lua(ZoneStates, ScriptPath, PreInstall) -> - Mtime = filelib:last_modified(ScriptPath), - maps:map( - fun(_Coords, ZoneState) -> - Base = - case ZoneState of - M when is_map(M) -> M; - _ -> #{} - end, - case asobi_lua_loader:new(ScriptPath, ?GENERATE_TIMEOUT, PreInstall) of - {ok, LuaSt} -> - Base#{ - lua_state => LuaSt, - script => ScriptPath, - script_mtime => Mtime - }; - {error, _} -> - Base - end - end, - ZoneStates - ). - -spec get_state(binary(), map()) -> map(). get_state(PlayerId, #{lua_state := LuaSt, game_state := GS}) -> case asobi_lua_loader:call(get_state, [PlayerId, GS], LuaSt, ?GET_STATE_TIMEOUT) of @@ -677,6 +654,78 @@ decode_max_respawns(nil) -> infinity; decode_max_respawns(N) when is_number(N) -> trunc(N); decode_max_respawns(_) -> infinity. +%% Build this zone's Luerl VM in the zone process, so it binds to the zone pid +%% (self()) and game.zone.spawn / zone-based game.spatial / game.terrain resolve. +%% Called once via asobi_zone's handle_continue, for every zone-creation path +%% (pre-spawned, lazy, recovered). Re-encodes gameplay state from a prior +%% snapshot if present; the VM itself is never persisted, only rebuilt here. +-spec init_zone_state(map(), term()) -> map(). +init_zone_state(Config, ZoneState00) -> + %% An empty Lua zone table decodes to [], not #{}; coerce before merging. + ZoneState0 = + case ZoneState00 of + M when is_map(M) -> M; + _ -> #{} + end, + GameConfig = maps:get(game_config, Config, #{}), + case maps:get(lua_script, GameConfig, undefined) of + undefined -> + ZoneState0; + ScriptPath -> + PreInstall = fun(St) -> asobi_lua_api:install(zone_ctx(Config), St) end, + case asobi_lua_loader:new(ScriptPath, ?GENERATE_TIMEOUT, PreInstall) of + {ok, LuaSt0} -> + {GameState, LuaSt1} = restore_game_state(ZoneState0, LuaSt0), + ZoneState0#{ + lua_state => LuaSt1, + game_state => GameState, + script => ScriptPath, + script_mtime => filelib:last_modified(ScriptPath) + }; + {error, Reason} -> + ?LOG_ERROR(#{ + msg => + ~"asobi_lua_world init_zone_state: lua_loader:new failed; zone Lua inert", + script => ScriptPath, + reason => Reason + }), + ZoneState0 + end + end. + +%% Inverse of init_zone_state's restore path: drop the non-serialisable VM and +%% decode the script's gameplay state to a plain, JSON-safe map for jsonb. +%% game_state is the sole canonical persisted field; other per-zone keys are +%% rebuilt from config on init, so they are intentionally not carried here. +%% A never-seeded zone (game_state nil) round-trips as null, not #{}, so the +%% script's `game_state == nil` initialisation guard still fires after recovery. +-spec dump_zone_state(map()) -> map(). +dump_zone_state(#{lua_state := LuaSt} = ZoneState) -> + GameState = + case maps:get(game_state, ZoneState, nil) of + nil -> null; + GS -> decode_to_map(GS, LuaSt) + end, + #{~"game_state" => GameState}; +dump_zone_state(ZoneState) -> + maps:remove(lua_state, ZoneState). + +-spec restore_game_state(map(), dynamic()) -> {dynamic(), dynamic()}. +restore_game_state(ZoneState0, LuaSt) -> + case maps:get(~"game_state", ZoneState0, undefined) of + Map when is_map(Map) -> luerl:encode(Map, LuaSt); + _ -> {nil, LuaSt} + end. + +-spec zone_ctx(map()) -> map(). +zone_ctx(Config) -> + GameConfig = maps:get(game_config, Config, #{}), + #{ + zone_pid => self(), + match_pid => maps:get(world_server_pid, Config, self()), + match_id => maps:get(match_id, GameConfig, maps:get(world_id, Config, undefined)) + }. + -spec make_ctx(map()) -> map(). make_ctx(Config) -> #{ diff --git a/test/asobi_lua_resource_limits_tests.erl b/test/asobi_lua_resource_limits_tests.erl index c044cfb..e8baa1e 100644 --- a/test/asobi_lua_resource_limits_tests.erl +++ b/test/asobi_lua_resource_limits_tests.erl @@ -103,7 +103,7 @@ world_handle_input_no_wall_clock_timeout_test() -> ), Config = #{game_config => #{lua_script => Path}}, {ok, ZoneStates} = asobi_lua_world:generate_world(0, Config), - Zone0 = maps:get({0, 0}, ZoneStates), + Zone0 = asobi_lua_world:init_zone_state(Config, maps:get({0, 0}, ZoneStates)), Self = self(), %% Process dictionary doesn't inherit across spawns — set it inside %% the child so handle_input enters the Lua call path. diff --git a/test/asobi_lua_world_tests.erl b/test/asobi_lua_world_tests.erl index 2e6dc1f..e3ddf16 100644 --- a/test/asobi_lua_world_tests.erl +++ b/test/asobi_lua_world_tests.erl @@ -28,11 +28,13 @@ generate_world_from_raw_config_test() -> ?assert(is_map(ZoneStates)), %% The fixture declares one zone at "0,0"; the bridge parses it into a tuple. ?assert(maps:is_key({0, 0}, ZoneStates)), - %% Each zone must have its own lua_state stitched in so zone_tick/ - %% handle_input can invoke Lua callbacks. + %% generate_world returns plain zone states; the per-zone VM is built later, + %% in the zone process, via init_zone_state/2. Zone = maps:get({0, 0}, ZoneStates), ?assert(is_map(Zone)), - ?assert(maps:is_key(lua_state, Zone)). + ?assertNot(maps:is_key(lua_state, Zone)), + Built = asobi_lua_world:init_zone_state(Config, Zone), + ?assert(maps:is_key(lua_state, Built)). generate_world_missing_script_returns_empty_test() -> Config = #{game_config => #{}}, @@ -50,7 +52,8 @@ handle_input_uses_zone_state_from_proc_dict_test() -> Config = #{game_config => #{lua_script => Script}}, {ok, ZoneStates} = asobi_lua_world:generate_world(0, Config), ?assert(maps:is_key({0, 0}, ZoneStates)), - ZoneState = maps:get({0, 0}, ZoneStates), + %% The zone process builds the per-zone VM via init_zone_state before ticking. + ZoneState = asobi_lua_world:init_zone_state(Config, maps:get({0, 0}, ZoneStates)), %% First zone_tick primes the proc dict. erlang:erase({asobi_lua_world, zone_state}), @@ -78,7 +81,7 @@ generate_world_empty_zone_table_still_gets_lua_state_test() -> Config = #{game_config => #{lua_script => Script}}, {ok, ZoneStates} = asobi_lua_world:generate_world(0, Config), ?assert(maps:is_key({0, 0}, ZoneStates)), - Zone = maps:get({0, 0}, ZoneStates), + Zone = asobi_lua_world:init_zone_state(Config, maps:get({0, 0}, ZoneStates)), ?assert(is_map(Zone)), ?assert(maps:is_key(lua_state, Zone)), @@ -494,7 +497,7 @@ hot_reload_zone_tick_picks_up_global_change_test() -> try Config = #{game_config => #{lua_script => Path}, mode => ~"test"}, {ok, ZoneStates} = asobi_lua_world:generate_world(0, Config), - Zone0 = maps:get({0, 0}, ZoneStates), + Zone0 = asobi_lua_world:init_zone_state(Config, maps:get({0, 0}, ZoneStates)), erlang:erase({asobi_lua_world, zone_state}), {Ents0, Zone1} = asobi_lua_world:zone_tick(#{}, Zone0), ?assertMatch(#{~"marker" := #{~"tag" := ~"before"}}, Ents0), @@ -541,7 +544,7 @@ hot_reload_zone_tick_survives_syntax_error_test() -> try Config = #{game_config => #{lua_script => Path}, mode => ~"test"}, {ok, ZoneStates} = asobi_lua_world:generate_world(0, Config), - Zone0 = maps:get({0, 0}, ZoneStates), + Zone0 = asobi_lua_world:init_zone_state(Config, maps:get({0, 0}, ZoneStates)), erlang:erase({asobi_lua_world, zone_state}), {_E0, Zone1} = asobi_lua_world:zone_tick(#{}, Zone0), @@ -595,7 +598,7 @@ game_namespace_visible_in_zone_tick_and_handle_input_test() -> Script = fixture("game_api_world.lua"), Config = #{game_config => #{lua_script => Script}}, {ok, ZoneStates} = asobi_lua_world:generate_world(0, Config), - ZoneState = maps:get({0, 0}, ZoneStates), + ZoneState = asobi_lua_world:init_zone_state(Config, maps:get({0, 0}, ZoneStates)), erlang:erase({asobi_lua_world, zone_state}), {_Ents, ZoneState1} = asobi_lua_world:zone_tick(#{}, ZoneState), diff --git a/test/asobi_lua_zone_spawn_tests.erl b/test/asobi_lua_zone_spawn_tests.erl new file mode 100644 index 0000000..9a0c5bd --- /dev/null +++ b/test/asobi_lua_zone_spawn_tests.erl @@ -0,0 +1,149 @@ +-module(asobi_lua_zone_spawn_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% Per-zone Lua now runs regardless of how the zone was created: each zone +%% process builds its own Luerl VM from the script via init_zone_state/2, bound +%% to the zone pid, so game.zone.spawn reaches the live zone. These tests cover +%% the lazy/fresh path (init_zone_state from an empty zone_state), the snapshot +%% round-trip (dump_zone_state -> jsonb-safe map -> init_zone_state restore), +%% and the full lifecycle through a real asobi_zone. + +-spec fixture(string()) -> file:filename_all(). +fixture(Name) -> + case code:lib_dir(asobi_lua) of + {error, _} -> error(asobi_lua_not_loaded); + Dir -> filename:absname(filename:join([Dir, "test", "fixtures", "lua", Name])) + end. + +setup() -> + %% asobi_zone is a plain gen_server; it only needs the nova_scope pg group + %% for pg:join. Starting the full asobi application would boot Nova, which + %% isn't configured in this unit-test context. + case whereis(nova_scope) of + undefined -> pg:start_link(nova_scope); + _ -> ok + end, + ok. + +cleanup(_) -> + ok. + +zone_spawn_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"init_zone_state builds a VM from an empty (lazy) zone_state", + fun init_zone_state_builds_vm/0}, + {"game_state round-trips through dump_zone_state/init_zone_state", + fun game_state_round_trips/0}, + {"a never-seeded zone round-trips as nil, not an empty table", + fun unseeded_game_state_round_trips_as_nil/0}, + {"dump_zone_state without a VM stays jsonb-safe", fun dump_without_vm_is_jsonb_safe/0}, + {"game.zone.spawn from Lua reaches a live zone built via handle_continue", + fun lua_spawn_reaches_zone/0} + ]}. + +zone_config() -> + #{ + world_id => ~"spawn_world", + coords => {0, 0}, + game_module => asobi_lua_world, + game_config => #{lua_script => fixture("spawn_world.lua")}, + world_server_pid => self() + }. + +init_zone_state_builds_vm() -> + %% Lazy zones start with no zone_state; init_zone_state must still produce a + %% usable VM bound to the zone pid. + ZoneState = asobi_lua_world:init_zone_state(zone_config(), #{}), + ?assert(maps:is_key(lua_state, ZoneState)), + ?assertEqual(nil, maps:get(game_state, ZoneState)). + +game_state_round_trips() -> + erlang:erase({asobi_lua_world, zone_state}), + ZoneState0 = asobi_lua_world:init_zone_state(zone_config(), #{}), + %% One tick lets the script stamp gameplay state (seeded = true). The spawn + %% casts it emits go to self() here and are harmless. + {_Ents, ZoneState1} = asobi_lua_world:zone_tick(#{}, ZoneState0), + flush_casts(), + + Dumped = asobi_lua_world:dump_zone_state(ZoneState1), + ?assertEqual(#{~"game_state" => #{~"seeded" => true}}, Dumped), + ?assertNot(maps:is_key(lua_state, Dumped)), + %% jsonb-safe: must encode without raising. + _ = json:encode(Dumped), + + %% Restoring re-encodes the gameplay state into a fresh VM. + Restored = asobi_lua_world:init_zone_state(zone_config(), Dumped), + ?assertEqual( + #{~"game_state" => #{~"seeded" => true}}, asobi_lua_world:dump_zone_state(Restored) + ), + erlang:erase({asobi_lua_world, zone_state}). + +lua_spawn_reaches_zone() -> + %% Templates come from the script, exactly as asobi_world_server wires them. + {ok, WorldState} = asobi_lua_world:init(#{lua_script => fixture("spawn_world.lua")}), + Templates = asobi_lua_world:spawn_templates(WorldState), + ?assertMatch(#{~"goblin" := _, ~"chest" := _}, Templates), + + %% Note: NO pre-injected lua_state. The zone builds its own VM in + %% handle_continue, mirroring a lazy zone. + {ok, ZonePid} = asobi_zone:start_link(#{ + world_id => ~"spawn_world", + coords => {0, 0}, + ticker_pid => self(), + game_module => asobi_lua_world, + game_config => #{lua_script => fixture("spawn_world.lua")}, + world_server_pid => self(), + spawn_templates => Templates + }), + + erlang:erase({asobi_lua_world, zone_state}), + gen_server:cast(ZonePid, {tick, 1}), + %% First call flushes the tick (which enqueues the spawn casts); the second + %% flushes those spawn casts so the entities are present. + _ = asobi_zone:get_entities(ZonePid), + Entities = asobi_zone:get_entities(ZonePid), + + Goblins = by_type(~"npc", Entities), + Chests = by_type(~"object", Entities), + ?assertEqual(1, length(Goblins)), + ?assertEqual(1, length(Chests)), + + [Goblin] = Goblins, + ?assert(maps:get(~"health", Goblin) == 100), + ?assertEqual(~"patrol", maps:get(~"ai", Goblin)), + + [Chest] = Chests, + ?assertEqual(~"rare", maps:get(~"loot", Chest)), + + gen_server:stop(ZonePid), + erlang:erase({asobi_lua_world, zone_state}). + +unseeded_game_state_round_trips_as_nil() -> + %% A zone snapshotted before its first seeding tick has game_state = nil. + %% It must come back nil (null over jsonb), not an empty table, so the + %% script's `game_state == nil` init guard still fires after recovery. + ZoneState0 = asobi_lua_world:init_zone_state(zone_config(), #{}), + ?assertEqual(nil, maps:get(game_state, ZoneState0)), + Dumped = asobi_lua_world:dump_zone_state(ZoneState0), + ?assertEqual(#{~"game_state" => null}, Dumped), + _ = json:encode(Dumped), + Restored = asobi_lua_world:init_zone_state(zone_config(), Dumped), + ?assertEqual(nil, maps:get(game_state, Restored)). + +dump_without_vm_is_jsonb_safe() -> + %% When init_zone_state fails to build a VM it returns the bare zone_state + %% (no lua_state); dumping that degraded zone must still be jsonb-safe. + Raw = #{~"game_state" => #{~"hp" => 10}, ~"misc" => 1}, + Dumped = asobi_lua_world:dump_zone_state(Raw), + ?assertNot(maps:is_key(lua_state, Dumped)), + _ = json:encode(Dumped), + ?assertEqual(Raw, Dumped). + +by_type(Type, Entities) -> + [E || {_Id, E} <- maps:to_list(Entities), maps:get(type, E, undefined) =:= Type]. + +flush_casts() -> + receive + _ -> flush_casts() + after 0 -> ok + end. diff --git a/test/fixtures/lua/spawn_world.lua b/test/fixtures/lua/spawn_world.lua new file mode 100644 index 0000000..474c736 --- /dev/null +++ b/test/fixtures/lua/spawn_world.lua @@ -0,0 +1,58 @@ +-- World fixture exercising spawn_templates + game.zone.spawn end-to-end. +-- zone_tick seeds the zone once on the first tick; the spawn cast is handled +-- by the live asobi_zone process, so the entity appears in the zone's entity +-- map a tick later. +match_size = 1 +max_players = 8 +game_type = "world" +grid_size = 1 +zone_size = 1200 +view_radius = 0 + +function spawn_templates(config) + return { + goblin = { + type = "npc", + base_state = { health = 100, ai = "patrol" }, + respawn = { delay = 5000, jitter = 1000 }, + }, + chest = { + type = "object", + base_state = { loot = "common" }, + }, + } +end + +function init(config) + return { tick = 0 } +end + +function generate_world(seed, config) + return { ["0,0"] = {} } +end + +function spawn_position(player_id, state) + return { x = 600, y = 600 } +end + +function join(player_id, state) return state end +function leave(player_id, state) return state end + +function zone_tick(entities, zone_state) + zone_state = zone_state or {} + if not zone_state.seeded then + game.zone.spawn("goblin", 500, 500) + game.zone.spawn("chest", 620, 600, { loot = "rare" }) + zone_state.seeded = true + end + return entities, zone_state +end + +function handle_input(player_id, input, entities) + return entities +end + +function post_tick(tick_n, state) + state.tick = tick_n + return state +end diff --git a/test/prop_lua_bridge_input_threading.erl b/test/prop_lua_bridge_input_threading.erl index 77e4bd5..c9ba4e2 100644 --- a/test/prop_lua_bridge_input_threading.erl +++ b/test/prop_lua_bridge_input_threading.erl @@ -62,7 +62,8 @@ run_plan(Plan) -> undefined -> io:format(user, "fixture missing zone (0,0): ~p~n", [ZoneStates]), false; - Z0 -> + Z0Raw -> + Z0 = asobi_lua_world:init_zone_state(fixture_config(), Z0Raw), try exec(Plan, Z0, #{}, #{}, false) after From 95f7bbe40158e717d4639bb92bd7f054e43dac4e Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 29 Jun 2026 17:34:42 +0200 Subject: [PATCH 2/2] chore(deps): pin asobi to per-zone-state callbacks (widgrensit/asobi#131) --- rebar.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rebar.lock b/rebar.lock index e1336a7..b020ba9 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,7 +1,7 @@ {"1.2.0", [{<<"asobi">>, {git,"https://github.com/widgrensit/asobi.git", - {ref,"a6e376a7b6166e95009e0ba9088c9ade2c11471e"}}, + {ref,"0258d582ff8bcbcc6bf4464b5004dd37fcf90bfe"}}, 0}, {<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},3}, {<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.15.0">>},2},