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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 19:00:22 +02:00
1 parent 14972d057f
commit 9b5eeaab57
11 files changed
+310 -38

No files matched your search

+95
View File
@@ -1291,6 +1291,101 @@ export async function tilemap(t) {
__gm.state.select(null)`);
await settle();
// 40t. Deleting a tile that is painted (#268): the dialog counts its cells, the same step empties
// them, a brush still holding the tile paints nothing, and an undo brings tile and cells back
// together. A tile of its own on the empty bottom row, so the cells above stay as they are.
await mutate(`
const assets = __gm.state.project.assets;
const doomed = structuredClone(assets.find((a) => a.id === 'at-grass'));
Object.assign(doomed, { id: 'tm-doomed', name: 'Doomed' });
assets.push(doomed);`);
const selectMap = async () => {
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
await evaluate(`__gm.lookAt(0, ${LANE})`);
await settle();
};
const doomedCells = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers` +
".flatMap((l) => Object.keys(l.cells).filter((key) => l.cells[key] === 'tm-doomed')).sort().join(' ')",
);
await selectMap();
await clickInPanel(t, '__gm.tilemap.tileButtonAt("tm-doomed")');
await clickInPanel(t, '__gm.tilemap.toolAt("brush")');
await settle();
const rowStart = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 3)`);
const rowEnd = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 3)`);
await drag(rowStart.x, rowStart.y, rowEnd.x, rowEnd.y);
await settle();
const paintedRow = await doomedCells();
const doomedRow = await json("__gm.assetRow('tm-doomed')");
await click(doomedRow.x, doomedRow.y, 'right');
await settle();
const deleteEntry = await t.locate('Delete…', true);
await click(deleteEntry.x, deleteEntry.y);
await settle();
const counted = await t.has('3 cell(s) on 1 tilemap(s) are painted with it and become empty.');
await shot('40-delete-painted-tile');
check(
'the delete dialog of a painted tile counts its cells and the maps they lie on',
paintedRow === '0,3 1,3 2,3' && counted,
JSON.stringify({ paintedRow, overlay: await json('__gm.overlayTexts()') }),
);
const confirm = await t.locate('Delete', true);
await click(confirm.x, confirm.y);
await settle();
const afterDelete = {
tile: await json("__gm.state.project.assets.some((a) => a.id === 'tm-doomed')"),
cells: await doomedCells(),
};
check(
'deleting the tile empties its cells in the same step',
afterDelete.tile === false && afterDelete.cells === '',
JSON.stringify(afterDelete),
);
// The brush still holds the deleted tile; a click with it on a cell never painted must leave no
// such cell behind.
await selectMap();
await clickInPanel(t, '__gm.tilemap.toolAt("brush")');
await settle();
const inHand = await json('__gm.tilemap.paint()');
const untouched = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 3)`);
await click(untouched.x, untouched.y);
await settle();
const afterClick = {
inHand,
cell: await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells['4,3'] ?? '')`,
),
notice: await json('__gm.state.notice'),
};
check(
'a brush still holding the deleted tile paints nothing and asks for a tile',
inHand.tool === 'brush' &&
inHand.tileId === 'tm-doomed' &&
afterClick.cell.every((id) => id === '') &&
afterClick.notice === 'Choose a tile to paint with.',
JSON.stringify(afterClick),
);
await undo();
await settle();
const restored = {
tile: await json("__gm.state.project.assets.some((a) => a.id === 'tm-doomed')"),
cells: await doomedCells(),
};
check(
'an undo brings the tile and its cells back together',
restored.tile === true && restored.cells === '0,3 1,3 2,3',
JSON.stringify(restored),
);
// The stroke goes the same way, and the tile the mutate brought in goes as it came.
await undo();
await settle();
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'tm-doomed');`);
await settle();
// Leave the editor as the rest of the suite expects to find it.
await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })");
await evaluate('__gm.state.select(null)');