Files
feuerwerk/tests/core/tile-access.test.ts
T
adminandClaude Opus 5.5 9b5eeaab57 Empty a deleted tile's cells on every tilemap (#268)
Deleting a tile left the cells painted with it in the tilemaps. They
drew nothing, blocked nothing and did not show in the delete dialog,
but still read as a tile for "Tile at" and stopped a fill.

- DeleteAssetCommand empties the cells of a deleted tile on every
  layer of every tilemap in the same step. Undo puts the tile and its
  cells back together.
- The delete dialog counts them, for example "3 cell(s) on 1
  tilemap(s) are painted with it and become empty." (cellsPainted).
- presentTile treats a cell naming a missing tile as empty. "Tile at"
  (World.tileAt) and floodFill use it, so cells already in existing
  files read and fill as empty. There is no migration.
- The brush, the rectangle and the fill check the tile in hand. A
  tile deleted after it was picked no longer paints new invisible
  cells, the editor says "Choose a tile to paint with." instead.
- Unchanged on purpose: the picker still reports such a cell as
  naming a tile that no longer exists, and erasing removes it.

Tests:
- ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6).
- GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t).
- Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4).
- Counter-checks, all fail as expected: 5 in unit tests (delete
  without emptying, undo without cells, fill and "Tile at" without
  presentTile, wrong map count) and 3 in the browser (dialog without
  count, delete without emptying, brush without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:00:22 +02:00

251 lines
12 KiB
TypeScript

import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import {
AddTilemapLayerCommand,
PaintTilesCommand,
RenameTilemapLayerCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import {
cellToWorld,
layerNamed,
liveLayers,
liveTileAt,
withTile,
worldToCell,
} from '../../src/core/model/liveTilemap';
import { transformMatrix, translation } from '../../src/core/model/matrix';
import { findAsset } from '../../src/core/model/Project';
import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, tilemapLayerNames } from '../../src/core/model/tilemap';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers';
const layer = (id: string, name: string, cells: Record<string, string> = {}) => ({ id, name, cells });
const asset = (): TilemapAsset => ({
id: 'map',
type: 'tilemap',
name: 'Map',
props: {},
layers: [layer('l1', 'Ground', { '0,0': 'grass', '1,0': 'grass' }), layer('l2', 'Top'), layer('l3', 'Top')],
});
const GRID = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 2, tileSize: 10 };
describe('the cells a run sees (#135)', () => {
it('a layer is found by its name, no name is the first, two of one name are the upper one', () => {
const map = asset();
expect(layerNamed(map, 'Ground')?.id).toBe('l1');
expect(layerNamed(map, '')?.id).toBe('l1');
expect(layerNamed(map, 'Top')?.id).toBe('l2');
expect(layerNamed(map, 'ground')).toBeUndefined();
expect(layerNamed({ ...map, layers: [] }, '')).toBeUndefined();
});
it('edits lie over the cells, an empty one erasing, and leave the asset alone', () => {
const map = asset();
let edits = withTile(map, undefined, GRID, 'Ground', { x: 2, y: 1 }, 'water')!;
edits = withTile(map, edits, GRID, '', { x: 0, y: 0 }, '')!;
expect(edits).toEqual({ l1: { '2,1': 'water', '0,0': '' } });
expect(liveLayers(map, edits)[0]!.cells).toEqual({ '1,0': 'grass', '2,1': 'water' });
// The other layers are the asset's own, untouched.
expect(liveLayers(map, edits)[1]).toBe(map.layers[1]);
expect(map.layers[0]!.cells).toEqual({ '0,0': 'grass', '1,0': 'grass' });
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 2, y: 1 })).toBe('water');
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 0, y: 0 })).toBe('');
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 1, y: 0 })).toBe('grass');
});
it('a cell off the grid or on a missing layer cannot be read or set; setting what is there changes nothing', () => {
const map = asset();
for (const cell of [
{ x: 4, y: 0 },
{ x: 0, y: 2 },
{ x: -1, y: 0 },
]) {
expect(liveTileAt(map, undefined, GRID, '', cell)).toBeNull();
expect(withTile(map, undefined, GRID, '', cell, 'water')).toBeNull();
}
expect(withTile(map, undefined, GRID, 'Sky', { x: 0, y: 0 }, 'water')).toBeNull();
const edits = withTile(map, undefined, GRID, '', { x: 3, y: 1 }, 'water')!;
expect(withTile(map, edits, GRID, '', { x: 3, y: 1 }, 'water')).toBe(edits);
});
it('offers the layer names of every tilemap, each once', () => {
const f = makeSpriteScene('Hero');
const a = new CreateAssetCommand('tilemap', 'A');
const b = new CreateAssetCommand('tilemap', 'B');
f.stack.execute(a);
f.stack.execute(b);
const add = new AddTilemapLayerCommand(b.asset.id);
f.stack.execute(add);
f.stack.execute(new RenameTilemapLayerCommand(b.asset.id, add.layerId, 'Walls'));
expect(tilemapLayerNames(f.project)).toEqual(['Layer 1', 'Walls']);
});
});
describe('world and cell (#135)', () => {
it('a map moved by (100, 50): cells from the top-left corner, the middle of each cell back', () => {
const m = translation(100, 50);
// 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60).
expect(worldToCell(GRID, m, { x: 80, y: 40 })).toEqual({ x: 0, y: 0, inside: true });
expect(worldToCell(GRID, m, { x: 119.9, y: 59.9 })).toEqual({ x: 3, y: 1, inside: true });
expect(worldToCell(GRID, m, { x: 120, y: 50 })).toEqual({ x: 4, y: 1, inside: false });
expect(worldToCell(GRID, m, { x: 79, y: 39 })).toEqual({ x: -1, y: -1, inside: false });
expect(cellToWorld(GRID, m, { x: 0, y: 0 })).toEqual({ x: 85, y: 45 });
expect(cellToWorld(GRID, m, { x: 3, y: 1 })).toEqual({ x: 115, y: 55 });
});
it('a turned, scaled and mirrored map: every cell middle comes back to its cell', () => {
for (const m of [
transformMatrix(30, -20, 90, 2, false, false),
transformMatrix(-7, 3, 33, 0.5, true, false),
transformMatrix(0, 0, 180, 3, false, true),
])
for (let y = 0; y < 2; y++)
for (let x = 0; x < 4; x++)
expect(worldToCell(GRID, m, cellToWorld(GRID, m, { x, y }))).toEqual({ x, y, inside: true });
});
it('turned a quarter clockwise and doubled, the top-left cell lies to the right of the origin, at the top', () => {
const m = transformMatrix(0, 0, 90, 2, false, false);
// Its middle (-15, -5) in the map's frame turns to (5, -15) and doubles to (10, -30).
const middle = cellToWorld(GRID, m, { x: 0, y: 0 });
expect(middle.x).toBeCloseTo(10);
expect(middle.y).toBeCloseTo(-30);
expect(worldToCell(GRID, m, { x: 10, y: -30 })).toEqual({ x: 0, y: 0, inside: true });
// Scaled to nothing, there are no cells to fall into.
expect(worldToCell(GRID, transformMatrix(0, 0, 0, 0, false, false), { x: 0, y: 0 }).inside).toBe(false);
});
});
/** A 10 x 10 map of cells of 48 around the origin, placed twice, and a hero with a collider on it. */
function setup() {
const f = makeSpriteScene('Hero');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40));
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40));
const wall = new CreateAssetCommand('tile', 'Wall');
const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [wall, map]) f.stack.execute(command);
f.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
const first = place(f, map.asset.id, { x: 0, y: 0 });
const second = place(f, map.asset.id, { x: 2000, y: 0 });
const hero = place(f, f.spriteId, { x: 0, y: 0 });
f.stack.execute(new AddColliderCommand(f.sceneId, hero));
const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset;
return { ...f, wallId: wall.asset.id, mapId: map.asset.id, first, second, hero, assetOf };
}
/** Cell column 6: its left face lies at x = 48, 28 to the right of the hero's edge. */
const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => ({ x: 6, y }));
describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
it('blocks at once, only on its own placement, and never reaches the asset', () => {
const s = setup();
const { world, collisions } = startWorld(s, { physics, collisions: true });
const map = world.findByObjectId(s.first)!;
const other = world.findByObjectId(s.second)!;
const hero = world.findByObjectId(s.hero)!;
for (const cell of COLUMN_SIX) expect(world.setTile(map, '', cell, s.wallId)).toBe(true);
// No tick in between: the move already meets the new wall.
expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(true);
expect(hero.x).toBeCloseTo(28, 9);
expect(world.tileAt(map, 'Layer 1', { x: 6, y: 3 })).toBe(s.wallId);
expect(world.tileAt(other, 'Layer 1', { x: 6, y: 3 })).toBe('');
expect(s.assetOf().layers[0]!.cells).toEqual({});
// Emptied again, the way is free.
for (const cell of COLUMN_SIX) world.setTile(map, '', cell, '');
expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(false);
});
it('refuses what it cannot set: no tile, no tilemap, no such layer, off the grid', () => {
const s = setup();
const { world } = startWorld(s, { physics, collisions: true });
const map = world.findByObjectId(s.first)!;
const hero = world.findByObjectId(s.hero)!;
expect(world.setTile(map, '', { x: 0, y: 0 }, s.spriteId)).toBe(false);
expect(world.setTile(hero, '', { x: 0, y: 0 }, s.wallId)).toBe(false);
expect(world.setTile(map, 'Sky', { x: 0, y: 0 }, s.wallId)).toBe(false);
expect(world.setTile(map, '', { x: 10, y: 0 }, s.wallId)).toBe(false);
expect(world.tileAt(hero, '', { x: 0, y: 0 })).toBeNull();
expect(world.isTilemap(map) && !world.isTilemap(hero)).toBe(true);
expect(world.takeTileChange()).toBe(false);
world.setTile(map, '', { x: 0, y: 0 }, s.wallId);
expect(world.takeTileChange()).toBe(true);
expect(world.takeTileChange()).toBe(false);
});
});
describe('a cell naming a tile the project does not have (#268)', () => {
it('reads as empty in a run, the way it is drawn', () => {
const s = setup();
const layerId = s.assetOf().layers[0]!.id;
s.stack.execute(
new PaintTilesCommand(s.mapId, layerId, [
{ x: 1, y: 1, tileId: 'gone' },
{ x: 2, y: 1, tileId: s.wallId },
]),
);
const { world } = startWorld(s);
const map = world.findByObjectId(s.first)!;
expect(world.tileAt(map, '', { x: 1, y: 1 })).toBe('');
expect(world.tileAt(map, '', { x: 2, y: 1 })).toBe(s.wallId);
});
});
describe('a tile set in a run is drawn (#135)', () => {
const cellsDrawn = (tilemap: { layers: { tiles: readonly { x: number; y: number }[] }[] } | null) =>
tilemap?.layers[0]!.tiles.map(({ x, y }) => `${x},${y}`);
it('in the played world, on its own placement only', () => {
const s = setup();
s.stack.execute(
new PaintTilesCommand(s.mapId, s.assetOf().layers[0]!.id, [{ x: 1, y: 1, tileId: s.wallId }]),
);
const { world } = startWorld(s);
world.setTile(world.findByObjectId(s.first)!, '', { x: 2, y: 1 }, s.wallId);
world.setTile(world.findByObjectId(s.first)!, '', { x: 1, y: 1 }, '');
const items = collectWorldItems(world).items;
expect(cellsDrawn(items.find((i) => i.id === s.first)!.tilemap)).toEqual(['2,1']);
expect(cellsDrawn(items.find((i) => i.id === s.second)!.tilemap)).toEqual(['1,1']);
});
it('the autotile edges of the cells around it follow the live cells', () => {
const s = setup();
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_KIND_KEY, 'floor'));
const { world } = startWorld(s);
const map = world.findByObjectId(s.first)!;
const quarters = () =>
collectWorldItems(world)
.items.find((i) => i.id === s.first)!
.tilemap!.layers[0]!.tiles.find((t) => t.x === 2 && t.y === 1)!.quadrants;
world.setTile(map, '', { x: 2, y: 1 }, s.wallId);
const alone = quarters();
world.setTile(map, '', { x: 3, y: 1 }, s.wallId);
// A neighbour to the east turns the two right quarters from an edge into a joint.
expect(quarters()).not.toEqual(alone);
expect([quarters()[0], quarters()[2]]).toEqual([alone[0], alone[2]]);
world.setTile(map, '', { x: 3, y: 1 }, '');
expect(quarters()).toEqual(alone);
});
it('in the editor run over the scene it runs on, and nowhere else', () => {
const s = setup();
const { world } = startWorld(s);
world.setTile(world.findByObjectId(s.first)!, '', { x: 4, y: 4 }, s.wallId);
const drawn = (run: typeof world | null) =>
cellsDrawn(
collectHolderItems(s.project, s.sceneOf(), 0, run).items.find((i) => i.id === s.first)!.tilemap,
);
expect(drawn(world)).toEqual(['4,4']);
expect(drawn(null)).toEqual([]);
});
});