import { describe, expect, it } from 'vitest'; import { TerrainBitmap } from '../src/shared/terrain/bitmap'; import { DetachFinder } from '../src/shared/terrain/detach'; import { applyTerrainEvent, rasterizeChunk } from '../src/shared/terrain/events'; import { rleDecode, rleEncode } from '../src/shared/terrain/rle'; import { boxesForMask } from '../src/server/physics/fallingChunk'; import { bitmapFrom, realMaterials } from './helpers'; const materials = realMaterials(); const all = (bm: TerrainBitmap) => ({ x0: 0, y0: 0, x1: bm.width, y1: bm.height }); describe('detached components', () => { it('everything connected to a plate stays', () => { const bm = bitmapFrom(['......', '.11...', '.1....', '.1111.', '######']); expect(new DetachFinder(bm).find(all(bm))).toEqual([]); }); it('a floating island detaches with its exact mask', () => { const bm = bitmapFrom(['.......', '..23...', '..1....', '.......', '#######']); const [c] = new DetachFinder(bm).find(all(bm)); expect(c.bounds).toEqual({ x0: 2, y0: 1, x1: 4, y1: 3 }); expect([...c.mask]).toEqual([2, 3, 1, 0]); expect(c.pixelCount).toBe(3); }); it('diagonal contact does not anchor (4-connectivity)', () => { const bm = bitmapFrom(['.1...', '..1..', '..1..', '#####']); const comps = new DetachFinder(bm).find(all(bm)); expect(comps).toHaveLength(1); expect(comps[0].pixelCount).toBe(1); }); it('finds every separate island in the region, once each', () => { const bm = bitmapFrom(['1.1.1', '.....', '1.1.1', '.....', '#####']); expect(new DetachFinder(bm).find(all(bm))).toHaveLength(6); }); it('only looks around the changed region', () => { const bm = bitmapFrom(['1......', '.......', '.....1.', '.....1.', '#######']); const comps = new DetachFinder(bm).find({ x0: 0, y0: 0, x1: 1, y1: 1 }); expect(comps).toHaveLength(1); expect(comps[0].bounds.x0).toBe(0); }); it('cutting the only support of a ledge detaches the ledge', () => { const rows = ['..........', '.1111111..', '.1........', '.1........', '##########']; const bm = bitmapFrom(rows); const finder = new DetachFinder(bm); expect(finder.find(all(bm))).toEqual([]); bm.data[3 * bm.width + 1] = 0; // cut the pillar const [c] = finder.find({ x0: 1, y0: 3, x1: 2, y1: 4 }); expect(c.pixelCount).toBe(8); expect(finder.isAnchored(1 * bm.width + 3)).toBe(false); }); it('a detach event removes exactly the masked pixels', () => { const bm = bitmapFrom(['.....', '.12..', '..1..', '.....', '#####']); const [c] = new DetachFinder(bm).find(all(bm)); const { x0, y0, x1, y1 } = c.bounds; applyTerrainEvent(bm, { kind: 'detach', id: 1, x: x0, y: y0, w: x1 - x0, h: y1 - y0, mask: rleEncode(c.mask), debris: false }, materials); expect([...bm.data].filter((v) => v !== 0 && v !== 255)).toEqual([]); }); }); describe('RLE', () => { it('round-trips and rejects malformed input', () => { const data = new Uint8Array([0, 0, 0, 5, 5, 1, 0, 0]); expect(rleEncode(data)).toEqual([0, 3, 5, 2, 1, 1, 0, 2]); expect([...rleDecode(rleEncode(data), data.length)]).toEqual([...data]); expect(() => rleDecode([1, 3], 2)).toThrow(); expect(() => rleDecode([1], 1)).toThrow(); }); }); describe('chunk rasterization', () => { it('writes an unrotated chunk back where it was, into empty pixels only', () => { const bm = new TerrainBitmap(8, 8); bm.data[5 * 8 + 3] = 4; // obstacle inside the chunk area const mask = new Uint8Array([1, 1, 1, 1, 1, 1]); // 3 × 2 const patch = rasterizeChunk(bm, mask, 3, 2, 2, 4, 0, 0)!; expect(patch.pixelCount).toBe(5); applyTerrainEvent(bm, { kind: 'merge', id: 1, x: patch.x, y: patch.y, w: patch.w, h: patch.h, patch: rleEncode(patch.cells) }, materials); expect(bm.get(2, 4)).toBe(1); expect(bm.get(3, 5)).toBe(4); expect(bm.get(4, 5)).toBe(1); }); it('a quarter turn keeps the pixel count and leaves no holes', () => { const bm = new TerrainBitmap(32, 32); const mask = new Uint8Array(10 * 4).fill(1); const patch = rasterizeChunk(bm, mask, 10, 4, 20, 5, Math.PI / 2, 0)!; expect(patch.pixelCount).toBe(40); }); it('glues a chunk resting a couple of pixels above the ground', () => { const bm = new TerrainBitmap(6, 8); for (let x = 0; x < 6; x++) bm.data[7 * 6 + x] = 255; const patch = rasterizeChunk(bm, new Uint8Array([1, 1]), 2, 1, 2, 4, 0, 3)!; applyTerrainEvent(bm, { kind: 'merge', id: 1, x: patch.x, y: patch.y, w: patch.w, h: patch.h, patch: rleEncode(patch.cells) }, materials); expect(bm.get(2, 5)).toBe(1); expect(bm.get(2, 6)).toBe(1); expect(new DetachFinder(bm).isAnchored(4 * 6 + 2)).toBe(true); }); }); describe('chunk collision boxes', () => { it('covers a rectangle with one box and an L with two', () => { expect(boxesForMask(new Uint8Array(24 * 12).fill(1), 24, 12)).toEqual([{ x0: 0, y0: 0, x1: 24, y1: 12 }]); const l = new Uint8Array(24 * 24); for (let y = 0; y < 24; y++) for (let x = 0; x < 24; x++) if (x < 12 || y >= 12) l[y * 24 + x] = 1; expect(boxesForMask(l, 24, 24)).toHaveLength(2); }); });