From b8f96abc52229eeb540a97efa7b073de361d579a Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Sun, 12 Apr 2026 20:16:27 +0200 Subject: [PATCH] feat: add spatial queries, zone spawning, and world recovery to Lua API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the three new asobi features to Lua scripts: - game.spatial.query_radius/nearest/in_range/distance — spatial queries on entity tables with automatic binary-to-atom key conversion - game.zone.spawn/despawn — template-based entity spawning (world mode) - spawn_templates(config) callback — define spawn templates from Lua - on_world_recovered(snapshots, state) callback — handle snapshot recovery --- rebar.lock | 6 +- src/lua/asobi_lua_api.erl | 191 ++++++++++++++++++++++++++++++++++- src/lua/asobi_lua_world.erl | 77 ++++++++++++++ test/asobi_lua_api_tests.erl | 76 +++++++++++++- 4 files changed, 344 insertions(+), 6 deletions(-) diff --git a/rebar.lock b/rebar.lock index 35d03fb..c5bd80f 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,5 +1,9 @@ {"1.2.0", -[{<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},3}, +[{<<"asobi">>, + {git,"https://github.com/widgrensit/asobi.git", + {ref,"67e571c3ed10173b2270841b64d19715e387d187"}}, + 0}, + {<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},3}, {<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},2}, {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},3}, {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},2}, diff --git a/src/lua/asobi_lua_api.erl b/src/lua/asobi_lua_api.erl index e65209e..1ec0d7c 100644 --- a/src/lua/asobi_lua_api.erl +++ b/src/lua/asobi_lua_api.erl @@ -2,7 +2,7 @@ -moduledoc """ Installs the `game.*` Lua API into a Luerl state, giving Lua scripts access to engine features like economy, leaderboards, notifications, -storage, and messaging. +storage, messaging, spatial queries, and zone spawning. Called from `asobi_lua_match:init/1` and `asobi_lua_world:init/1` before the Lua script's `init()` runs. @@ -41,6 +41,19 @@ game.storage.player_set(player_id, collection, key, value) -- Chat game.chat.send(channel_id, sender_id, content) + +-- Spatial queries (operate on entity tables) +game.spatial.query_radius(entities, x, y, radius) +game.spatial.query_radius(entities, x, y, radius, opts) +game.spatial.nearest(entities, x, y, n) +game.spatial.nearest(entities, x, y, n, opts) +game.spatial.in_range(entity_a, entity_b, range) +game.spatial.distance(entity_a, entity_b) + +-- Zone spawning (world mode only, requires zone_pid in context) +game.zone.spawn(template_id, x, y) +game.zone.spawn(template_id, x, y, overrides) +game.zone.despawn(entity_id) ``` """. @@ -53,7 +66,9 @@ install(Ctx, St0) -> St2 = create_table([~"game", ~"economy"], St1), St3 = create_table([~"game", ~"leaderboard"], St2), St4 = create_table([~"game", ~"storage"], St3), - St5 = create_table([~"game", ~"chat"], St4), + St5a = create_table([~"game", ~"chat"], St4), + St5b = create_table([~"game", ~"spatial"], St5a), + St5 = create_table([~"game", ~"zone"], St5b), Fns = [ %% Core {[~"game", ~"id"], fun_id()}, @@ -78,7 +93,15 @@ install(Ctx, St0) -> {[~"game", ~"storage", ~"player_get"], fun_storage_player_get()}, {[~"game", ~"storage", ~"player_set"], fun_storage_player_set()}, %% Chat - {[~"game", ~"chat", ~"send"], fun_chat_send()} + {[~"game", ~"chat", ~"send"], fun_chat_send()}, + %% Spatial + {[~"game", ~"spatial", ~"query_radius"], fun_spatial_query_radius()}, + {[~"game", ~"spatial", ~"nearest"], fun_spatial_nearest()}, + {[~"game", ~"spatial", ~"in_range"], fun_spatial_in_range()}, + {[~"game", ~"spatial", ~"distance"], fun_spatial_distance()}, + %% Zone spawning + {[~"game", ~"zone", ~"spawn"], fun_zone_spawn(Ctx)}, + {[~"game", ~"zone", ~"despawn"], fun_zone_despawn(Ctx)} ], lists:foldl( fun({Path, Fn}, St) -> @@ -362,6 +385,136 @@ fun_chat_send() -> end end. +%% --- Spatial --- + +fun_spatial_query_radius() -> + fun(Args, St) -> + case decode_args(Args, St) of + [Entities, X, Y, Radius] when + is_map(Entities), is_number(X), is_number(Y), is_number(Radius) + -> + Results = asobi_spatial:query_radius(atomize_entities(Entities), {X, Y}, Radius), + encode_spatial_results(Results, St); + [Entities, X, Y, Radius, OptsRaw] when + is_map(Entities), is_number(X), is_number(Y), is_number(Radius) + -> + Opts = decode_spatial_opts(OptsRaw), + Results = asobi_spatial:query_radius( + atomize_entities(Entities), {X, Y}, Radius, Opts + ), + encode_spatial_results(Results, St); + _ -> + error_result(~"query_radius requires (entities, x, y, radius[, opts])", St) + end + end. + +fun_spatial_nearest() -> + fun(Args, St) -> + case decode_args(Args, St) of + [Entities, X, Y, N] when is_map(Entities), is_number(X), is_number(Y), is_number(N) -> + Results = asobi_spatial:nearest(atomize_entities(Entities), {X, Y}, trunc(N)), + encode_spatial_results(Results, St); + [Entities, X, Y, N, OptsRaw] when + is_map(Entities), is_number(X), is_number(Y), is_number(N) + -> + Opts = decode_spatial_opts(OptsRaw), + Results = asobi_spatial:nearest(atomize_entities(Entities), {X, Y}, trunc(N), Opts), + encode_spatial_results(Results, St); + _ -> + error_result(~"nearest requires (entities, x, y, n[, opts])", St) + end + end. + +fun_spatial_in_range() -> + fun(Args, St) -> + case decode_args(Args, St) of + [A, B, Range] when is_map(A), is_map(B), is_number(Range) -> + Result = asobi_spatial:in_range(atomize_keys(A), atomize_keys(B), Range), + {[Result], St}; + _ -> + error_result(~"in_range requires (entity_a, entity_b, range)", St) + end + end. + +fun_spatial_distance() -> + fun(Args, St) -> + case decode_args(Args, St) of + [A, B] when is_map(A), is_map(B) -> + D = asobi_spatial:distance(atomize_keys(A), atomize_keys(B)), + {[D], St}; + _ -> + error_result(~"distance requires (entity_a, entity_b)", St) + end + end. + +encode_spatial_results(Results, St) -> + Encoded = [ + #{~"id" => Id, ~"entity" => Entity, ~"distance" => Dist} + || {Id, Entity, Dist} <- Results + ], + {Enc, St1} = luerl:encode(Encoded, St), + {[Enc], St1}. + +decode_spatial_opts(OptsRaw) when is_map(OptsRaw) -> + Opts0 = #{}, + Opts1 = + case maps:find(~"type", OptsRaw) of + {ok, T} when is_binary(T) -> Opts0#{type => T}; + {ok, T} when is_list(T) -> Opts0#{type => [B || B <- T, is_binary(B)]}; + _ -> Opts0 + end, + Opts2 = + case maps:find(~"exclude", OptsRaw) of + {ok, E} when is_binary(E) -> Opts1#{exclude => E}; + {ok, E} when is_list(E) -> Opts1#{exclude => [B || B <- E, is_binary(B)]}; + _ -> Opts1 + end, + Opts3 = + case maps:find(~"max_results", OptsRaw) of + {ok, N} when is_number(N) -> Opts2#{max_results => trunc(N)}; + _ -> Opts2 + end, + case maps:find(~"sort", OptsRaw) of + {ok, ~"nearest"} -> Opts3#{sort => nearest}; + {ok, ~"farthest"} -> Opts3#{sort => farthest}; + _ -> Opts3 + end; +decode_spatial_opts(_) -> + #{}. + +%% --- Zone spawning --- + +fun_zone_spawn(#{zone_pid := ZonePid}) -> + fun(Args, St) -> + case decode_args(Args, St) of + [TemplateId, X, Y] when is_binary(TemplateId), is_number(X), is_number(Y) -> + asobi_zone:spawn_entity(ZonePid, TemplateId, {X, Y}), + {[true], St}; + [TemplateId, X, Y, Overrides] when + is_binary(TemplateId), is_number(X), is_number(Y), is_map(Overrides) + -> + asobi_zone:spawn_entity(ZonePid, TemplateId, {X, Y}, Overrides), + {[true], St}; + _ -> + error_result(~"zone.spawn requires (template_id, x, y[, overrides])", St) + end + end; +fun_zone_spawn(_) -> + fun(_, St) -> error_result(~"zone.spawn not available (no zone context)", St) end. + +fun_zone_despawn(#{zone_pid := ZonePid}) -> + fun(Args, St) -> + case decode_args(Args, St) of + [EntityId] when is_binary(EntityId) -> + asobi_zone:despawn_entity(ZonePid, EntityId), + {[true], St}; + _ -> + error_result(~"zone.despawn requires (entity_id)", St) + end + end; +fun_zone_despawn(_) -> + fun(_, St) -> error_result(~"zone.despawn not available (no zone context)", St) end. + %% --- Storage helpers --- -spec storage_get(binary(), binary(), binary() | undefined) -> {ok, map()} | {error, term()}. @@ -493,6 +646,38 @@ to_bin(B) when is_binary(B) -> B; to_bin(A) when is_atom(A) -> atom_to_binary(A); to_bin(T) -> list_to_binary(io_lib:format("~p", [T])). +%% --- Entity key conversion --- +%% Lua tables use binary keys ("x"), asobi_spatial expects atom keys (x). + +atomize_entities(Entities) -> + maps:map( + fun + (_Id, E) when is_map(E) -> atomize_keys(E); + (_, V) -> V + end, + Entities + ). + +atomize_keys(M) when is_map(M) -> + maps:fold( + fun(K, V, Acc) -> + Key = safe_to_atom(K), + Acc#{Key => V} + end, + #{}, + M + ). + +safe_to_atom(B) when is_binary(B) -> + try + binary_to_existing_atom(B) + catch + _:_ -> B + end; +safe_to_atom(A) when is_atom(A) -> A; +safe_to_atom(V) -> + V. + -spec create_table([binary()], dynamic()) -> dynamic(). create_table(Path, St) -> {Tab, St1} = luerl:encode(#{}, St), diff --git a/src/lua/asobi_lua_world.erl b/src/lua/asobi_lua_world.erl index d952229..8bc0d26 100644 --- a/src/lua/asobi_lua_world.erl +++ b/src/lua/asobi_lua_world.erl @@ -20,6 +20,8 @@ function vote_resolved(template, result, state) -- return updated state function phases(config) -- return list of phase definitions function on_phase_started(phase_name, state) -- return updated state function on_phase_ended(phase_name, state) -- return updated state +function spawn_templates(config) -- return template registry table +function on_world_recovered(snapshots, state) -- return updated state ``` """. @@ -29,6 +31,7 @@ function on_phase_ended(phase_name, state) -- return updated state -export([zone_tick/2, handle_input/3, post_tick/2]). -export([generate_world/2, get_state/2]). -export([phases/1, on_phase_started/2, on_phase_ended/2]). +-export([spawn_templates/1, on_world_recovered/2]). -define(TICK_TIMEOUT, 500). @@ -187,6 +190,31 @@ on_phase_ended(PhaseName, #{lua_state := LuaSt, game_state := GS} = State) -> {ok, State} end. +%% --- Spawn templates --- + +-spec spawn_templates(map()) -> #{binary() => asobi_zone_spawner:spawn_template()}. +spawn_templates(#{lua_state := LuaSt} = _Config) -> + case asobi_lua_loader:call(spawn_templates, [#{}], LuaSt) of + {ok, [TemplatesRef | _], LuaSt1} -> + decode_spawn_templates(TemplatesRef, LuaSt1); + {error, _} -> + #{} + end; +spawn_templates(_) -> + #{}. + +%% --- World recovery --- + +-spec on_world_recovered(map(), map()) -> {ok, map()}. +on_world_recovered(Snapshots, #{lua_state := LuaSt, game_state := GS} = State) -> + {EncSnap, LuaSt1} = luerl:encode(Snapshots, LuaSt), + case asobi_lua_loader:call(on_world_recovered, [EncSnap, GS], LuaSt1) of + {ok, [GS1 | _], LuaSt2} -> + {ok, State#{lua_state => LuaSt2, game_state => GS1}}; + {error, _} -> + {ok, State} + end. + %% --- Internal --- decode_position(PosTable, LuaSt) -> @@ -321,3 +349,52 @@ to_number(_) -> 0.0. to_integer(N) when is_number(N) -> trunc(N); to_integer(_) -> 0. + +decode_spawn_templates(TemplatesRef, LuaSt) -> + Decoded = luerl:decode(TemplatesRef, LuaSt), + lists:foldl( + fun + ({TemplateId, Props}, Acc) when is_binary(TemplateId), is_list(Props) -> + Type = proplists:get_value(~"type", Props, ~"npc"), + BaseState = deep_decode(proplists:get_value(~"base_state", Props, [])), + Base = + case is_map(BaseState) of + true -> BaseState; + false -> #{} + end, + Template = #{ + template_id => TemplateId, + type => Type, + base_state => Base, + persistent => proplists:get_value(~"persistent", Props, true), + respawn => decode_respawn_rule( + proplists:get_value(~"respawn", Props, nil) + ) + }, + Acc#{TemplateId => Template}; + (_, Acc) -> + Acc + end, + #{}, + Decoded + ). + +decode_respawn_rule(nil) -> + undefined; +decode_respawn_rule(false) -> + undefined; +decode_respawn_rule(Props) when is_list(Props) -> + #{ + strategy => timer, + delay => to_integer(proplists:get_value(~"delay", Props, 0)), + max_respawns => decode_max_respawns( + proplists:get_value(~"max_respawns", Props, nil) + ), + jitter => to_integer(proplists:get_value(~"jitter", Props, 0)) + }; +decode_respawn_rule(_) -> + undefined. + +decode_max_respawns(nil) -> infinity; +decode_max_respawns(N) when is_number(N) -> trunc(N); +decode_max_respawns(_) -> infinity. diff --git a/test/asobi_lua_api_tests.erl b/test/asobi_lua_api_tests.erl index 7db5edf..be9b48f 100644 --- a/test/asobi_lua_api_tests.erl +++ b/test/asobi_lua_api_tests.erl @@ -26,7 +26,13 @@ api_test_() -> {"game.notify sends notification", fun game_notify/0}, {"game.chat.send sends message", fun game_chat_send/0}, {"api installed in match init", fun api_in_match_init/0}, - {"game api callable from lua script", fun game_api_from_script/0} + {"game api callable from lua script", fun game_api_from_script/0}, + {"game.spatial.query_radius returns results", fun spatial_query_radius/0}, + {"game.spatial.nearest returns closest", fun spatial_nearest/0}, + {"game.spatial.in_range checks distance", fun spatial_in_range/0}, + {"game.spatial.distance returns distance", fun spatial_distance/0}, + {"game.zone.spawn calls zone", fun zone_spawn/0}, + {"game.zone.despawn calls zone", fun zone_despawn/0} ]}. setup() -> @@ -68,6 +74,10 @@ setup() -> meck:expect(asobi_repo, update_all, fun(_, _) -> {ok, 1} end), meck:new(asobi_chat_channel, [no_link]), meck:expect(asobi_chat_channel, send_message, fun(_, _, _) -> ok end), + meck:new(asobi_zone, [no_link]), + meck:expect(asobi_zone, spawn_entity, fun(_, _, _) -> ok end), + meck:expect(asobi_zone, spawn_entity, fun(_, _, _, _) -> ok end), + meck:expect(asobi_zone, despawn_entity, fun(_, _) -> ok end), ok. cleanup(_) -> @@ -79,7 +89,8 @@ cleanup(_) -> asobi_leaderboard_server, asobi_notify, asobi_repo, - asobi_chat_channel + asobi_chat_channel, + asobi_zone ]). %% --- Test cases --- @@ -145,6 +156,62 @@ game_api_from_script() -> ?assertEqual(~"test-uuid-v7", Id), ?assert(meck:called(asobi_leaderboard_server, submit, [~"test", ~"p1", 99])). +spatial_query_radius() -> + St = install_api(), + Code = + "local entities = {\n" + " a = { x = 0.0, y = 0.0, type = 'npc' },\n" + " b = { x = 3.0, y = 4.0, type = 'npc' },\n" + " c = { x = 100.0, y = 100.0, type = 'npc' }\n" + "}\n" + "local results = game.spatial.query_radius(entities, 0.0, 0.0, 6.0)\n" + "local count = 0\n" + "for _ in pairs(results) do count = count + 1 end\n" + "return count", + {ok, [Count | _], _} = eval(Code, St), + ?assertEqual(2, trunc(Count)). + +spatial_nearest() -> + St = install_api(), + Code = + "local entities = {\n" + " a = { x = 10.0, y = 10.0, type = 'npc' },\n" + " b = { x = 1.0, y = 1.0, type = 'npc' }\n" + "}\n" + "local results = game.spatial.nearest(entities, 0.0, 0.0, 1)\n" + "return results[1].id", + {ok, [Id | _], _} = eval(Code, St), + ?assertEqual(~"b", Id). + +spatial_in_range() -> + St = install_api(), + Code = + "local a = { x = 0.0, y = 0.0 }\n" + "local b = { x = 3.0, y = 4.0 }\n" + "return game.spatial.in_range(a, b, 5.0)", + {ok, [true | _], _} = eval(Code, St). + +spatial_distance() -> + St = install_api(), + Code = + "local a = { x = 0.0, y = 0.0 }\n" + "local b = { x = 3.0, y = 4.0 }\n" + "return game.spatial.distance(a, b)", + {ok, [D | _], _} = eval(Code, St), + ?assert(abs(D - 5.0) < 0.001). + +zone_spawn() -> + St = install_api_with_zone(), + Code = "return game.zone.spawn('goblin', 10.0, 20.0)", + {ok, [true | _], _} = eval(Code, St), + ?assert(meck:called(asobi_zone, spawn_entity, '_')). + +zone_despawn() -> + St = install_api_with_zone(), + Code = "return game.zone.despawn('entity-123')", + {ok, [true | _], _} = eval(Code, St), + ?assert(meck:called(asobi_zone, despawn_entity, '_')). + %% --- Helpers --- install_api() -> @@ -152,6 +219,11 @@ install_api() -> Ctx = #{match_id => ~"test-match", match_pid => self()}, asobi_lua_api:install(Ctx, St0). +install_api_with_zone() -> + {ok, St0} = asobi_lua_loader:new(fixture("test_match.lua")), + Ctx = #{match_id => ~"test-match", match_pid => self(), zone_pid => self()}, + asobi_lua_api:install(Ctx, St0). + -spec eval(string(), dynamic()) -> {ok, [term()], dynamic()} | {error, term()}. eval(Code, St) -> case luerl:do(Code, St) of