diff --git a/init.lua b/init.lua index e2f480a..19e6457 100644 --- a/init.lua +++ b/init.lua @@ -699,6 +699,133 @@ function M.set_roof(x, y, value, map_id) map._dirty = true end +-- ===================================================================== +-- Internal JSON pretty-printer (hand-rolled, 2-space indent). +-- Supports: nil/null, bool, number (int + float), string, array, object. +-- Deterministic key ordering (sorted) so saves are byte-stable. +-- ===================================================================== +local json_value -- forward declaration +local json_table -- forward declaration + +local function json_escape_string(s) + local replacements = { + ['"'] = '\\"', + ['\\'] = '\\\\', + ['\n'] = '\\n', + ['\r'] = '\\r', + ['\t'] = '\\t', + ['\b'] = '\\b', + ['\f'] = '\\f', + } + return '"' .. s:gsub('[%z\1-\31"\\]', function(c) + return replacements[c] or string.format('\\u%04x', string.byte(c)) + end) .. '"' +end + +json_value = function(v, indent) + if v == nil then return "null" end + local t = type(v) + if t == "boolean" then return v and "true" or "false" end + if t == "number" then + if v ~= v then return "null" end -- NaN + if v == math.huge or v == -math.huge then return "null" end + if math.type(v) == "integer" or v == math.floor(v) then + return string.format("%d", v) + end + return string.format("%.17g", v) + end + if t == "string" then return json_escape_string(v) end + if t == "table" then + return json_table(v, indent) + end + error("json: cannot serialize value of type " .. t) +end + +json_table = function(t, indent) + -- Detect array vs object by checking integer keys 1..n + local n = #t + local is_array = n > 0 + if is_array then + for k, _ in pairs(t) do + if type(k) ~= "number" then is_array = false; break end + end + end + local indent_next = indent .. " " + + if is_array then + if n == 0 then return "[]" end + local parts = {} + for i = 1, n do + parts[i] = indent_next .. json_value(t[i], indent_next) + end + return "[\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "]" + end + + -- Object: collect & sort keys for determinism + local keys = {} + for k, _ in pairs(t) do + table.insert(keys, tostring(k)) + end + table.sort(keys) + if #keys == 0 then return "{}" end + local parts = {} + for _, k in ipairs(keys) do + parts[#parts + 1] = indent_next .. json_escape_string(k) .. + ": " .. json_value(t[k], indent_next) + end + return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}" +end + +-- ===================================================================== +-- v2-Map serialization: reverse the internal resolved shape back to the +-- on-disk JSON schema (atlases as ID strings, layers in canonical order). +-- ===================================================================== +local LAYER_DISK_ORDER = { + "foundation", "subsurface", "surface", "topsurface", + "lower_wall", "wall", "upper_wall", "canopy", +} + +local function serialize_map_v2(map) + local out = { + schema_version = 2, + id = map.id, + size = { w = map.size.w, h = map.size.h }, + atlases = {}, + layers = {}, + } + -- Use atlas_aliases (raw string IDs from disk) for round-trip fidelity. + for i, alias in ipairs(map.atlas_aliases) do + out.atlases[i] = alias + end + for _, layer_name in ipairs(LAYER_DISK_ORDER) do + local layer = map.layers[layer_name] + if layer then + local tiles_copy = {} + for i = 1, #layer.tiles do tiles_copy[i] = layer.tiles[i] end + out.layers[layer_name] = { tiles = tiles_copy } + end + end + if map.roof then + local roof_copy = {} + for i = 1, #map.roof do roof_copy[i] = map.roof[i] end + out.roof = roof_copy + end + return json_value(out, "") .. "\n" +end + +function M.save_to_disk(map_id, path) + local map = require_map(map_id) + local json_str = serialize_map_v2(map) + local f, err = io.open(path, "w") + if not f then + error(string.format("maps.save_to_disk: cannot open '%s' (%s)", + path, err or "?")) + end + f:write(json_str) + f:close() + map._dirty = false +end + function M.is_indoor(x, y, map_id) local id = map_id or current_map_id if not id then error("maps.is_indoor: no current map") end