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,"1df9de715fcbb1f7e8821288b54d8f43571cdbdd"}},
{ref,"032e0ecf82226d8d4a32caf2a6dd1570ef883798"}},
0},
{<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},3},
{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},2},
Expand Down
24 changes: 18 additions & 6 deletions src/asobi_lua_config.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/lua/asobi_lua_match_shared.erl
Original file line number Diff line number Diff line change
@@ -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).
44 changes: 44 additions & 0 deletions test/asobi_lua_config_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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"),
Expand Down
44 changes: 44 additions & 0 deletions test/asobi_lua_match_shared_tests.erl
Original file line number Diff line number Diff line change
@@ -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).
38 changes: 38 additions & 0 deletions test/fixtures/lua/test_match_shared.lua
Original file line number Diff line number Diff line change
@@ -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
Loading