Compare commits

...

14 Commits

Author SHA1 Message Date
Axel Meyer
ba3e8ce64f chore(deps): bump lib-core.maps 0.5.6 -> 0.5.7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:44:24 +02:00
Axel Meyer
ff27f3caf6 test(maps): maps_t8 round-trip + maps_t9 byte-determinism
t8 saves a map with cells_material set, verifies the JSON
contains the key, reloads, and reads the flag back. t9 saves
twice with no cells_material content and asserts byte-identity
plus absence of the cells_material key in the output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:30:24 +02:00
Axel Meyer
52da578e3f test(maps): maps_t2-t7 slot-resolution proofs for cell-tile
Proves that previously-unreachable atlas slots become reachable
via the cell-tile material path: slot 0 isolated (t3), slot 1 end
with correct rotations (t4), slot 4 straight (t5), slot 5
tee_open (t6), slot 8 cross_open (t7).

Upgrades write_test.map.json to schema_version 3 with material
and vertices fields on surface so cell_material_slot + set_vertex
resolve correctly in the tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:13:36 +02:00
Axel Meyer
b68b1d4fba test(maps): maps_t1 cell-tile material round-trip + guards
Exercises set_cell_material / get_cell_material round-trip on the
write_test fixture, plus bounds rejection and unknown-layer
rejection. Confirms the new public API is wired and the guards
match the existing v3 write-API conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:51:01 +02:00
Axel Meyer
c159738870 chore(deps): bump lib-core.maps 0.5.5 -> 0.5.6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 17:19:07 +02:00
Axel Meyer
ba2ef2df06 chore(deps): bump to 0.5.5; track lib-core.maps 0.5.5 2026-05-30 23:40:57 +02:00
Axel Meyer
aaeb8d3650 Bump lib-core.maps-test to v0.4.0 and pin maps dep to 0.4.0
Test-lib follows the lib it exercises; the new asserts in init.lua
exercise the v0.4.0 write APIs that did not exist in 0.3.0.
2026-05-24 00:51:12 +02:00
Axel Meyer
e082a39596 Add save_to_disk round-trip and determinism asserts
Verifies the saved JSON is readable, that two successive saves
produce byte-identical output, and that re-loading the saved file
recovers the same cell values that were written before saving.
Uses engine.module.dir_of to write the round-trip fixture to the
lib maps/ directory (asset sandbox rejects absolute temp paths).
2026-05-24 00:43:31 +02:00
Axel Meyer
e399f6c6f8 Add asserts for set_roof write API
Covers value validation (rejects 2), bounds rejection, and the
basic call paths that set up roof state for the save-roundtrip
test in the next commit.
2026-05-24 00:34:18 +02:00
Axel Meyer
8453b1fc8f Add asserts for set_cell_gid write API
Covers happy-path round-trip, bounds rejection (positive and negative),
unknown-layer rejection, and on-demand layer allocation.
2026-05-24 00:25:12 +02:00
Axel Meyer
1ee19b4b7f Migrate test assertions to atlas tile-record shape; bump to 0.3.0
tile.id is now an integer; the stable string identifier is tile.name.
Updates all tile-record string-id assertions to use .name. Adds
walkable field to stub atlas JSONs so gameplay assertions pass without
the legacy tiles dir. Test bootstrap calls maps.load_textures with
aliases pointing to the test-lib itself as the asset provider. Moves
load_textures calls inline after each set_current (Task 4 and Task 9
blocks) so all tile-record shape and walkability checks use atlas
records throughout. Bumps version to 0.3.0, dep on maps 0.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:09:14 +02:00
Axel Meyer
394e8faef2 Bake stub atlas-sets for demo_tilemap and walls_tilemap
Replaces the legacy color-only *.tilemap.json fixtures with baked
2-tile atlas-sets in the new atlases/<id>/{diffuse,height,atlas-json,
lock-json} layout. Stub PNGs are 32x32 solid colors matching the
previous color values; height channel is synthesized L8=0 by the baker.
A scripts/bake.sh re-bake helper drives the atlas-baker tool against
assets/_sources/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:53:30 +02:00
Axel Meyer
c3e9eda702 Add v0.2.0 test assertions, multi-layer fixture, second atlas
Migrates the test-lib content authored alongside lib-core.maps v0.2.0
into its canonical role=test location. Covers packed-u32 GID encode/
decode round-trip, v1 to v2 schema upgrade, schema-v2 validation,
multi-layer load, multi-atlas resolution, per-layer tile lookup with
tile_at backwards-compat, gameplay queries (is_walkable, blocks_walk,
blocks_sight), layer iteration around the entity slot, and v1 JSON
auto-migration on load. 367 assertions total when run via the engine
test-libs harness.

New fixtures: maps/demo_v2.map.json (4x4 multi-layer with roof) and
assets/tiles/walls_tilemap.tilemap.json (second atlas with wall tiles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:54:08 +02:00
Axel Meyer
705cae199e Bump to 0.2.0 to track lib-core.maps 0.2.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:14:01 +02:00
19 changed files with 695 additions and 20 deletions

21
assets/_sources/_gen.js Normal file
View File

@@ -0,0 +1,21 @@
const { PNG } = require('pngjs');
const fs = require('node:fs');
const path = require('node:path');
function writeSolidRGBA(p, w, h, rgba) {
const png = new PNG({ width: w, height: h });
for (let i = 0; i < png.data.length; i += 4) {
png.data[i + 0] = rgba[0];
png.data[i + 1] = rgba[1];
png.data[i + 2] = rgba[2];
png.data[i + 3] = rgba[3];
}
fs.writeFileSync(p, PNG.sync.write(png));
}
const SOURCES = path.resolve(__dirname);
writeSolidRGBA(path.join(SOURCES, 'demo_tilemap', 'grass_diffuse.png'), 32, 32, [110, 170, 80, 255]);
writeSolidRGBA(path.join(SOURCES, 'demo_tilemap', 'stone_diffuse.png'), 32, 32, [120, 120, 120, 255]);
writeSolidRGBA(path.join(SOURCES, 'walls_tilemap', 'wall_brick_diffuse.png'), 32, 32, [180, 100, 80, 255]);
writeSolidRGBA(path.join(SOURCES, 'walls_tilemap', 'wall_wood_diffuse.png'), 32, 32, [140, 90, 50, 255]);
console.log('test-stub sources generated');

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1,33 @@
{
"atlas_id": "demo_tilemap",
"atlas_version": 1,
"atlas_size_px": [
64,
64
],
"tile_size_px": 32,
"tiles": [
{
"id": 1,
"name": "grass",
"walkable": true,
"uv": [
0,
0,
32,
32
]
},
{
"id": 2,
"name": "stone",
"walkable": false,
"uv": [
32,
0,
32,
32
]
}
]
}

View File

@@ -0,0 +1,9 @@
{
"atlas_id": "demo_tilemap",
"bindings": {
"grass": 1,
"stone": 2
},
"deleted": [],
"next_id": 3
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

View File

@@ -0,0 +1,35 @@
{
"atlas_id": "walls_tilemap",
"atlas_version": 1,
"atlas_size_px": [
64,
64
],
"tile_size_px": 32,
"tiles": [
{
"id": 1,
"name": "wall_brick",
"walkable": false,
"uv": [
0,
0,
32,
32
],
"blocks_sight": true
},
{
"id": 2,
"name": "wall_wood",
"walkable": false,
"uv": [
32,
0,
32,
32
],
"blocks_sight": true
}
]
}

View File

@@ -0,0 +1,9 @@
{
"atlas_id": "walls_tilemap",
"bindings": {
"wall_brick": 1,
"wall_wood": 2
},
"deleted": [],
"next_id": 3
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

View File

@@ -1,8 +0,0 @@
{
"id": "demo_tilemap",
"tile_size": 32,
"tiles": [
{"id": "grass", "walkable": true, "color": [110, 170, 80]},
{"id": "stone", "walkable": false, "color": [120, 120, 120]}
]
}

515
init.lua
View File

@@ -8,32 +8,527 @@ local maps = require("lib-core.maps")
local M = {}
function M.run_tests(ctx)
-- Setup
local demo_map = maps.load("maps/demo.map.json")
maps.set_current(demo_map)
-- Asset-aliases for texture-loading (test-libs lack manifest asset_aliases;
-- hard-code the test-lib itself as the asset provider for its own atlases).
local aliases = {
demo_tilemap = "lib-core.maps-test",
walls_tilemap = "lib-core.maps-test",
}
-- Geometry assertions
-- ---------- Task 1: GID encode/decode ----------
engine.test.equals(maps.encode_gid(0, 0, 0), 0, "encode_gid(0,0,0) = 0 (empty sentinel)")
engine.test.equals(maps.encode_gid(0, 12, 0), 192, "encode_gid(0,12,0) = 192 (= 12<<4)")
engine.test.equals(maps.encode_gid(0, 12, 1), 196, "encode_gid(0,12,1) = 196 (= 12<<4 | 1<<2)")
engine.test.equals(maps.encode_gid(1, 5, 0), 16777296, "encode_gid(1,5,0) = 16777296 (= 1<<24 | 5<<4)")
engine.test.equals(maps.encode_gid(255, 1048575, 3), 0xFFFFFFFC, "encode_gid max-values fills 32 bits except reserved")
local a, t, r = maps.decode_gid(196)
engine.test.equals(a, 0, "decode_gid(196) atlas = 0")
engine.test.equals(t, 12, "decode_gid(196) tile = 12")
engine.test.equals(r, 1, "decode_gid(196) rot = 1")
-- Round-trip
for atlas = 0, 3 do
for tile = 0, 100, 17 do
for rot = 0, 3 do
local gid = maps.encode_gid(atlas, tile, rot)
local a2, t2, r2 = maps.decode_gid(gid)
engine.test.equals(a2, atlas, "round-trip atlas")
engine.test.equals(t2, tile, "round-trip tile")
engine.test.equals(r2, rot, "round-trip rot")
end
end
end
-- ---------- Task 2: Schema-v2 + v1→v2 migration ----------
-- Build a v1-style map table in-memory
local v1_table = {
id = "test_v1",
tilemap = "demo_tilemap",
size = { w = 2, h = 2 },
tiles = { 1, 2, 2, 1 },
tile_rotations = { 0, 90, 180, 270 },
}
local v2 = maps.upgrade_v1_to_v2(v1_table)
engine.test.equals(v2.schema_version, 2, "upgraded schema_version = 2")
engine.test.equals(v2.id, "test_v1", "upgraded id preserved")
engine.test.equals(v2.size.w, 2, "upgraded size.w preserved")
engine.test.equals(#v2.atlases, 1, "upgraded atlases[] has 1 entry")
engine.test.equals(v2.atlases[1], "demo_tilemap", "upgraded atlases[0] = v1 tilemap")
engine.test.assert(v2.layers.surface ~= nil, "upgraded has surface layer")
engine.test.equals(#v2.layers.surface.tiles, 4, "upgraded surface tiles count")
-- Check first cell: tile_id=1, rotation=0 → encode_gid(0, 1, 0) = 16
engine.test.equals(v2.layers.surface.tiles[1], 16, "upgraded cell 0 = (atlas=0, tile=1, rot=0)")
-- Check second cell: tile_id=2, rotation=90 → quadrant 1 → encode_gid(0, 2, 1) = (2<<4)|(1<<2) = 36
engine.test.equals(v2.layers.surface.tiles[2], 36, "upgraded cell 1 with rot 90° → quadrant 1")
-- Fourth cell: rotation 270 → quadrant 3 → encode_gid(0, 1, 3) = (1<<4)|(3<<2) = 28
engine.test.equals(v2.layers.surface.tiles[4], 28, "upgraded cell 3 with rot 270° → quadrant 3")
-- ---------- Task 3: Schema-v2 validation ----------
-- Valid minimal v2 map should NOT raise
local v2_minimal = {
schema_version = 2,
id = "v2_min",
size = { w = 2, h = 2 },
atlases = { "demo_tilemap" },
layers = { surface = { tiles = { 16, 16, 16, 16 } } }
}
local ok = pcall(function() maps.validate_map_table_v2(v2_minimal, "in-memory") end)
engine.test.assert(ok, "valid v2 map validates without error")
-- Bad schema_version
local bad_ver = { schema_version = 99, id = "x", size = {w=1,h=1}, atlases = {"a"}, layers = {} }
local ok2, err2 = pcall(function() maps.validate_map_table_v2(bad_ver, "in-memory") end)
engine.test.assert(not ok2, "rejected schema_version 99")
-- Missing atlases
local no_atlas = { schema_version = 2, id = "x", size = {w=1,h=1}, layers = {} }
local ok3 = pcall(function() maps.validate_map_table_v2(no_atlas, "in-memory") end)
engine.test.assert(not ok3, "rejected missing atlases[]")
-- Wrong tile-array length
local wrong_len = {
schema_version = 2, id = "x", size = {w=2,h=2},
atlases = {"demo_tilemap"},
layers = { surface = { tiles = {16, 16, 16} } } -- only 3, expected 4
}
local ok4 = pcall(function() maps.validate_map_table_v2(wrong_len, "in-memory") end)
engine.test.assert(not ok4, "rejected wrong tile array length")
-- Unknown layer name → warning logged but accepted (graceful)
local unknown_layer = {
schema_version = 2, id = "x", size = {w=1,h=1},
atlases = {"demo_tilemap"},
layers = { surface = { tiles = {0} }, made_up = { tiles = {0} } }
}
local ok5 = pcall(function() maps.validate_map_table_v2(unknown_layer, "in-memory") end)
engine.test.assert(ok5, "unknown layer name accepted (logged warn)")
-- ---------- Task 4: Load v2 map with single atlas, multi-layer ----------
local v2_id = maps.load("maps/demo_v2.map.json")
engine.test.equals(v2_id, "demo_v2", "v2 map loaded with id 'demo_v2'")
maps.set_current(v2_id)
maps.load_textures(aliases)
local v2_size = maps.size()
engine.test.equals(v2_size.w, 4, "v2 map width = 4")
engine.test.equals(v2_size.h, 4, "v2 map height = 4")
-- Atlas count (demo_v2 has 2 atlases as of Task 5 fixture)
engine.test.equals(maps.atlas_count(), 2, "v2 map has 2 atlases")
engine.test.equals(maps.atlas_id_at(0), "demo_tilemap", "v2 atlas[0] = demo_tilemap")
-- Layer existence
engine.test.assert(maps.has_layer("surface"), "v2 has surface layer")
engine.test.assert(maps.has_layer("topsurface"), "v2 has topsurface layer")
engine.test.assert(maps.has_layer("wall"), "v2 has wall layer")
-- Roof
engine.test.assert(maps.is_indoor(1, 1), "v2 cell (1,1) is indoor")
engine.test.assert(not maps.is_indoor(0, 0), "v2 cell (0,0) is outdoor")
-- ---------- Task 5: Multi-atlas-per-map ----------
-- Re-load (demo_v2 already loaded; reset state by closing & re-opening)
-- For the test, we trust the existing loaded state if the fixture file was updated
engine.test.equals(maps.atlas_count(), 2, "demo_v2 now has 2 atlases")
engine.test.equals(maps.atlas_id_at(0), "demo_tilemap", "atlas[0] alias")
engine.test.equals(maps.atlas_id_at(1), "walls_tilemap", "atlas[1] alias")
-- Per-layer atlas resolution: wall layer cell (0,0) should be atlas-1, tile-1
local wall_gid = maps.cell_gid("wall", 0, 0)
local a, t, r = maps.decode_gid(wall_gid)
engine.test.equals(a, 1, "wall cell (0,0) atlas = 1 (walls_tilemap)")
engine.test.equals(t, 1, "wall cell (0,0) tile = 1 (wall_brick)")
-- Empty cell in wall layer
local center_gid = maps.cell_gid("wall", 1, 1)
engine.test.equals(center_gid, 0, "wall cell (1,1) empty (inside roof)")
-- ---------- Task 6: Per-layer tile_at ----------
-- tile_at_layer returns tile-record from the correct atlas
local surface_tile = maps.tile_at_layer("surface", 1, 1)
engine.test.assert(surface_tile ~= nil, "surface (1,1) returns tile")
engine.test.equals(surface_tile.name, "grass", "surface (1,1) name = grass")
local wall_tile = maps.tile_at_layer("wall", 0, 0)
engine.test.assert(wall_tile ~= nil, "wall (0,0) returns tile")
engine.test.equals(wall_tile.name, "wall_brick", "wall (0,0) name = wall_brick (from walls_tilemap)")
-- Empty cell returns nil
local empty_top = maps.tile_at_layer("topsurface", 0, 0)
engine.test.equals(empty_top, nil, "empty topsurface cell returns nil")
-- OOB returns nil
engine.test.equals(maps.tile_at_layer("surface", -1, 0), nil, "OOB tile_at_layer returns nil")
-- Legacy tile_at backwards-compat: queries surface layer
local legacy = maps.tile_at(1, 1)
engine.test.equals(legacy.name, "grass", "legacy tile_at(x,y) queries surface layer")
-- ---------- Task 7: Gameplay queries ----------
-- Surface-only cells (inside, no wall) → walkable
engine.test.assert(maps.is_walkable(1, 1), "inner (1,1) walkable (surface=grass, no wall)")
engine.test.assert(maps.is_walkable(2, 2), "inner (2,2) walkable")
-- Wall cells block walk
engine.test.assert(not maps.is_walkable(0, 0), "(0,0) not walkable (wall present)")
engine.test.assert(not maps.is_walkable(3, 3), "(3,3) not walkable (wall present)")
-- blocks_walk direct query
engine.test.assert(maps.blocks_walk(0, 0), "(0,0) blocks_walk")
engine.test.assert(not maps.blocks_walk(1, 1), "(1,1) does not block_walk")
-- blocks_sight: wall layer cell blocks sight; surface-only cell does not
engine.test.assert(maps.blocks_sight(0, 0), "(0,0) wall blocks sight")
engine.test.assert(not maps.blocks_sight(1, 1), "(1,1) no wall does not block sight")
-- OOB → not walkable, blocks_walk false (out-of-map)
engine.test.assert(not maps.is_walkable(-1, 0), "OOB not walkable")
engine.test.assert(not maps.blocks_walk(-1, 0), "OOB does not block_walk (outside)")
-- ---------- Task 8: Layer iteration + draw ----------
-- iterate_layers_pre_entities should visit foundation/subsurface/surface/topsurface in order
local pre_visited = {}
maps.iterate_layers_pre_entities(function(layer_name)
pre_visited[#pre_visited + 1] = layer_name
end)
-- Only layers that exist in the map are visited; surface + topsurface here
engine.test.equals(#pre_visited, 2, "pre_entities visits 2 layers (surface, topsurface)")
engine.test.equals(pre_visited[1], "surface", "pre_entities[0] = surface")
engine.test.equals(pre_visited[2], "topsurface", "pre_entities[1] = topsurface")
-- iterate_layers_post_entities should visit wall layers if present
local post_visited = {}
maps.iterate_layers_post_entities(function(layer_name)
post_visited[#post_visited + 1] = layer_name
end)
engine.test.equals(#post_visited, 1, "post_entities visits 1 layer (wall)")
engine.test.equals(post_visited[1], "wall", "post_entities[0] = wall")
-- draw_map should not raise (no actual render here; just call-through)
-- We're not in a render-phase, so this will be a no-op or error-safe.
-- Skip actual draw_map test; integration test happens in spine-prototype manual run.
-- ---------- Task 9: v1 map auto-migration on load ----------
-- The existing demo.map.json is v1-format. Loading should auto-upgrade.
local v1_id = maps.load("maps/demo.map.json")
engine.test.equals(v1_id, "demo", "v1 demo map loaded")
maps.set_current(v1_id)
maps.load_textures(aliases)
engine.test.equals(maps.atlas_count(), 1, "v1 auto-upgrade has 1 atlas")
engine.test.equals(maps.atlas_id_at(0), "demo_tilemap", "v1 atlas[0] = demo_tilemap")
engine.test.assert(maps.has_layer("surface"), "v1 auto-upgrade has surface layer")
engine.test.assert(not maps.has_layer("wall"), "v1 has no wall layer")
-- Walkability via v2-API should match v1-API result (atlas records now loaded)
engine.test.assert(maps.is_walkable(5, 5), "v1 (5,5) walkable (was grass in v1 test)")
engine.test.assert(not maps.is_walkable(0, 0), "v1 (0,0) not walkable (was stone in v1 test)")
-- Geometry assertions (demo map, already current after load_textures block)
local size = maps.size()
engine.test.equals(size.w, 16, "map width = 16")
engine.test.equals(size.h, 16, "map height = 16")
engine.test.equals(maps.tile_size(), 32, "tile_size = 32")
-- Tile lookup + walkability
engine.test.equals(maps.tile_at(0, 0).id, "stone", "tile_at(0,0) is stone")
engine.test.equals(maps.tile_at(5, 5).id, "grass", "tile_at(5,5) is grass")
-- Tile lookup + walkability (post-M.2: tile.id is int, tile.name is string)
engine.test.equals(maps.tile_at(0, 0).name, "stone", "tile_at(0,0) name = stone")
engine.test.equals(maps.tile_at(5, 5).name, "grass", "tile_at(5,5) name = grass")
engine.test.assert(not maps.is_walkable(0, 0), "stone border non-walkable")
engine.test.assert(maps.is_walkable(5, 5), "grass interior walkable")
-- Arity-flex tile_at: 3-arg with nil map_id falls back to current_map (P.2.1)
engine.test.equals(maps.tile_at(nil, 5, 5).id, "grass", "tile_at 3-arg nil-fallback")
engine.test.equals(maps.tile_at(nil, 5, 5).name, "grass", "tile_at 3-arg nil-fallback name = grass")
-- Out-of-bounds returns nil
engine.test.equals(maps.tile_at(-1, 0), nil, "tile_at OOB negative tx returns nil")
engine.test.equals(maps.tile_at(0, 16), nil, "tile_at OOB ty=size.h returns nil")
-- list() returns registered map-id
-- list() returns registered map-ids (demo + demo_v2 loaded earlier)
local list = maps.list()
engine.test.equals(#list, 1, "maps.list returns 1 registered map")
engine.test.equals(#list, 2, "maps.list returns 2 registered maps")
-- ---------- v0.4.0: Write APIs — set_cell_gid ----------
-- Build a fresh v2 map for write tests (separate from earlier load-based tests).
-- maps.create only handles the v1 path (requires tilemap_table); use maps.load
-- with a fixture file for the v2 path instead.
local write_id = maps.load("maps/write_test.map.json")
maps.set_current(write_id)
-- Happy path
local gid_painted = maps.encode_gid(0, 1, 0)
maps.set_cell_gid("surface", 1, 2, gid_painted)
engine.test.equals(maps.cell_gid("surface", 1, 2), gid_painted,
"set_cell_gid writes the cell that cell_gid reads back")
-- Bounds-error: x >= w
local ok1, err1 = pcall(function() maps.set_cell_gid("surface", 4, 0, gid_painted) end)
engine.test.assert(not ok1, "set_cell_gid rejects x out of bounds")
engine.test.assert(err1 and err1:find("bounds"), "set_cell_gid bounds-error mentions 'bounds'")
-- Bounds-error: negative coord
local ok2 = pcall(function() maps.set_cell_gid("surface", -1, 0, gid_painted) end)
engine.test.assert(not ok2, "set_cell_gid rejects negative x")
-- Unknown-layer error
local ok3, err3 = pcall(function() maps.set_cell_gid("bogus_layer", 0, 0, gid_painted) end)
engine.test.assert(not ok3, "set_cell_gid rejects unknown layer")
engine.test.assert(err3 and err3:find("layer"), "unknown-layer error mentions 'layer'")
-- On-demand layer allocation
engine.test.assert(maps.cell_gid("wall", 0, 0) == 0,
"wall layer empty before any write")
maps.set_cell_gid("wall", 2, 2, gid_painted)
engine.test.equals(maps.cell_gid("wall", 2, 2), gid_painted,
"set_cell_gid auto-allocates the wall layer")
engine.test.equals(maps.cell_gid("wall", 0, 0), 0,
"auto-allocated layer initialised to all-zero except the written cell")
-- ---------- v0.4.0: set_roof ----------
-- Roof should be nil before any write. No direct cell_roof reader exists yet
-- (out of scope for v0.4.0). Verify behaviourally via JSON round-trip in Task 3.
-- Happy path: set roof cells, no assertion on read since no public reader
maps.set_roof(1, 1, 1)
maps.set_roof(1, 1, 0) -- toggle back
maps.set_roof(2, 2, 1) -- leave this one set for Task 3's round-trip test
-- Value validation
local ok_v, err_v = pcall(function() maps.set_roof(0, 0, 2) end)
engine.test.assert(not ok_v, "set_roof rejects value=2")
engine.test.assert(err_v and err_v:find("0.*1"), "set_roof error mentions 0 or 1")
-- Bounds rejection
local ok_b = pcall(function() maps.set_roof(4, 0, 1) end)
engine.test.assert(not ok_b, "set_roof rejects x out of bounds")
-- ---------- v0.4.0: save_to_disk ----------
-- Map state from Task 1+2: surface(1,2) = gid_painted, wall(2,2) = gid_painted,
-- roof(2,2) = 1. Save and re-load to verify round-trip.
-- Re-establish deterministic state (defensive — earlier asserts may have toggled).
maps.set_cell_gid("surface", 1, 2, gid_painted)
maps.set_cell_gid("wall", 2, 2, gid_painted)
maps.set_roof(2, 2, 1)
-- Pick a temp path. Use TEMP env var for Windows compatibility; fall back to
-- os.tmpname() on POSIX (where it returns a writable path).
local tmp_dir = os.getenv("TEMP") or os.getenv("TMP") or "/tmp"
-- Unique suffix via os.clock() + os.time() to avoid collisions.
local tmp_suffix = string.format("sporel_maps_test_%d_%d", os.time(), math.floor(os.clock() * 1e6) % 1000000)
local out_path = tmp_dir .. "/" .. tmp_suffix .. "_1.json"
local out_path_2 = tmp_dir .. "/" .. tmp_suffix .. "_2.json"
local rl_path = tmp_dir .. "/" .. tmp_suffix .. "_rl.json"
maps.save_to_disk("write_test", out_path)
-- Re-read raw bytes for byte-determinism test
local f1 = io.open(out_path, "r")
engine.test.assert(f1 ~= nil, "save_to_disk wrote a readable file")
local bytes_1 = f1:read("*a")
f1:close()
-- Save a second time; bytes must match exactly
maps.save_to_disk("write_test", out_path_2)
local f2 = io.open(out_path_2, "r")
local bytes_2 = f2:read("*a")
f2:close()
engine.test.equals(bytes_1, bytes_2,
"save_to_disk is byte-deterministic across calls")
-- Verify JSON has expected keys
engine.test.assert(bytes_1:find('"schema_version"') ~= nil, "saved file has schema_version key")
engine.test.assert(bytes_1:find('"surface"') ~= nil, "saved file has surface key")
engine.test.assert(bytes_1:find('"wall"') ~= nil, "saved file has wall key (auto-allocated)")
engine.test.assert(bytes_1:find('"roof"') ~= nil, "saved file has roof key (set in Task 2)")
-- Re-load (with patched id to avoid registry collision).
-- engine.asset.load_json only accepts relative (sandboxed) paths, so write
-- the round-trip file into the maps-test lib's own maps/ directory where
-- the asset resolver can find it by relative path.
local lib_dir = engine.module.dir_of("lib-core.maps-test")
local rewritten = bytes_1:gsub('"id"%s*:%s*"write_test"', '"id":"write_test_reloaded"')
local abs_rl_path = lib_dir .. "/maps/write_test_reloaded.map.json"
local rf = io.open(abs_rl_path, "w"); rf:write(rewritten); rf:close()
maps.load("maps/write_test_reloaded.map.json")
engine.test.equals(maps.cell_gid("surface", 1, 2, "write_test_reloaded"), gid_painted,
"save round-trip preserves surface cell")
engine.test.equals(maps.cell_gid("wall", 2, 2, "write_test_reloaded"), gid_painted,
"save round-trip preserves wall cell")
-- Cleanup
os.remove(out_path); os.remove(out_path_2); os.remove(abs_rl_path)
-- =====================================================================
-- 0.5.7: Cell-Tile material API tests (maps_t1)
-- =====================================================================
-- Reload the write_test map for a clean slate.
maps.set_current("write_test")
-- maps_t1: round-trip set/get
maps.set_cell_material("surface", 1, 1, true)
engine.test.equals(maps.get_cell_material("surface", 1, 1), true,
"maps_t1: set_cell_material then get_cell_material round-trips")
maps.set_cell_material("surface", 1, 1, false)
engine.test.equals(maps.get_cell_material("surface", 1, 1), false,
"maps_t1: set_cell_material(false) clears the flag")
-- Out-of-bounds rejection
local ok_oob, err_oob = pcall(function()
maps.set_cell_material("surface", -1, 0, true)
end)
engine.test.assert(not ok_oob,
"maps_t1: set_cell_material rejects negative x")
engine.test.assert(err_oob and err_oob:find("bounds"),
"maps_t1: bounds-error mentions 'bounds'")
-- Unknown-layer rejection
local ok_lay = pcall(function()
maps.set_cell_material("bogus_layer", 0, 0, true)
end)
engine.test.assert(not ok_lay,
"maps_t1: set_cell_material rejects unknown layer")
-- =====================================================================
-- maps_t2-t7: Cell-Tile slot-resolution proofs
-- =====================================================================
-- write_test is 4x4. Use v2_size for w/h reference (already declared earlier).
-- maps_t2: cell_material_slot returns a non-nil record for a
-- cells_material cell.
-- Clear the surface layer's cells_material first.
for cy = 0, v2_size.h - 1 do
for cx = 0, v2_size.w - 1 do
maps.set_cell_material("surface", cx, cy, false)
end
end
maps.set_cell_material("surface", 1, 1, true)
local rec = maps.cell_material_slot("surface", 1, 1)
engine.test.assert(rec ~= nil,
"maps_t2: cell_material_slot returns non-nil for cell-tile material")
-- maps_t3: isolated slot reachable for the first time.
-- Clear cells_material AND vertices, place an isolated paint at (2, 2).
for cy = 0, v2_size.h - 1 do
for cx = 0, v2_size.w - 1 do
maps.set_cell_material("surface", cx, cy, false)
maps.set_vertex("surface", cx, cy, false)
end
maps.set_vertex("surface", v2_size.w, cy, false)
end
for cx = 0, v2_size.w do maps.set_vertex("surface", cx, v2_size.h, false) end
maps.set_cell_material("surface", 2, 2, true)
local r3 = maps.cell_material_slot("surface", 2, 2)
engine.test.equals(r3.slot, 0, "maps_t3: slot 0 isolated reachable for first time")
engine.test.equals(r3.rot, 0, "maps_t3: isolated rot=0")
-- maps_t4: two adjacent cells → two end-caps facing each other.
maps.set_cell_material("surface", 1, 2, true)
maps.set_cell_material("surface", 2, 2, true)
local r4a = maps.cell_material_slot("surface", 1, 2)
local r4b = maps.cell_material_slot("surface", 2, 2)
engine.test.equals(r4a.slot, 1, "maps_t4: left cell is end (slot 1)")
engine.test.equals(r4a.rot, 1, "maps_t4: left end points east (rot 1)")
engine.test.equals(r4b.slot, 1, "maps_t4: right cell is end (slot 1)")
engine.test.equals(r4b.rot, 3, "maps_t4: right end points west (rot 3)")
-- maps_t5: 3-cell line → middle is straight.
maps.set_cell_material("surface", 0, 2, true)
-- already painted (1,2) and (2,2) from t4
local r5 = maps.cell_material_slot("surface", 1, 2)
engine.test.equals(r5.slot, 4, "maps_t5: middle of 3-cell line is straight (slot 4)")
-- rot may be 1 or 3 (horizontal canonical); accept both
engine.test.assert(r5.rot == 1 or r5.rot == 3,
"maps_t5: straight is horizontal (rot 1 or 3)")
-- Reset surface for t6.
for cx = 0, v2_size.w - 1 do
maps.set_cell_material("surface", cx, 2, false)
end
-- maps_t6: T-junction at (1, 2) with arms W (0, 2), E (2, 2), S (1, 3).
-- write_test is 4x4 so (1, 3) is the last row.
maps.set_cell_material("surface", 0, 2, true)
maps.set_cell_material("surface", 1, 2, true)
maps.set_cell_material("surface", 2, 2, true)
maps.set_cell_material("surface", 1, 3, true)
local r6 = maps.cell_material_slot("surface", 1, 2)
engine.test.equals(r6.slot, 5, "maps_t6: T-junction is tee_open (slot 5)")
-- Reset for t7.
for cy = 0, v2_size.h - 1 do
for cx = 0, v2_size.w - 1 do
maps.set_cell_material("surface", cx, cy, false)
end
end
-- maps_t7: 4-cell "+" at (1, 1) with N (1, 0), E (2, 1), S (1, 2), W (0, 1).
maps.set_cell_material("surface", 1, 1, true)
maps.set_cell_material("surface", 1, 0, true)
maps.set_cell_material("surface", 2, 1, true)
maps.set_cell_material("surface", 1, 2, true)
maps.set_cell_material("surface", 0, 1, true)
local r7 = maps.cell_material_slot("surface", 1, 1)
engine.test.equals(r7.slot, 8, "maps_t7: cross is cross_open (slot 8) — second previously-unreachable slot reachable")
-- Reset surface fully so later tests (if any added) start clean.
for cy = 0, v2_size.h - 1 do
for cx = 0, v2_size.w - 1 do
maps.set_cell_material("surface", cx, cy, false)
end
end
-- =====================================================================
-- maps_t8: cells_material round-trips through save_to_disk + reload
-- =====================================================================
-- Use a unique temp path so we don't collide with the earlier write_test save.
local cm_path = tmp_dir .. "/" .. tmp_suffix .. "_cm.json"
local lib_dir2 = engine.module.dir_of("lib-core.maps-test")
local cm_rl_abs = lib_dir2 .. "/maps/write_test_cm_reloaded.map.json"
maps.set_current("write_test")
maps.set_cell_material("surface", 2, 1, true)
maps.save_to_disk("write_test", cm_path)
local f = io.open(cm_path, "r")
local cm_bytes = f:read("*a"); f:close()
engine.test.assert(cm_bytes:find('"cells_material"') ~= nil,
"maps_t8: cells_material appears in serialized JSON when set")
-- Patch id + relocate so the load resolver can find it.
local cm_rewritten = cm_bytes:gsub('"id"%s*:%s*"write_test"',
'"id":"write_test_cm_reloaded"')
local rf = io.open(cm_rl_abs, "w"); rf:write(cm_rewritten); rf:close()
maps.load("maps/write_test_cm_reloaded.map.json")
engine.test.equals(maps.get_cell_material("surface", 2, 1, "write_test_cm_reloaded"), true,
"maps_t8: cells_material round-trips through save+load")
-- Cleanup
os.remove(cm_path); os.remove(cm_rl_abs)
-- =====================================================================
-- maps_t9: byte-determinism regression — a map with no cells_material
-- writes byte-identical output through the 0.5.7 serializer as it did
-- under 0.5.6.
-- =====================================================================
-- Reset surface cells_material so the save contains no cell-tile data.
maps.set_current("write_test")
for cy = 0, v2_size.h - 1 do
for cx = 0, v2_size.w - 1 do
maps.set_cell_material("surface", cx, cy, false)
end
end
local bytes_a_path = tmp_dir .. "/" .. tmp_suffix .. "_a.json"
local bytes_b_path = tmp_dir .. "/" .. tmp_suffix .. "_b.json"
maps.save_to_disk("write_test", bytes_a_path)
maps.save_to_disk("write_test", bytes_b_path)
local fa = io.open(bytes_a_path, "r"); local bytes_a = fa:read("*a"); fa:close()
local fb = io.open(bytes_b_path, "r"); local bytes_b = fb:read("*a"); fb:close()
engine.test.equals(bytes_a, bytes_b,
"maps_t9: byte-deterministic save with no cells_material content")
engine.test.assert(bytes_a:find('"cells_material"') == nil,
"maps_t9: cells_material key absent when all entries are zero")
os.remove(bytes_a_path); os.remove(bytes_b_path)
engine.exit(engine.test.failures())
end

View File

@@ -1,9 +1,9 @@
{
"id": "lib-core.maps-test",
"role": "test",
"version": "0.1.0",
"version": "0.5.5",
"api_min": "0.1",
"deps": [
{"id": "lib-core.maps", "version": "0.1.2"}
{"id": "lib-core.maps", "version": "0.5.7"}
]
}

38
maps/demo_v2.map.json Normal file
View File

@@ -0,0 +1,38 @@
{
"schema_version": 2,
"id": "demo_v2",
"size": { "w": 4, "h": 4 },
"atlases": ["demo_tilemap", "walls_tilemap"],
"layers": {
"surface": {
"tiles": [
32, 32, 32, 32,
32, 16, 16, 32,
32, 16, 16, 32,
32, 32, 32, 32
]
},
"wall": {
"tiles": [
16777232, 16777232, 16777232, 16777232,
16777232, 0, 0, 16777232,
16777232, 0, 0, 16777232,
16777232, 16777232, 16777232, 16777232
]
},
"topsurface": {
"tiles": [
0, 0, 0, 0,
0, 0, 16, 0,
0, 0, 0, 0,
0, 0, 0, 0
]
}
},
"roof": [
0, 0, 0, 0,
0, 1, 1, 0,
0, 1, 1, 0,
0, 0, 0, 0
]
}

24
maps/write_test.map.json Normal file
View File

@@ -0,0 +1,24 @@
{
"schema_version": 3,
"id": "write_test",
"size": { "w": 4, "h": 4 },
"atlases": ["demo_tilemap"],
"layers": {
"surface": {
"material": "demo_tilemap",
"vertices": [
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0
],
"tiles": [
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
]
}
}
}

19
scripts/bake.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Re-bake the stub atlases used by maps-test fixtures.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BAKER=~/Projects/Sporel/sporel-tool-atlas-baker/bin/atlas-baker.js
node "$BAKER" \
--in "$LIB_DIR/assets/_sources/demo_tilemap" \
--out "$LIB_DIR/assets/atlases/demo_tilemap" \
--atlas-id demo_tilemap \
--tile-size 32
node "$BAKER" \
--in "$LIB_DIR/assets/_sources/walls_tilemap" \
--out "$LIB_DIR/assets/atlases/walls_tilemap" \
--atlas-id walls_tilemap \
--tile-size 32 \
--blocks-sight-pattern '^wall_'