Files
feuerwerk/tests/core/tilemap-patch.test.ts
T
adminandClaude Opus 5.5 5c0d52da66
feuerwerk / gate (push) Successful in 8m24s
feuerwerk / suite (push) Successful in 20m19s
Let a behavior judge a key press against the beat of the music (#438)
A rhythm game has to know how far a press lay from the beat. Input only
kept that a key went down during a frame, so a press was known to a
frame (16.7 ms) at best. Hit windows are ±20 to 50 ms.

- Input and the game pointer keep the time stamp of each real press.
  Auto repeat of a held key does not count.
- The new event node On key hit hears a key or a click. It gives the
  offset to the nearest point of the grid (beat, 1/8 or 1/16), with
  bar, beat and the heard second. A calibration input moves the press.
- The SoundLibrary maps the press onto the heard song position through
  getOutputTimestamp, minus the limiter. Safari lacks it, there the
  audio clock minus the latency is wound back to the press.
- Playbacks.times takes the heard second as a parameter, so the song
  position can be asked for a moment in the past.
- A pattern with swing puts its grid points where the swing moves the
  notes. The end of a pattern without loop is no grid point.
- A press older than 0.25 s when its frame comes is dropped, which
  covers presses during the toolbar pause. Exactly half way between two
  points counts as late for the first one.
- Without music playing no event comes, as decided in the issue.
- The 400 step test in tilemap-patch.test.ts gets a 30 s limit. It took
  7.45 s in gate 761 while the suite shared the cores of stein2.

Tests: 23 new unit tests (key-hit 19, sound-library 1, audio-switch 1,
catalog and event count). 23 mutations, all red. e2e 65 ALL PASSED,
79 checks. In the new 65k7 three presses of H in one frame lie 4.4 and
4.1 ms apart, and so do their offsets. Without the time stamps all
three get the same offset and only 65k7 goes red. Gate 5251 of 5252
green, the one red count in event-order.test.ts is fixed and checked
on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:54:55 +01:00

389 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TilemapAsset, TilemapLayer } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import type { PropertyValue } from '../../src/core/model/Properties';
import {
TILE_COLLISION_SCALE_KEY,
TILE_FRICTION_KEY,
TILE_KIND_KEY,
TILE_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY,
TILE_SOLID_KEY,
TILE_TRIGGER_KEY,
} from '../../src/core/model/tile';
import {
cellKey,
cellsAround,
layerTiles,
patchedInOrder,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
type TileDraw,
} from '../../src/core/model/tilemap';
import {
oneWayAngles,
solidPolygons,
solidRects,
tileMaterials,
triggerCells,
triggerPolygons,
triggerRects,
} from '../../src/core/model/tilemapCollision';
import {
cellsChanged,
cellsChangedSince,
cellsTileIds,
cellsVersion,
} from '../../src/core/model/tilemapMemo';
import { layerRows } from '../../src/viewport/drawOrder';
import type { TilemapDraw, TilemapLayerDraw } from '../../src/viewport/items';
import { groupByTile } from '../../src/viewport/tilemapMesh';
import { makeSpriteScene } from '../helpers';
const COLUMNS = 12;
const ROWS = 9;
/**
* A 12 × 9 map of two layers and a tile of every kind that works out differently: plain and
* autotiles, with and without outlines, drawn in and out, one-way at two angles, of another
* surface, triggers, one that draws only – and an id no tile has.
*/
function patchMap() {
const f = makeSpriteScene('Thing');
const texture = new CreateAssetCommand('texture', 'Sheet');
f.stack.execute(texture);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 128;
sheet.height = 128;
const frame = (x: number, y: number, w: number, h: number) => {
const command = new AddFrameCommand(texture.asset.id, { x, y, w, h });
f.stack.execute(command);
return command.frameId;
};
const single = frame(0, 0, 32, 32);
const outlined = frame(0, 32, 64, 96);
const bare = frame(64, 32, 64, 96);
f.stack.execute(
new SetFrameCollisionCommand(texture.asset.id, single, [
[
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 0, y: 32 },
],
]),
);
f.stack.execute(
new SetFrameCollisionCommand(texture.asset.id, outlined, [
[
{ x: 0, y: 0 },
{ x: 64, y: 0 },
{ x: 0, y: 96 },
],
]),
);
const tile = (name: string, props: Record<string, PropertyValue>, picture?: string) => {
const command = new CreateAssetCommand('tile', name);
f.stack.execute(command);
const id = command.asset.id;
if (picture) f.stack.execute(new AddTilePictureCommand(id, picture));
f.stack.execute(new SetAssetPropertyCommand(id, TILE_KIND_KEY, 'none'));
for (const [key, value] of Object.entries(props))
f.stack.execute(new SetAssetPropertyCommand(id, key, value));
return id;
};
const solid = { [TILE_SOLID_KEY]: true };
const floor = { [TILE_KIND_KEY]: 'floor' };
const tiles = [
tile('Slope', solid, single),
tile('Lake', { ...solid, ...floor }, outlined),
tile('Grass', { ...solid, ...floor, [TILE_COLLISION_SCALE_KEY]: 0.5 }, bare),
tile('Wall', solid),
tile('Bush', { ...solid, [TILE_COLLISION_SCALE_KEY]: 0.5 }),
tile('Big', { ...solid, [TILE_COLLISION_SCALE_KEY]: 1.5 }),
tile('Ledge', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 30 }),
tile('Steep', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 60 }, bare),
tile('Ice', { ...solid, [TILE_FRICTION_KEY]: 0 }),
tile('Water', { [TILE_TRIGGER_KEY]: true, ...floor }, bare),
tile('Spikes', { [TILE_TRIGGER_KEY]: true }, single),
tile('Deco', floor, bare),
'no-such-tile',
];
const map = new CreateAssetCommand('tilemap', 'Ground');
f.stack.execute(map);
for (const [key, value] of [
[TILEMAP_COLUMNS_KEY, COLUMNS],
[TILEMAP_ROWS_KEY, ROWS],
[TILEMAP_TILE_KEY, 32],
] as const)
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, key, value));
const asset = findAsset(f.project, map.asset.id) as TilemapAsset;
asset.layers.push({ id: 'upper', name: 'Upper', cells: {} });
return { ...f, asset, tiles };
}
/** A small seeded generator, so a failing walk can be walked again. */
function random(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Everything a map works out, as plain data: tiles per layer, solids per filter, triggers. */
function workedOut(m: ReturnType<typeof patchMap>, asset: TilemapAsset) {
const props = m.asset.props;
const solidsOf = (of: TilemapAsset) => ({
rects: solidRects(m.project, props, of),
polygons: solidPolygons(m.project, props, of),
oneWay: oneWayAngles(m.project, of).map((angle) => ({
angle,
rects: solidRects(m.project, props, of, angle),
polygons: solidPolygons(m.project, props, of, angle),
})),
materials: tileMaterials(m.project, of).map((material) => ({
material,
rects: solidRects(m.project, props, of, false, material),
polygons: solidPolygons(m.project, props, of, false, material),
})),
triggerRects: triggerRects(m.project, props, of),
triggerPolygons: triggerPolygons(m.project, props, of),
triggerCells: [...triggerCells(m.project, props, of)],
});
return {
tiles: asset.layers.map((layer) => layerTiles(m.project, props, layer)),
whole: solidsOf(asset),
perLayer: asset.layers.map((layer) => solidsOf({ ...asset, layers: [layer] })),
};
}
/**
* A layer's tiles as the meshes take them (#424): grouped by tile, and by sort row and tile with
* offsets that move some tiles a row up or down. Each next to the same grouping of a copy of the
* list, which nothing was patched from; `reused` counts the groups handed on from the last call.
*/
function grouping(m: ReturnType<typeof patchMap>, layer: TilemapLayer, last: Set<readonly unknown[]>) {
const draw = {
tiles: new Map(m.tiles.map((id, i) => [id, { sortOffset: (i % 4) - 1 }])),
} as unknown as TilemapDraw;
const tiles = layerTiles(m.project, m.asset.props, layer);
const of = (list: readonly TileDraw[]) => {
const rows = layerRows(draw, { tiles: list } as TilemapLayerDraw);
return {
byTile: [...groupByTile(list)],
byRow: [...rows].map(([row, cells]) => [row, cells, [...groupByTile(cells)]] as const),
};
};
const patched = of(tiles);
const groups = [
...patched.byTile.map(([, group]) => group),
...patched.byRow.flatMap(([, cells, byTile]) => [cells, ...byTile.map(([, group]) => group)]),
];
const reused = groups.filter((group) => last.has(group)).length;
return { patched, fresh: of([...tiles]), reused, groups: new Set<readonly unknown[]>(groups) };
}
/** The same map on cells never seen before, so nothing of it is patched. */
function afresh(asset: TilemapAsset): TilemapAsset {
return { ...asset, layers: asset.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })) };
}
describe('a painted map works out only the cells around what changed (#348)', () => {
it('knows which cells changed since a version, and when it does not know', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const first = cellsVersion(cells);
expect(cellsChangedSince(cells, first)).toEqual([]);
cells['2,0'] = 'a';
cellsChanged(cells, ['2,0']);
const second = cellsVersion(cells);
delete cells['0,0'];
cells['1,0'] = 'c';
// Named and not, the same: one walk finds what the painter left out.
cellsChanged(cells);
expect([...cellsChangedSince(cells, first)!].sort()).toEqual(['0,0', '1,0', '2,0']);
expect([...cellsChangedSince(cells, second)!].sort()).toEqual(['0,0', '1,0']);
expect(cellsTileIds(cells)).toEqual(['a', 'c']);
expect(cellsChangedSince(cells, -1)).toBeNull();
expect(cellsChangedSince({ ...cells }, second)).toBeNull();
});
it('a key named but written back as it was is no change', () => {
const cells: Record<string, string> = { '0,0': 'a' };
const first = cellsVersion(cells);
cells['0,0'] = 'b';
cells['0,0'] = 'a';
cellsChanged(cells, ['0,0', '5,5']);
expect(cellsVersion(cells)).toBe(first);
});
it('the cells around a change stay in the grid', () => {
expect(cellsAround(['0,0'], 3, 2)).toEqual([0, 1, 3, 4]);
expect(cellsAround(['2,1', '1,1'], 3, 2)).toEqual([0, 1, 2, 3, 4, 5]);
expect(cellsAround(['9,9', 'nonsense'], 3, 2)).toEqual([]);
});
it('a patch replaces the entries of the cells it works out, keeps the rest and their order', () => {
const entry = (at: number, name: string) => ({ at, name });
const list = [entry(1, 'a'), entry(3, 'b'), entry(3, 'c'), entry(7, 'd')];
const made: Record<number, ReturnType<typeof entry>[]> = {
0: [entry(0, 'new')],
3: [],
5: [entry(5, 'x'), entry(5, 'y')],
};
const patched = patchedInOrder(
list,
(e) => e.at,
[0, 3, 5],
(at) => made[at]!,
);
expect(patched.map((e) => e.name)).toEqual(['new', 'a', 'x', 'y', 'd']);
expect(patched[1]).toBe(list[0]);
});
it('patched and worked out afresh agree after every step of a long walk of painting', () => {
const m = patchMap();
const next = random(348);
const pick = <T>(list: readonly T[]) => list[Math.floor(next() * list.length)]!;
const somewhere = () => ({
x: Math.floor(next() * (COLUMNS + 2)) - 1,
y: Math.floor(next() * (ROWS + 2)) - 1,
});
const layerOf = (): TilemapLayer => pick(m.asset.layers);
const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 };
workedOut(m, m.asset);
const lastGroups = m.asset.layers.map((layer) => grouping(m, layer, new Set()).groups);
let reused = 0;
for (let step = 0; step < 400; step++) {
const roll = next();
if (roll < 0.5) {
const layer = layerOf();
const edits = Array.from({ length: 1 + Math.floor(next() * 4) }, () => {
const at = somewhere();
return { ...at, tileId: next() < 0.15 ? '' : pick(m.tiles) };
});
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits));
counts.paint++;
} else if (roll < 0.65 && m.stack.canUndo) {
m.stack.undo();
counts.undo++;
} else {
// In place, as a stroke paints before it is a command: with its keys, or without.
const layer = layerOf();
const keys = Array.from({ length: 1 + Math.floor(next() * 3) }, () => {
const at = somewhere();
const key = cellKey(at.x, at.y);
const tileId = next() < 0.3 ? '' : pick(m.tiles);
if (tileId) layer.cells[key] = tileId;
else delete layer.cells[key];
return key;
});
const named = next() < 0.5;
cellsChanged(layer.cells, named ? keys : undefined);
counts[named ? 'named' : 'unnamed']++;
}
const patched = workedOut(m, m.asset);
const fresh = workedOut(m, afresh(m.asset));
expect(patched, `step ${step}`).toEqual(fresh);
m.asset.layers.forEach((layer, i) => {
const groups = grouping(m, layer, lastGroups[i]!);
expect(groups.patched, `step ${step}, layer ${i}`).toEqual(groups.fresh);
reused += groups.reused;
lastGroups[i] = groups.groups;
});
}
// The walk went everywhere it should: enough of each step, and maps that block in every way.
expect(Math.min(...Object.values(counts))).toBeGreaterThan(20);
expect(reused).toBeGreaterThan(1000);
const end = workedOut(m, m.asset);
expect(end.whole.rects.length).toBeGreaterThan(5);
expect(end.whole.polygons.length).toBeGreaterThan(5);
expect(end.whole.oneWay).toHaveLength(2);
expect(end.whole.triggerCells.length).toBeGreaterThan(3);
// About 4 s alone; with the CI suite on the same cores it took 7.5 s (gate 761).
}, 30_000);
it('a cell two rows off is cut again when the autotile between turns into a box', () => {
const m = patchMap();
const [, lake, , , bush, big] = m.tiles;
const layer = m.asset.layers[0]!;
// Found by a search: erasing (3,1) makes the lake at (3,2) block whole, so the big box at (3,3)
// – drawn out on every side without a blocking neighbour – no longer grows upwards.
const rows = [
[bush, lake, lake, lake],
[lake, '', lake, lake],
['', lake, lake, lake],
[lake, '', lake, big],
];
const edits = rows.flatMap((row, y) => row.map((tileId, x) => ({ x, y, tileId: tileId! })));
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits));
solidRects(m.project, m.asset.props, m.asset);
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 3, y: 1, tileId: '' }]));
expect(solidRects(m.project, m.asset.props, m.asset)).toEqual(
solidRects(m.project, m.asset.props, afresh(m.asset)),
);
});
it('a painted cell leaves what is drawn and what blocks far from it the very same objects', () => {
const m = patchMap();
const [slope, lake] = m.tiles;
const layer = m.asset.layers[0]!;
const all = Array.from({ length: COLUMNS * ROWS }, (_, i) => ({
x: i % COLUMNS,
y: Math.floor(i / COLUMNS),
tileId: i % COLUMNS < 6 ? lake! : slope!,
}));
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all));
const tiles = layerTiles(m.project, m.asset.props, layer);
const polygons = solidPolygons(m.project, m.asset.props, m.asset);
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 2, y: 4, tileId: '' }]));
const after = layerTiles(m.project, m.asset.props, layer);
expect(after).not.toBe(tiles);
expect(after.find((t) => t.x === 10 && t.y === 0)).toBe(tiles.find((t) => t.x === 10 && t.y === 0));
const far = (list: readonly { x: number; y: number }[]) => list.filter((p) => p.y === 8 && p.x === 0);
expect(far(solidPolygons(m.project, m.asset.props, m.asset))).toEqual(far(polygons));
expect(far(solidPolygons(m.project, m.asset.props, m.asset))[0]).toBe(far(polygons)[0]);
});
it('a painted cell regroups only the tiles and rows it touches, so only their meshes are built (#424)', () => {
const m = patchMap();
const [slope, lake, , wall] = m.tiles;
const layer = m.asset.layers[0]!;
const all = Array.from({ length: COLUMNS * ROWS }, (_, i) => ({
x: i % COLUMNS,
y: Math.floor(i / COLUMNS),
tileId: i % COLUMNS < 6 ? lake! : slope!,
}));
// A wall in a corner already: a tile new to the layer would work all of it out again (#348).
all[all.length - 1]!.tileId = wall!;
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all));
const draw = { tiles: new Map(m.tiles.map((id) => [id, { sortOffset: 0 }])) } as unknown as TilemapDraw;
const tiles = layerTiles(m.project, m.asset.props, layer);
const groups = groupByTile(tiles);
const rows = layerRows(draw, { tiles } as TilemapLayerDraw);
// A wall on the slopes, away from the lake: the lake looks the same, so its group stays.
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: wall! }]));
const after = layerTiles(m.project, m.asset.props, layer);
const regrouped = groupByTile(after);
expect(regrouped.get(lake!)).toBe(groups.get(lake!));
expect(regrouped.get(slope!)).not.toBe(groups.get(slope!));
expect(regrouped.get(wall!)!.map((t) => [t.x, t.y])).toEqual([
[10, 4],
[11, 8],
]);
const rowsAfter = layerRows(draw, { tiles: after } as TilemapLayerDraw);
expect([...rowsAfter.keys()].filter((row) => rowsAfter.get(row) !== rows.get(row))).toEqual([4]);
// And back to a slope, a patch on a patch: still the same lake, and the same as grouped afresh.
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: slope! }]));
const back = layerTiles(m.project, m.asset.props, layer);
expect(groupByTile(back).get(lake!)).toBe(groups.get(lake!));
expect([...groupByTile(back)]).toEqual([...groupByTile([...back])]);
});
});