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
50 changes: 46 additions & 4 deletions src/lua/asobi_lua_api.erl
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ game.terrain.preload(coords_list) -- preload chunks async

-export([install/2]).
-export([deep_decode/1, decode_to_map/2]).
-export([to_storage_value/1]).

-spec install(map(), dynamic()) -> dynamic().
install(Ctx, St0) ->
Expand Down Expand Up @@ -354,7 +355,7 @@ fun_storage_set() ->
fun(Args, St) ->
case decode_args(Args, St) of
[Collection, Key, Value] when is_binary(Collection), is_binary(Key) ->
wrap_result(storage_set(Collection, Key, undefined, to_map(Value)), St);
wrap_result(storage_set(Collection, Key, undefined, to_storage_value(Value)), St);
_ ->
error_result(~"set requires (collection, key, value)", St)
end
Expand All @@ -378,7 +379,7 @@ fun_storage_player_set() ->
[PlayerId, Collection, Key, Value] when
is_binary(PlayerId), is_binary(Collection), is_binary(Key)
->
wrap_result(storage_set(Collection, Key, PlayerId, to_map(Value)), St);
wrap_result(storage_set(Collection, Key, PlayerId, to_storage_value(Value)), St);
_ ->
error_result(~"player_set requires (player_id, collection, key, value)", St)
end
Expand Down Expand Up @@ -612,7 +613,8 @@ fun_terrain_preload(_) ->

%% --- Storage helpers ---

-spec storage_get(binary(), binary(), binary() | undefined) -> {ok, map()} | {error, term()}.
-spec storage_get(binary(), binary(), binary() | undefined) ->
{ok, term()} | {error, term()}.
storage_get(Collection, Key, PlayerId) ->
Q0 = kura_query:from(asobi_storage),
Q1 = kura_query:where(Q0, {collection, Collection}),
Expand All @@ -624,7 +626,8 @@ storage_get(Collection, Key, PlayerId) ->
{error, _} = Err -> Err
end.

-spec storage_set(binary(), binary(), binary() | undefined, map()) -> {ok, map()} | {error, term()}.
-spec storage_set(binary(), binary(), binary() | undefined, term()) ->
{ok, term()} | {error, term()}.
storage_set(Collection, Key, PlayerId, Value) ->
case storage_get(Collection, Key, PlayerId) of
{ok, _} ->
Expand Down Expand Up @@ -785,6 +788,45 @@ to_map_acc([{K, V} | T], Acc) when is_binary(K) ->
to_map_acc([_ | T], Acc) ->
to_map_acc(T, Acc).

%% Coerce a Luerl-decoded value into a shape `asobi_storage`'s jsonb column
%% can round-trip. Unlike `to_map/1` (which silently drops anything that
%% isn't a map or string-keyed proplist), this preserves the JSON-compatible
%% leaf types — numbers, binaries, booleans, nil, and array-shaped tables.
%% Game scripts can therefore use plain scalars as storage values:
%%
%% game.storage.player_set(pid, "barrow", "run_loot", 5)
%%
%% rather than having to wrap them as `{ n = 5 }` to avoid silent data loss.
%% Maps and nested tables recurse so e.g. `{ position = { x = 1, y = 2 } }`
%% round-trips intact.
-spec to_storage_value(term()) -> term().
to_storage_value(V) when is_number(V); is_boolean(V); is_binary(V) -> V;
to_storage_value(nil) ->
nil;
to_storage_value(V) when is_map(V) ->
maps:map(fun(_K, Val) -> to_storage_value(Val) end, V);
to_storage_value([{K, _} | _] = L) when is_binary(K) ->
%% String-keyed proplist — Luerl's representation of `{ k = v }`.
maps:from_list([{Key, to_storage_value(Val)} || {Key, Val} <- L]);
to_storage_value([]) ->
%% Empty Lua tables are ambiguous (could be either array or map). Round
%% to an empty map for parity with `to_map/1`'s prior behaviour. This
%% clause must come before the generic `is_list/1` clause below.
#{};
to_storage_value([{K, _} | _] = L) when is_integer(K) ->
%% Integer-keyed proplist — Luerl's representation of `{ a, b, c }`
%% when handed an undecoded table.
[to_storage_value(Val) || {_N, Val} <- lists:sort(L)];
to_storage_value(L) when is_list(L) ->
%% Plain list — `deep_decode/1` already flattens an integer-keyed
%% proplist into this shape, so callers passing a deep-decoded
%% value land here for Lua arrays.
[to_storage_value(V) || V <- L];
to_storage_value(_) ->
%% Unsupported (Luerl function refs etc.) — surface as `nil` rather
%% than silently masking with an empty map.
nil.

-spec ensure_pairs([term()]) -> [{term(), term()}].
ensure_pairs(L) ->
[{K, V} || {K, V} <- L].
Expand Down
91 changes: 90 additions & 1 deletion test/asobi_lua_api_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ api_test_() ->
{"game.spatial.query_rect errors without zone", fun spatial_query_rect_no_zone/0},
{"game.terrain.get_chunk returns data", fun terrain_get_chunk/0},
{"game.terrain.get_chunk errors without store", fun terrain_get_chunk_no_store/0},
{"game.terrain.preload forwards coords", fun terrain_preload/0}
{"game.terrain.preload forwards coords", fun terrain_preload/0},
{"to_storage_value preserves scalars", fun to_storage_value_scalars/0},
{"to_storage_value preserves maps", fun to_storage_value_maps/0},
{"to_storage_value preserves arrays", fun to_storage_value_arrays/0},
{"to_storage_value preserves nested structures", fun to_storage_value_nested/0},
{"player_set forwards a scalar number", fun storage_player_set_scalar/0},
{"player_set forwards a binary", fun storage_player_set_binary/0},
{"player_set forwards an array", fun storage_player_set_array/0}
]}.

setup() ->
Expand Down Expand Up @@ -405,6 +412,88 @@ terrain_preload() ->
{ok, [true | _], _} = eval(Code, St),
?assert(meck:called(asobi_terrain_store, preload_chunks, '_')).

%% --- to_storage_value coercion ---

to_storage_value_scalars() ->
?assertEqual(5, asobi_lua_api:to_storage_value(5)),
?assertEqual(3.14, asobi_lua_api:to_storage_value(3.14)),
?assertEqual(~"hello", asobi_lua_api:to_storage_value(~"hello")),
?assertEqual(true, asobi_lua_api:to_storage_value(true)),
?assertEqual(false, asobi_lua_api:to_storage_value(false)),
?assertEqual(nil, asobi_lua_api:to_storage_value(nil)).

to_storage_value_maps() ->
?assertEqual(
#{~"k" => ~"v", ~"n" => 5},
asobi_lua_api:to_storage_value(#{~"k" => ~"v", ~"n" => 5})
),
%% Luerl decodes maps to string-keyed proplists. The coercer must
%% turn that into an Erlang map so kura's jsonb encoder can serialise.
?assertEqual(
#{~"k" => ~"v"},
asobi_lua_api:to_storage_value([{~"k", ~"v"}])
).

to_storage_value_arrays() ->
%% Luerl decodes Lua arrays to integer-keyed proplists.
?assertEqual(
[~"a", ~"b", ~"c"],
asobi_lua_api:to_storage_value([{1, ~"a"}, {2, ~"b"}, {3, ~"c"}])
),
%% Out-of-order pairs still produce a stable ordering by key.
?assertEqual(
[~"a", ~"b", ~"c"],
asobi_lua_api:to_storage_value([{3, ~"c"}, {1, ~"a"}, {2, ~"b"}])
).

to_storage_value_nested() ->
%% { position = { x = 1, y = 2 }, tags = { "rare", "shiny" } }
Input = [
{~"position", [{~"x", 1}, {~"y", 2}]},
{~"tags", [{1, ~"rare"}, {2, ~"shiny"}]}
],
Expected = #{
~"position" => #{~"x" => 1, ~"y" => 2},
~"tags" => [~"rare", ~"shiny"]
},
?assertEqual(Expected, asobi_lua_api:to_storage_value(Input)).

%% --- Storage round-trip: scalar values must reach the changeset ---

storage_player_set_scalar() ->
storage_player_set_roundtrip("5", 5).

storage_player_set_binary() ->
storage_player_set_roundtrip("\"dark\"", ~"dark").

storage_player_set_array() ->
storage_player_set_roundtrip("{1, 2, 3}", [1, 2, 3]).

-spec storage_player_set_roundtrip(string(), term()) -> ok.
storage_player_set_roundtrip(LuaValueExpr, Expected) ->
St = install_api(),
meck:reset(asobi_repo),
meck:expect(asobi_repo, all, fun(_) -> {ok, []} end),
Code =
"local r = game.storage.player_set('p1', 'inv', 'k', " ++
LuaValueExpr ++
")\nreturn r.ok ~= nil",
{ok, [true | _], _} = eval(Code, St),
%% kura_changeset:cast/4 receives the params map; meck:history captures
%% every call to asobi_repo:insert/1. Pull the most recent insert and
%% verify the value field round-trips intact.
History = meck:history(asobi_repo),
[{_, {asobi_repo, insert, [Changeset]}, _} | _] =
lists:reverse([H || H <- History, element(2, element(2, H)) =:= insert]),
?assertEqual(Expected, changeset_param(Changeset, value)).

%% Pull a named field out of a kura_changeset record. Position 5 holds
%% the (normalised) params map per kura.hrl.
-spec changeset_param(term(), atom()) -> term().
changeset_param(CS, Field) when is_tuple(CS) ->
Params = element(5, CS),
maps:get(Field, Params).

%% --- Helpers ---

install_api() ->
Expand Down
Loading