149 lines
6.5 KiB
TypeScript
149 lines
6.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { cellKey } from '../../src/core/model/tilemap';
|
||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import { decodeCells, encodeCells, MAX_RUN } from '../../src/core/store/tilemapCells';
|
||
import { makeProject, roundTrip } from '../helpers';
|
||
|
||
/** Cells from a picture: one character per cell, `.` is empty, any other names the tile `t<char>`. */
|
||
function cellsFrom(rows: string[], x0 = 0, y0 = 0): Record<string, string> {
|
||
const cells: Record<string, string> = {};
|
||
rows.forEach((row, y) =>
|
||
[...row].forEach((c, x) => {
|
||
if (c !== '.') cells[cellKey(x0 + x, y0 + y)] = `t${c}`;
|
||
}),
|
||
);
|
||
return cells;
|
||
}
|
||
|
||
function fullMap(size: number, tileOf: (x: number, y: number) => string): Record<string, string> {
|
||
const cells: Record<string, string> = {};
|
||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) cells[cellKey(x, y)] = tileOf(x, y);
|
||
return cells;
|
||
}
|
||
|
||
/** A project with one tilemap whose single layer holds `cells`, as the editor would have it. */
|
||
function projectWith(cells: Record<string, string>) {
|
||
const f = makeProject();
|
||
const create = new CreateAssetCommand('tilemap', 'Ground');
|
||
f.stack.execute(create);
|
||
const asset = findAsset(f.project, create.asset.id) as TilemapAsset;
|
||
asset.layers[0]!.cells = { ...cells };
|
||
return { project: f.project, id: create.asset.id };
|
||
}
|
||
|
||
function storedLayer(raw: unknown, id: string): Record<string, unknown> {
|
||
const assets = (raw as { assets: { id: string; layers?: Record<string, unknown>[] }[] }).assets;
|
||
return assets.find((a) => a.id === id)!.layers![0]!;
|
||
}
|
||
|
||
describe('the cells of a tilemap in the file', () => {
|
||
it('store an empty layer as nothing at all', () => {
|
||
expect(encodeCells({})).toEqual({ tiles: [], origin: { x: 0, y: 0 }, rows: [] });
|
||
expect(decodeCells(encodeCells({}))).toEqual({});
|
||
});
|
||
|
||
it('store a single cell as its position and one run', () => {
|
||
expect(encodeCells({ '7,3': 'grass' })).toEqual({
|
||
tiles: ['grass'],
|
||
origin: { x: 7, y: 3 },
|
||
rows: ['1'],
|
||
});
|
||
});
|
||
|
||
it('write runs, gaps and several tiles row by row, and read them back', () => {
|
||
const cells = cellsFrom(['aaab', '..b.', '', 'a..a']);
|
||
const stored = encodeCells(cells);
|
||
expect(stored).toEqual({
|
||
tiles: ['ta', 'tb'],
|
||
origin: { x: 0, y: 0 },
|
||
rows: ['1*3 2', '0*2 2', '', '1 0*2 1'],
|
||
});
|
||
expect(decodeCells(stored)).toEqual(cells);
|
||
});
|
||
|
||
it('keep cells outside the grid, on either side of it', () => {
|
||
const cells = { '-3,-2': 'a', '40,-2': 'a', '0,25': 'b' };
|
||
const stored = encodeCells(cells);
|
||
expect(stored.origin).toEqual({ x: -3, y: -2 });
|
||
expect(stored.rows).toHaveLength(28);
|
||
expect(decodeCells(stored)).toEqual(cells);
|
||
});
|
||
|
||
it('bring a full map with several tiles back exactly', () => {
|
||
const cells = fullMap(100, (x, y) => (x < 30 ? 'water' : y % 7 === 0 ? 'path' : 'grass'));
|
||
const stored = encodeCells(cells);
|
||
// Row 0 is a path row (0 % 7), so path is met before grass.
|
||
expect(stored.tiles).toEqual(['water', 'path', 'grass']);
|
||
expect(stored.rows).toHaveLength(100);
|
||
expect(decodeCells(stored)).toEqual(cells);
|
||
});
|
||
|
||
it('bring a scattered map back exactly', () => {
|
||
let seed = 7;
|
||
const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
||
const ids = ['a', 'b', 'c', 'd'];
|
||
const cells: Record<string, string> = {};
|
||
for (let i = 0; i < 500; i++)
|
||
cells[cellKey(Math.floor(random() * 60) - 10, Math.floor(random() * 40) - 5)] =
|
||
ids[Math.floor(random() * ids.length)]!;
|
||
expect(decodeCells(encodeCells(cells))).toEqual(cells);
|
||
});
|
||
|
||
it('are written compactly by the project file and read back by it', () => {
|
||
const cells = cellsFrom(['aa.b']);
|
||
const { project, id } = projectWith(cells);
|
||
const layer = storedLayer(serializeProject(project), id);
|
||
expect(layer.cells).toBeUndefined();
|
||
expect(layer).toMatchObject({ tiles: ['ta', 'tb'], origin: { x: 0, y: 0 }, rows: ['1*2 0 2'] });
|
||
expect((findAsset(roundTrip(project), id) as TilemapAsset).layers[0]!.cells).toEqual(cells);
|
||
});
|
||
|
||
it('leave the map in memory untouched when saving', () => {
|
||
const cells = cellsFrom(['ab']);
|
||
const { project, id } = projectWith(cells);
|
||
serializeProject(project);
|
||
expect((findAsset(project, id) as TilemapAsset).layers[0]!.cells).toEqual(cells);
|
||
});
|
||
|
||
// No migration any more (#137): the map of the first version (#128) is not read.
|
||
it('read a layer holding the map itself as empty', () => {
|
||
const { project, id } = projectWith({});
|
||
const raw = serializeProject(project) as { assets: Record<string, unknown>[] };
|
||
raw.assets.find((a) => a.id === id)!.layers = [{ id: 'L1', name: 'Ground', cells: { '1,2': 'grass' } }];
|
||
const layer = (findAsset(deserializeProject(raw), id) as TilemapAsset).layers[0]!;
|
||
expect(layer).toMatchObject({ id: 'L1', name: 'Ground' });
|
||
expect(layer.cells).toEqual({});
|
||
});
|
||
|
||
it('shrink a full map of 100 × 100 with two layers to a small part of the old size', () => {
|
||
const ground = fullMap(100, (x) => (x < 50 ? 'grass' : 'dirt'));
|
||
const decor = fullMap(100, (x, y) => ((x * 7 + y * 3) % 11 === 0 ? 'flower' : 'bush'));
|
||
const old = JSON.stringify([{ cells: ground }, { cells: decor }], null, 4).length;
|
||
const now = JSON.stringify([encodeCells(ground), encodeCells(decor)], null, 4).length;
|
||
expect(now).toBeLessThan(old / 5);
|
||
});
|
||
});
|
||
|
||
describe('reading stored cells leniently', () => {
|
||
it('ends a row at a run that does not parse, since every later run would land wrongly', () => {
|
||
const cells = decodeCells({ tiles: ['a'], origin: { x: 0, y: 0 }, rows: ['1 x 1', '1*2', '1*-1 1'] });
|
||
expect(cells).toEqual({ '0,0': 'a', '0,1': 'a', '1,1': 'a' });
|
||
});
|
||
|
||
it('counts an index that names no tile as an empty cell', () => {
|
||
const cells = decodeCells({ tiles: ['a', 5], origin: { x: 0, y: 0 }, rows: ['3 2 1'] });
|
||
expect(cells).toEqual({ '2,0': 'a' });
|
||
});
|
||
|
||
it('refuses a run longer than any grid, and a row that is not a string', () => {
|
||
expect(decodeCells({ tiles: ['a'], rows: [`1*${MAX_RUN + 1}`, 7, '1'] })).toEqual({ '0,2': 'a' });
|
||
});
|
||
|
||
it('starts at the top-left corner when the origin is missing or broken', () => {
|
||
expect(decodeCells({ tiles: ['a'], origin: { x: 1.5, y: 'no' }, rows: ['0 1'] })).toEqual({ '1,0': 'a' });
|
||
});
|
||
});
|