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`. */ function cellsFrom(rows: string[], x0 = 0, y0 = 0): Record { const cells: Record = {}; 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 { const cells: Record = {}; 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) { 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 { const assets = (raw as { assets: { id: string; layers?: Record[] }[] }).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 = {}; 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[] }; 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' }); }); });