fix(api): preserve scalar values through game.storage.set/player_set#46
Merged
Conversation
`to_map/1` was applied to the `value` argument of `game.storage.set` and
`game.storage.player_set` before persisting, but its catch-all clause
silently coerced anything that wasn't a map or string-keyed proplist
into an empty map. As a result, `game.storage.player_set(pid, "c",
"k", 5)` stored `{}` rather than `5` — scripts could only store
map-shaped values, and the lossy coercion was undocumented.
Add `to_storage_value/1` (exported), used in place of `to_map/1` for
the two storage call sites. It preserves the JSON-compatible leaf
types and recurses into containers:
number, binary, boolean, nil -> kept as-is
map -> recurse over values
string-keyed proplist -> map (recurse)
integer-keyed proplist -> list (recurse, ordered)
plain list (post-deep_decode) -> list (recurse)
empty list -> #{} (preserves prior behaviour)
anything else -> nil (instead of #{})
`storage_get/3` and `storage_set/4` spec returns widen from `map()` to
`term()` to admit scalar round-trips.
Eunit covers the coercion table directly (`to_storage_value_scalars/0`
et al.) plus a Lua-side round-trip that asserts the unwrapped value
reaches the kura changeset via meck history (`storage_player_set_scalar/0`
et al.).
6 tasks
Taure
added a commit
to Taure/kura
that referenced
this pull request
May 14, 2026
* fix(types): accept scalar values in jsonb cast/dump/load
`cast(jsonb, ...)` only matched maps, lists, and binaries — any other
JSON-compatible scalar (integer, float, boolean) was rejected as
`cannot cast to jsonb`. That was inherited from a time when jsonb was
treated as "structured", but postgres accepts any valid JSON at the
root of a jsonb column, including scalars.
Add the missing clauses to `cast/2`, `dump/2`, and `load/2` so numbers
and booleans round-trip. `null` continues to land in the generic
`cast(_, null) -> {ok, undefined}` and `load(_, null) -> {ok, undefined}`
clauses higher up, keeping the kura convention of materialising SQL
NULLs as the `undefined` atom.
Found via barrow's bank-on-extract loop: scripts wanted
`game.storage.player_set(pid, "barrow", "run_loot", 5)` but had to
wrap as `{ n = 5 }` because the changeset cast rejected the scalar.
Pairs with the asobi_lua-side coercion fix in widgrensit/asobi_lua#46.
* chore: remove stray elvis.config from previous commit
elvis.config was an untracked file in the worktree from an unrelated
experiment that got swept into the jsonb commit via `git add -A`.
It's not part of this PR's scope.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
`game.storage.set` and `game.storage.player_set` silently coerced any non-map, non-string-keyed-proplist value into an empty map before persisting, because their wrapper applied `to_map/1`'s catch-all clause to the `value` arg. `game.storage.player_set(pid, "c", "k", 5)` stored `{}`, not `5`. Scripts had to wrap scalars as `{ n = 5 }` to avoid silent data loss.
Adds `to_storage_value/1` (exported) and swaps it in at the two storage call sites. It preserves JSON-compatible leaf types and recurses into containers:
Specs for `storage_get/3` and `storage_set/4` widened from `map()` to `term()` to admit scalar round-trips.
Test plan
Caller cleanup (separate PR)
Found via barrow. With this PR in, the workaround in Taure/barrow (wrapping `{ n = N }` around stored integers) can be dropped — barrow's `run_loot`/`stash` lua then just `game.storage.player_set(pid, "barrow", key, value)`.
Note: per-player rows still need the `player_id` workaround in the storage key until widgrensit/asobi#122 ships.