import { describe, expect, it } from 'vitest'; import { listMapIds, loadCharacterStats, loadMap } from '../src/server/assets'; import { Game } from '../src/server/game'; import { isModifierId } from '../src/server/modifiers'; import { defaultMap, lastPlaced, rotationPool, sanitizeRotation } from '../src/server/rotation'; import { TICK_RATE } from '../src/shared/constants'; import { Rng } from '../src/shared/rng'; import type { MapSummary } from '../src/shared/protocol'; import { ASSETS, realMaterials } from './helpers'; const materials = realMaterials(); const catalog: MapSummary[] = [ { id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }, ]; describe('built-in maps', () => { it('all load, validate, and only use known modifiers', () => { const ids = listMapIds(ASSETS); expect(ids).toEqual(expect.arrayContaining(['gateau-etages', 'mille-feuille', 'piece-montee', 'roule', 'tarte-fruits', 'crepes'])); for (const id of ids) { const { definition, terrain } = loadMap(ASSETS, id, materials); expect(terrain.width).toBe(definition.size[0]); for (const m of definition.modifiers) expect(isModifierId(m), `${id}: ${m}`).toBe(true); } }); it('la pièce montée: removing a chou drops the column above it', () => { const { definition, terrain } = loadMap(ASSETS, 'piece-montee', materials); const game = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) }); // Bottom row, centre chou: centre (1024, 905 - 42). game.queueOp({ kind: 'carve', x: 1024, y: 863, r: 44, power: 99 }); const res = game.step(); const heights = res.terrain.flatMap((e) => (e.ev.kind === 'detach' && !e.ev.debris ? [e.ev.h] : [])); expect(heights.length).toBeGreaterThanOrEqual(1); expect(Math.max(...heights)).toBeGreaterThan(300); // the whole column, not just one chou }); }); describe('le roulé', () => { it('only its biscuit stand holds it: saw through the stand and the whole roll comes loose', () => { const { definition, terrain } = loadMap(ASSETS, 'roule', materials); const game = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) }); for (let x = 836; x <= 1214; x += 10) game.queueOp({ kind: 'carve', x, y: 930, r: 32, power: 99 }); const res = game.step(); const freed = res.terrain.flatMap((e) => (e.ev.kind === 'detach' && !e.ev.debris ? [e.ev.w] : [])); expect(Math.max(0, ...freed)).toBeGreaterThan(600); // the whole roll, ~660 px wide expect(game.chunkStates().length).toBeGreaterThanOrEqual(1); }); }); describe('rotation', () => { it('pool: the selection when valid, every map otherwise', () => { expect(rotationPool({ mode: 'all', selection: ['b'] }, catalog)).toEqual(catalog); expect(rotationPool({ mode: 'selection', selection: ['c', 'a'] }, catalog).map((m) => m.id)).toEqual(['a', 'c']); expect(rotationPool({ mode: 'selection', selection: ['zzz'] }, catalog)).toEqual(catalog); expect(sanitizeRotation({ mode: 'selection', selection: ['a', 'zzz', 'a'] }, catalog)).toEqual({ mode: 'selection', selection: ['a'] }); }); it('default map: in order, or seeded random', () => { const rng = new Rng(1); expect([1, 2, 3, 4].map((r) => defaultMap({ mode: 'all', selection: [] }, catalog, r, rng))).toEqual(['a', 'b', 'c', 'a']); const pick = (): string[] => [1, 2, 3, 4, 5].map((r) => defaultMap({ mode: 'random', selection: [] }, catalog, r, new Rng(9 + r))); expect(pick()).toEqual(pick()); }); it('the last-placed player picks', () => { expect( lastPlaced([ { id: 1, name: '', characterId: '', roundScore: 5, total: 20 }, { id: 2, name: '', characterId: '', roundScore: 1, total: 3 }, { id: 3, name: '', characterId: '', roundScore: 9, total: 3 }, ]), ).toBe(2); expect(lastPlaced([])).toBeNull(); }); }); describe('modifiers', () => { function round(mapId: string, seed = 3): Game { const { definition, terrain } = loadMap(ASSETS, mapId, materials); return new Game({ map: definition, terrain, materials, seed, rules: { duration: 180 }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) }); } it('wind blows players sideways (crepes)', () => { const game = round('crepes'); const { id } = game.addPlayer('Plume', 'generic'); game.teleport(id, 300, 300); let maxWind = 0; const x0 = game.snapshot()[0].x; let drift = 0; for (let i = 0; i < 12 * TICK_RATE; i++) { game.step(); maxWind = Math.max(maxWind, Math.abs(game.roundState().wind)); if (i === 30) drift = game.snapshot().find((p) => p.id === id)!.x - x0; } expect(maxWind).toBeGreaterThan(50); expect(Math.abs(drift)).toBeGreaterThan(0); }); it('random cracks open in the brittle shortcrust over time (tarte-fruits), deterministically', () => { const a = round('tarte-fruits', 4); const b = round('tarte-fruits', 4); for (let i = 0; i < 10 * TICK_RATE; i++) { a.step(); b.step(); } expect(a.events.length).toBeGreaterThan(10); expect(a.terrain.hash()).toBe(b.terrain.hash()); }); it('modifiers are off during the warm-up', () => { const { definition, terrain } = loadMap(ASSETS, 'tarte-fruits', materials); const warm = new Game({ map: definition, terrain, materials, seed: 4, loadCharacter: (id) => loadCharacterStats(ASSETS, id) }); for (let i = 0; i < 10 * TICK_RATE; i++) warm.step(); expect(warm.events).toHaveLength(0); }); });