From fb04a7d042e204603690db46aa68777c318940ee Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 5 May 2026 17:28:16 +0200 Subject: [PATCH 1/2] feat(lua): asobi_lua_match_shared bridge for encode-once match broadcast Companion to asobi#117. Adds a thin bridge module that exports get_state/1 (shared-payload variant) and delegates everything else to asobi_lua_match. Selected by declaring `state_strategy = "shared"` in match.lua, which asobi_lua_config now reads and propagates to the mode config so asobi_game_modes resolves to this bridge. Lua scripts opting in must define one-arg `get_state(state)` returning the world view. The match server then JSON-encodes once per tick and broadcasts the same binary to every player (see asobi#117 for the broadcast-side change). Pin bump pending the upstream merge. --- src/asobi_lua_config.erl | 24 +++++++++--- src/lua/asobi_lua_match_shared.erl | 49 +++++++++++++++++++++++++ test/asobi_lua_config_tests.erl | 44 ++++++++++++++++++++++ test/asobi_lua_match_shared_tests.erl | 44 ++++++++++++++++++++++ test/fixtures/lua/test_match_shared.lua | 38 +++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/lua/asobi_lua_match_shared.erl create mode 100644 test/asobi_lua_match_shared_tests.erl create mode 100644 test/fixtures/lua/test_match_shared.lua diff --git a/src/asobi_lua_config.erl b/src/asobi_lua_config.erl index 04476f0..3634db2 100644 --- a/src/asobi_lua_config.erl +++ b/src/asobi_lua_config.erl @@ -26,11 +26,12 @@ configure via `sys.config` are unaffected). ## Match script globals ```lua -match_size = 4 -- required, positive integer -max_players = 10 -- optional, defaults to match_size -strategy = "fill" -- optional, "fill" | "skill_based" -bots = { script = "bots/ai.lua" } -- optional -game_type = "world" -- optional, "match" (default) or "world" +match_size = 4 -- required, positive integer +max_players = 10 -- optional, defaults to match_size +strategy = "fill" -- optional, "fill" | "skill_based" +bots = { script = "bots/ai.lua" } -- optional +game_type = "world" -- optional, "match" (default) or "world" +state_strategy = "shared" -- optional, "shared" picks asobi_lua_match_shared (encode-once broadcast) -- World mode config (large session games, game_type = "world"): tick_rate = 50 -- optional, ms per world tick (default 50 = 20 Hz) @@ -146,6 +147,7 @@ read_match_globals(ScriptPath, St) -> Strategy = read_global_string(~"strategy", St), Bots = read_global_table(~"bots", St), GameType = read_global_string(~"game_type", St), + StateStrategy = read_global_string(~"state_strategy", St), TickRate = read_global_int(~"tick_rate", St), GridSize = read_global_int(~"grid_size", St), ZoneSize = read_global_int(~"zone_size", St), @@ -173,7 +175,8 @@ read_match_globals(ScriptPath, St) -> }, Config1 = maybe_add_game_type(Config0, GameType), Config2 = maybe_add_strategy(Config1, Strategy), - Config3 = maybe_add_bots(Config2, Bots, ScriptPath), + Config2a = maybe_add_state_strategy(Config2, StateStrategy), + Config3 = maybe_add_bots(Config2a, Bots, ScriptPath), Config4 = maybe_add_zone_config(Config3, LazyZones, ZoneIdleTimeout, MaxActiveZones), Config5 = maybe_add_int(Config4, spatial_grid_cell_size, SpatialGridCellSize), Config6 = maybe_add_int(Config5, cold_tick_divisor, ColdTickDivisor), @@ -203,6 +206,15 @@ maybe_add_strategy(Config, Strategy) -> Other -> Config#{strategy => Other} end. +%% A shared `get_state(state)` payload is broadcast pre-encoded once per +%% tick instead of re-encoded per player. Set `state_strategy = "shared"` +%% in the match script when every player sees the same world (the +%% common case for action games / shared-arena modes). +maybe_add_state_strategy(Config, ~"shared") -> + Config#{state_strategy => shared}; +maybe_add_state_strategy(Config, _) -> + Config. + maybe_add_zone_config(Config, LazyZones, ZoneIdleTimeout, MaxActiveZones) -> Config1 = case LazyZones of diff --git a/src/lua/asobi_lua_match_shared.erl b/src/lua/asobi_lua_match_shared.erl new file mode 100644 index 0000000..d2b7a42 --- /dev/null +++ b/src/lua/asobi_lua_match_shared.erl @@ -0,0 +1,49 @@ +-module(asobi_lua_match_shared). +-moduledoc """ +Variant of `asobi_lua_match` for matches where every player sees the same +world state. The match server calls `get_state/1` once per tick and +broadcasts a single pre-encoded payload to every subscriber, instead of +re-encoding once per player. + +Selected by declaring `state_strategy = "shared"` in the match script's +config globals. The Lua script must define `get_state(state)` (one +argument). All other callbacks are identical to `asobi_lua_match` and are +delegated to it directly. +""". + +-behaviour(asobi_match). + +-export([init/1, join/2, leave/2, handle_input/3, tick/1, get_state/1]). +-export([vote_requested/1, vote_resolved/3]). + +-define(GET_STATE_TIMEOUT, 100). + +-spec init(map()) -> {ok, map()}. +init(Config) -> asobi_lua_match:init(Config). + +-spec join(binary(), map()) -> {ok, map()} | {error, term()}. +join(PlayerId, State) -> asobi_lua_match:join(PlayerId, State). + +-spec leave(binary(), map()) -> {ok, map()}. +leave(PlayerId, State) -> asobi_lua_match:leave(PlayerId, State). + +-spec handle_input(binary(), map(), map()) -> {ok, map()}. +handle_input(PlayerId, Input, State) -> asobi_lua_match:handle_input(PlayerId, Input, State). + +-spec tick(map()) -> {ok, map()} | {finished, map(), map()}. +tick(State) -> asobi_lua_match:tick(State). + +-spec get_state(map()) -> map(). +get_state(#{lua_state := LuaSt, game_state := GS}) -> + case asobi_lua_loader:call(get_state, [GS], LuaSt, ?GET_STATE_TIMEOUT) of + {ok, [SharedState | _], LuaSt1} -> + asobi_lua_api:decode_to_map(SharedState, LuaSt1); + {error, _} -> + #{} + end. + +-spec vote_requested(map()) -> {ok, map()} | none. +vote_requested(State) -> asobi_lua_match:vote_requested(State). + +-spec vote_resolved(binary(), map(), map()) -> {ok, map()}. +vote_resolved(Template, Result, State) -> asobi_lua_match:vote_resolved(Template, Result, State). diff --git a/test/asobi_lua_config_tests.erl b/test/asobi_lua_config_tests.erl index 175ce1c..55ef189 100644 --- a/test/asobi_lua_config_tests.erl +++ b/test/asobi_lua_config_tests.erl @@ -51,6 +51,10 @@ config_test_() -> {"match_size float is truncated then rejected", fun match_size_float_rejected/0}, {"unknown strategy is preserved as-is", fun unknown_strategy_preserved/0}, {"strategy = skill_based is recognised", fun strategy_skill_based/0}, + {"state_strategy = shared resolves to asobi_lua_match_shared", + fun state_strategy_shared/0}, + {"state_strategy absent resolves to asobi_lua_match", fun state_strategy_absent/0}, + {"state_strategy = unknown is ignored", fun state_strategy_unknown/0}, {"config.lua returning non-table errors", fun config_returns_non_table/0}, {"config.lua referencing missing match script errors", fun config_missing_match_script/0}, @@ -298,6 +302,46 @@ strategy_skill_based() -> ?assertEqual(skill_based, maps:get(strategy, Mode)), cleanup_temp_dir(TmpDir). +state_strategy_shared() -> + TmpDir = make_temp_dir(), + ok = file:write_file( + filename:join(TmpDir, "match.lua"), + ~"match_size = 2\nstate_strategy = 'shared'\n" + ), + application:set_env(asobi, game_dir, TmpDir), + ok = asobi_lua_config:maybe_load_game_config(), + Mode = maps:get(~"default", get_game_modes()), + ?assertEqual(shared, maps:get(state_strategy, Mode)), + {ok, GameMod, _} = asobi_game_modes:resolve_game_module(~"default"), + ?assertEqual(asobi_lua_match_shared, GameMod), + cleanup_temp_dir(TmpDir). + +state_strategy_absent() -> + TmpDir = make_temp_dir(), + ok = file:write_file( + filename:join(TmpDir, "match.lua"), + ~"match_size = 2\n" + ), + application:set_env(asobi, game_dir, TmpDir), + ok = asobi_lua_config:maybe_load_game_config(), + Mode = maps:get(~"default", get_game_modes()), + ?assertNot(maps:is_key(state_strategy, Mode)), + {ok, GameMod, _} = asobi_game_modes:resolve_game_module(~"default"), + ?assertEqual(asobi_lua_match, GameMod), + cleanup_temp_dir(TmpDir). + +state_strategy_unknown() -> + TmpDir = make_temp_dir(), + ok = file:write_file( + filename:join(TmpDir, "match.lua"), + ~"match_size = 2\nstate_strategy = 'totally_made_up'\n" + ), + application:set_env(asobi, game_dir, TmpDir), + ok = asobi_lua_config:maybe_load_game_config(), + Mode = maps:get(~"default", get_game_modes()), + ?assertNot(maps:is_key(state_strategy, Mode)), + cleanup_temp_dir(TmpDir). + config_returns_non_table() -> TmpDir = make_temp_dir(), ok = file:write_file(filename:join(TmpDir, "config.lua"), ~"return 42\n"), diff --git a/test/asobi_lua_match_shared_tests.erl b/test/asobi_lua_match_shared_tests.erl new file mode 100644 index 0000000..1bfbe97 --- /dev/null +++ b/test/asobi_lua_match_shared_tests.erl @@ -0,0 +1,44 @@ +-module(asobi_lua_match_shared_tests). +-include_lib("eunit/include/eunit.hrl"). + +-spec fixture(string()) -> file:filename_all(). +fixture(Name) -> + {ok, LibDir} = safe_lib_dir(), + filename:join([LibDir, "test", "fixtures", "lua", Name]). + +-spec safe_lib_dir() -> {ok, string()}. +safe_lib_dir() -> + case code:lib_dir(asobi_lua) of + {error, bad_name} -> error(asobi_lua_not_loaded); + Dir -> {ok, Dir} + end. + +shared_match_test_() -> + [ + {"init delegates to asobi_lua_match", fun init_ok/0}, + {"join/leave/handle_input/tick still work", fun lifecycle/0}, + {"get_state/1 returns Lua's get_state(state) result", fun get_state_one_arg/0} + ]. + +init_ok() -> + Config = #{lua_script => fixture("test_match_shared.lua")}, + {ok, State} = asobi_lua_match_shared:init(Config), + ?assertMatch(#{lua_state := _, game_state := _}, State). + +lifecycle() -> + Config = #{lua_script => fixture("test_match_shared.lua")}, + {ok, S0} = asobi_lua_match_shared:init(Config), + {ok, S1} = asobi_lua_match_shared:join(~"p1", S0), + {ok, S2} = asobi_lua_match_shared:handle_input(~"p1", #{~"action" => ~"noop"}, S1), + {ok, S3} = asobi_lua_match_shared:tick(S2), + {ok, _S4} = asobi_lua_match_shared:leave(~"p1", S3). + +get_state_one_arg() -> + Config = #{lua_script => fixture("test_match_shared.lua")}, + {ok, S0} = asobi_lua_match_shared:init(Config), + {ok, S1} = asobi_lua_match_shared:join(~"p1", S0), + {ok, S2} = asobi_lua_match_shared:tick(S1), + Shared = asobi_lua_match_shared:get_state(S2), + %% Shared payload includes tick count + world data, NOT player_id-keyed + %% per-player data. + ?assertMatch(#{~"tick" := _, ~"world" := _}, Shared). diff --git a/test/fixtures/lua/test_match_shared.lua b/test/fixtures/lua/test_match_shared.lua new file mode 100644 index 0000000..80f3ebc --- /dev/null +++ b/test/fixtures/lua/test_match_shared.lua @@ -0,0 +1,38 @@ +-- Match script that exposes get_state(state) for the shared-state bridge. + +state_strategy = "shared" + +function init(config) + return { + players = {}, + tick_count = 0, + world = { ticks = 0 } + } +end + +function join(player_id, state) + state.players[player_id] = { x = 0, y = 0 } + return state +end + +function leave(player_id, state) + state.players[player_id] = nil + return state +end + +function handle_input(_player_id, _input, state) + return state +end + +function tick(state) + state.tick_count = state.tick_count + 1 + state.world.ticks = state.tick_count + return state +end + +function get_state(state) + return { + tick = state.tick_count, + world = state.world + } +end From 3ae039a69c0557b73d2bf5219a28300a1e6ffd27 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 5 May 2026 17:40:58 +0200 Subject: [PATCH 2/2] chore: bump asobi pin to encode-once broadcast (asobi#117) --- rebar.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rebar.lock b/rebar.lock index c538ced..65926f2 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,7 +1,7 @@ {"1.2.0", [{<<"asobi">>, {git,"https://github.com/widgrensit/asobi.git", - {ref,"1df9de715fcbb1f7e8821288b54d8f43571cdbdd"}}, + {ref,"032e0ecf82226d8d4a32caf2a6dd1570ef883798"}}, 0}, {<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},3}, {<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},2},