Files
sporel-lib-core.maps/init.lua
Axel Meyer db9eb222cb fix(P.3.1): split_namespaced_id replaced with current-module-prefix match; v0.1.1
Pre-existing bug: split_namespaced_id used first-dot-split. For modules
with dotted IDs (e.g. lib-core.maps-test), this misclassified
'<dotted-module-id>.<tilemap-name>' as cross-lib reference.

Replaced with split_local_tilemap() that matches by exact current-
module-id prefix. Module-id can now have arbitrary dots.

Surfaced by P.3.1 reference test-module 'lib-core.maps-test' which
has a dotted module-id.
2026-05-11 02:50:32 +02:00

242 lines
8.3 KiB
Lua

-- =====================================================================
-- lib-core.maps — Single Tile-Grid Map Lib (P.0)
-- See: meta/docs/superpowers/specs/2026-05-09-p0-lib-maps-design.md
--
-- Forward-compat stubs (DEPRECATED-MVP) for: multi-map-graph,
-- lifecycle-states, walls, sprite-fields, generators, save-integration,
-- cross-lib tilemap resolution, override-field-merge, sub-require.
-- =====================================================================
-- Module-private state
local map_registry = {} -- map_id -> Map
local tilemap_registry = {} -- full_tilemap_id -> Tilemap
local current_map_id = nil
-- =====================================================================
-- Internal helpers
-- =====================================================================
-- Checks whether `full_id` is a tilemap belonging to the current module.
-- Module-IDs can themselves contain dots (e.g. `lib-core.maps-test`), so a
-- naive first-dot-split is wrong. Match by prefix `<current-module-id>.`.
-- Returns (is_local, local_name) — local_name is nil if not local.
local function split_local_tilemap(full_id)
local mod = engine.module.id()
local prefix = mod .. "."
if string.sub(full_id, 1, #prefix) == prefix then
return true, string.sub(full_id, #prefix + 1)
end
return false, nil
end
-- "demo_tilemap" -> "<current-module-id>.demo_tilemap"
-- "lib-x.foo" -> "lib-x.foo" (cross-lib path; checked at load)
local function resolve_tilemap_id(ref)
if string.find(ref, ".", 1, true) then
return ref
end
return engine.module.id() .. "." .. ref
end
-- Schema-validation helper: reads required field with type-check.
local function require_field(t, key, expected_type, source)
local v = t[key]
if v == nil then
error(string.format("maps.load: schema violation in %s: missing required field '%s'",
source, key))
end
if type(v) ~= expected_type then
error(string.format("maps.load: schema violation in %s: field '%s' must be %s, got %s",
source, key, expected_type, type(v)))
end
return v
end
local function validate_map_table(t, source)
require_field(t, "id", "string", source)
require_field(t, "tilemap", "string", source)
local size = require_field(t, "size", "table", source)
require_field(size, "w", "number", source .. ".size")
require_field(size, "h", "number", source .. ".size")
local tiles = require_field(t, "tiles", "table", source)
local expected = size.w * size.h
if #tiles ~= expected then
error(string.format("maps.load: schema violation in %s: tiles array length %d != size.w * size.h (%d)",
source, #tiles, expected))
end
end
local function validate_tilemap_table(t, source, expected_local_name)
require_field(t, "id", "string", source)
if t.id ~= expected_local_name then
error(string.format("maps.load: tilemap manifest id '%s' mismatches filename-stem '%s' in %s",
t.id, expected_local_name, source))
end
require_field(t, "tile_size", "number", source)
local tiles = require_field(t, "tiles", "table", source)
for i, entry in ipairs(tiles) do
if type(entry) ~= "table" then
error(string.format("maps.load: tilemap %s tiles[%d] must be table", source, i))
end
require_field(entry, "id", "string", source .. ".tiles[" .. i .. "]")
require_field(entry, "walkable", "boolean", source .. ".tiles[" .. i .. "]")
end
end
local function load_tilemap(full_id)
if tilemap_registry[full_id] then
return tilemap_registry[full_id]
end
local is_local, local_name = split_local_tilemap(full_id)
if not is_local then
-- DEPRECATED-MVP: cross-lib tilemap resolution deferred to render-slice
error(string.format("maps.load: cross-lib tilemap resolution deferred [DEPRECATED-MVP]; tilemap '%s' not from current module '%s'",
full_id, engine.module.id()))
end
local path = "assets/tiles/" .. local_name .. ".tilemap.json"
local raw = engine.asset.load_json(path)
validate_tilemap_table(raw, path, local_name)
local tilemap = {
id = full_id,
tile_size = raw.tile_size,
tiles = raw.tiles,
}
tilemap_registry[full_id] = tilemap
return tilemap
end
local function build_map(t_map, tilemap)
-- Verify each tile-id is in palette range
for i, tid in ipairs(t_map.tiles) do
if type(tid) ~= "number" or tid < 1 or tid > #tilemap.tiles then
error(string.format("maps.load: tile-id %s at index %d exceeds palette size %d (in map '%s')",
tostring(tid), i, #tilemap.tiles, t_map.id))
end
end
return {
id = t_map.id,
size = t_map.size,
tile_size = t_map.tile_size or tilemap.tile_size,
tiles = t_map.tiles, -- shallow-ref
tilemap = tilemap, -- shallow-ref
-- DEPRECATED-MVP: forward-compat stubs (multi-map slice fills)
walls = {},
regions = {},
edges = {},
state = "Active",
pinned = false,
}
end
-- =====================================================================
-- Public API
-- =====================================================================
local M = {}
function M.load(path)
local raw = engine.asset.load_json(path)
validate_map_table(raw, path)
local full_id = resolve_tilemap_id(raw.tilemap)
local tilemap = load_tilemap(full_id)
local map = build_map(raw, tilemap)
if map_registry[map.id] then
error(string.format("maps.load: map-id '%s' already registered", map.id))
end
map_registry[map.id] = map
return map.id
end
-- Programmatic creation (tests, future procedural-map generators).
-- Caller must provide a fully-built tilemap-table (not a path/id ref).
function M.create(t)
if type(t.tilemap_table) ~= "table" then
error("maps.create: tilemap_table required (use maps.load for JSON path)")
end
local map = build_map(t, t.tilemap_table)
if map_registry[map.id] then
error(string.format("maps.create: map-id '%s' already registered", map.id))
end
map_registry[map.id] = map
return map.id
end
function M.size(map_id)
local id = map_id or current_map_id
if not id then error("maps.size: no current map") end
return map_registry[id].size
end
function M.tile_size(map_id)
local id = map_id or current_map_id
if not id then error("maps.tile_size: no current map") end
return map_registry[id].tile_size
end
-- Arity-flex sugar: tile_at(tx, ty) uses current_map_id; tile_at(map_id, tx, ty)
-- is explicit. Both forms accept nil map_id and fall back to current_map_id.
function M.tile_at(a, b, c)
local map_id, tx, ty
if c == nil then
map_id, tx, ty = current_map_id, a, b
else
map_id, tx, ty = a, b, c
if map_id == nil then map_id = current_map_id end
end
if not map_id then
error("maps.tile_at: no current map; call set_current() first or pass map_id")
end
local m = map_registry[map_id]
if not m then
error(string.format("maps.tile_at: unknown map-id '%s'", tostring(map_id)))
end
if tx < 0 or tx >= m.size.w or ty < 0 or ty >= m.size.h then
return nil
end
local palette_id = m.tiles[ty * m.size.w + tx + 1]
return m.tilemap.tiles[palette_id]
end
function M.is_walkable(a, b, c)
local t = M.tile_at(a, b, c)
if t == nil then return false end
return t.walkable == true
end
function M.tilemap_id(map_id)
local id = map_id or current_map_id
return map_registry[id].tilemap.id
end
function M.current()
return current_map_id
end
function M.set_current(map_id)
if not map_registry[map_id] then
error(string.format("maps.set_current: unknown map-id '%s'", tostring(map_id)))
end
current_map_id = map_id
end
function M.list()
local out = {}
for id, _ in pairs(map_registry) do
out[#out+1] = id
end
return out
end
-- Forward-compat stubs (DEPRECATED-MVP — implemented in later slices)
function M.state(map_id)
-- DEPRECATED-MVP: lifecycle states (Virgin/Inert/Passive/Active/Pinned) — multi-map slice
return "Active"
end
function M.pin(map_id, reason)
-- DEPRECATED-MVP: world.pin_map mechanic — multi-map slice
engine.warn("maps.pin: deferred to map-topology lifecycle slice")
end
return M