diff --git a/src/lua/asobi_lua_api.erl b/src/lua/asobi_lua_api.erl index 975f162..7f321b5 100644 --- a/src/lua/asobi_lua_api.erl +++ b/src/lua/asobi_lua_api.erl @@ -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) -> @@ -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 @@ -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 @@ -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}), @@ -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, _} -> @@ -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]. diff --git a/test/asobi_lua_api_tests.erl b/test/asobi_lua_api_tests.erl index 7ae7d8e..cd13147 100644 --- a/test/asobi_lua_api_tests.erl +++ b/test/asobi_lua_api_tests.erl @@ -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() -> @@ -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() ->