Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rebar.lock
Original file line number Diff line number Diff line change
@@ -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},
Expand Down
105 changes: 77 additions & 28 deletions src/lua/asobi_lua_world.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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 =>
Expand All @@ -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
Expand Down Expand Up @@ -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) ->
#{
Expand Down
2 changes: 1 addition & 1 deletion test/asobi_lua_resource_limits_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 11 additions & 8 deletions test/asobi_lua_world_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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 => #{}},
Expand All @@ -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}),
Expand Down Expand Up @@ -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)),

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),

Expand Down Expand Up @@ -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),
Expand Down
149 changes: 149 additions & 0 deletions test/asobi_lua_zone_spawn_tests.erl
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading