import { describe, it, expect } from 'vitest' import { stationsFromMap, navFromMap, spawnFromMap, spawnPointIn, COLLECT_PROPERTY, DEPOSIT_PROPERTY, SCOREBOARD_PROPERTY, MEETING_PROPERTY, STATION_ID_PROPERTY, COLLISION_PROPERTY, SPAWN_PROPERTY, SPAWN_VALUE, NPC_PROPERTY, AUTO_GATE_PROPERTY, npcKeysFromMap, collisionBoxesFromExport, type MapRecord, type MapExportSubset, } from './map.js' type Box = { x: number; y: number; w: number; h: number } /** An areaNode tagged with the given capability props. */ function area(props: Record, boxes: Box[]) { return { $type: 'areaNode', boxes, properties: Object.entries(props).map(([key, value]) => ({ key, value })) } } const GRID = { tileWidth: 48, tileHeight: 48 } describe('stationsFromMap', () => { it('returns [] for a map with no station areas', () => { const map: MapRecord = { grid: GRID, nodes: [{ $type: 'areaNode', boxes: [{ x: 0, y: 0, w: 10, h: 10 }], properties: [{ key: COLLISION_PROPERTY, value: '1' }] }] } expect(stationsFromMap(map)).toEqual([]) }) it('reads a collect station: a resource AT-URI, auto id from its rkey, area = union, pos = centre', () => { const wood = 'at://did:plc:abc/town.deception.resource/wood1' const map: MapRecord = { grid: GRID, nodes: [area({ [COLLECT_PROPERTY]: wood }, [{ x: 100, y: 100, w: 40, h: 40 }])] } const [s] = stationsFromMap(map) expect(s.id).toBe('res-wood1') // auto id labels by the referenced record's rkey expect(s.collect).toEqual([wood]) expect(s.deposit).toEqual([]) expect(s.scoreboard).toEqual([]) expect(s.area).toEqual({ x: 100, y: 100, w: 40, h: 40 }) expect(s.pos).toEqual({ x: 120, y: 120 }) }) it('splits comma-separated resource lists (multi-resource station)', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [COLLECT_PROPERTY]: 'wood, beef ,wood' }, [{ x: 0, y: 0, w: 10, h: 10 }])] } expect(stationsFromMap(map)[0]!.collect).toEqual(['wood', 'beef']) // trimmed + de-duped }) it('merges a property repeated across multiple entries (multi-value by repeated key)', () => { const map: MapRecord = { grid: GRID, nodes: [ { $type: 'areaNode', boxes: [{ x: 0, y: 0, w: 10, h: 10 }], properties: [ { key: COLLECT_PROPERTY, value: 'wood' }, { key: COLLECT_PROPERTY, value: 'beef, wood' }, // repeated key + CSV, all merged + de-duped ], }, ], } expect(stationsFromMap(map)[0]!.collect).toEqual(['wood', 'beef']) }) it('combines capabilities on one area (the restaurant table: collect + deposit)', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [COLLECT_PROPERTY]: 'dirtyDishes', [DEPOSIT_PROPERTY]: 'cleanDishes' }, [{ x: 0, y: 0, w: 16, h: 16 }])], } const [s] = stationsFromMap(map) expect(s.collect).toEqual(['dirtyDishes']) expect(s.deposit).toEqual(['cleanDishes']) }) it('combines a deposit point with a scoreboard, and reads the scoreboard resource CSV', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [DEPOSIT_PROPERTY]: 'wood,stone', [SCOREBOARD_PROPERTY]: 'wood, stone' }, [{ x: 0, y: 0, w: 16, h: 16 }])], } const [s] = stationsFromMap(map) expect(s.deposit).toEqual(['wood', 'stone']) expect(s.scoreboard).toEqual(['wood', 'stone']) expect(s.meeting).toBe(false) }) it('unions multiple boxes for the footprint', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [DEPOSIT_PROPERTY]: 'wood' }, [{ x: 0, y: 0, w: 20, h: 20 }, { x: 40, y: 40, w: 20, h: 20 }])] } const [s] = stationsFromMap(map) expect(s.area).toEqual({ x: 0, y: 0, w: 60, h: 60 }) expect(s.pos).toEqual({ x: 30, y: 30 }) }) it('takes the slot count from the caller (never the map) and honours an explicit id', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [MEETING_PROPERTY]: '1', [STATION_ID_PROPERTY]: 'town-hall' }, [{ x: 0, y: 0, w: 10, h: 10 }])] } const [s] = stationsFromMap(map, { slotCount: 5 }) expect(s.id).toBe('town-hall') expect(s.meeting).toBe(true) expect(s.slotCount).toBe(5) }) it('auto-numbers ids per base label and recurses into groups, skipping hidden nodes', () => { const map: MapRecord = { grid: GRID, nodes: [ { $type: 'groupNode', children: [area({ [SCOREBOARD_PROPERTY]: 'wood' }, [{ x: 0, y: 0, w: 8, h: 8 }]), area({ [SCOREBOARD_PROPERTY]: 'wood' }, [{ x: 50, y: 0, w: 8, h: 8 }])] }, { $type: 'areaNode', hidden: true, boxes: [{ x: 0, y: 0, w: 8, h: 8 }], properties: [{ key: MEETING_PROPERTY, value: '1' }] }, ], } expect(stationsFromMap(map).map((s) => s.id)).toEqual(['scoreboard', 'scoreboard-2']) }) it('ignores an area with no capability flags', () => { const map: MapRecord = { grid: GRID, nodes: [area({ 'town.deception.teleporter': '1' }, [{ x: 0, y: 0, w: 8, h: 8 }])] } expect(stationsFromMap(map)).toEqual([]) }) it('navFromMap still works alongside station areas (independent conventions)', () => { const map: MapRecord = { grid: GRID, nodes: [ { $type: 'areaNode', boxes: [{ x: 0, y: 0, w: 48, h: 48 }], properties: [{ key: COLLISION_PROPERTY, value: '1' }] }, area({ [COLLECT_PROPERTY]: 'stone' }, [{ x: 200, y: 200, w: 48, h: 48 }]), ], } expect(stationsFromMap(map)).toHaveLength(1) const nav = navFromMap(map) expect(nav.blocked.some((b) => b === 1)).toBe(true) }) }) function spawnArea(boxes: Box[]) { return { $type: 'areaNode', boxes, properties: [{ key: SPAWN_PROPERTY, value: SPAWN_VALUE }] } } describe('spawnFromMap', () => { it('returns null when no spawn area is authored', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [COLLECT_PROPERTY]: 'wood' }, [{ x: 0, y: 0, w: 48, h: 48 }])] } expect(spawnFromMap(map)).toBeNull() }) it('returns the union of all spawn-tagged boxes, skipping hidden nodes', () => { const map: MapRecord = { grid: GRID, nodes: [ spawnArea([{ x: 100, y: 100, w: 40, h: 40 }, { x: 140, y: 100, w: 40, h: 40 }]), { ...spawnArea([{ x: 0, y: 0, w: 999, h: 999 }]), hidden: true }, ], } expect(spawnFromMap(map)).toEqual({ x: 100, y: 100, w: 80, h: 40 }) }) }) describe('npcKeysFromMap', () => { const npc = (key: string) => ({ $type: 'markerNode', properties: [{ key: NPC_PROPERTY, value: key }] }) it('returns [] when the map authors no NPC markers', () => { const map: MapRecord = { grid: GRID, nodes: [area({ [COLLECT_PROPERTY]: 'wood' }, [{ x: 0, y: 0, w: 8, h: 8 }])] } expect(npcKeysFromMap(map)).toEqual([]) }) it('collects distinct type keys in first-seen order, recursing groups and skipping hidden nodes', () => { const map: MapRecord = { grid: GRID, nodes: [ npc('cow'), { $type: 'groupNode', children: [npc('chicken'), npc('cow')] }, // cow de-duped { ...npc('ghost'), hidden: true }, // hidden places nothing ], } expect(npcKeysFromMap(map)).toEqual(['cow', 'chicken']) }) it('trims whitespace around the type key', () => { const map: MapRecord = { grid: GRID, nodes: [npc(' pig ')] } expect(npcKeysFromMap(map)).toEqual(['pig']) }) }) describe('collisionBoxesFromExport', () => { const def = (id: string, origin: { x: number; y: number }, collision: Box[]) => ({ id, origin, collision }) const obj = (defId: string, pos: { x: number; y: number }, extra: Record = {}) => ({ kind: 'object', defId, pos, ...extra, }) it('includes standalone collision nodes verbatim', () => { const exp: MapExportSubset = { objectDefs: [], nodes: [{ kind: 'collision', rect: { x: 10, y: 20, w: 30, h: 40 } }], } expect(collisionBoxesFromExport(exp)).toEqual([{ x: 10, y: 20, w: 30, h: 40 }]) }) it('resolves each placed object\'s def-collision into world px (anchor-relative + pos)', () => { const exp: MapExportSubset = { objectDefs: [def('d', { x: 24, y: 24 }, [{ x: 15, y: 18, w: 18, h: 15 }])], nodes: [obj('d', { x: 100, y: 200 })], } // box is relative to art top-left; world = pos + (box - origin) expect(collisionBoxesFromExport(exp)).toEqual([{ x: 100 + (15 - 24), y: 200 + (18 - 24), w: 18, h: 15 }]) }) it('applies an object transform (180° rotation) to its colliders', () => { const exp: MapExportSubset = { objectDefs: [def('d', { x: 0, y: 0 }, [{ x: 10, y: 0, w: 4, h: 6 }])], nodes: [obj('d', { x: 0, y: 0 }, { rotation: 2 })], } // 180° about the anchor flips the box to the opposite side: x10..14 → x-14..-10 expect(collisionBoxesFromExport(exp)).toEqual([{ x: -14, y: -6, w: 4, h: 6 }]) }) it('drops colliders of objects tagged as auto-gates', () => { const exp: MapExportSubset = { objectDefs: [def('gate', { x: 0, y: 0 }, [{ x: 0, y: 0, w: 48, h: 16 }])], nodes: [ obj('gate', { x: 0, y: 0 }, { properties: [{ key: AUTO_GATE_PROPERTY, value: '1' }] }), { kind: 'collision', rect: { x: 0, y: 0, w: 8, h: 8 } }, ], } // the gate object contributes nothing; only the standalone wall remains expect(collisionBoxesFromExport(exp)).toEqual([{ x: 0, y: 0, w: 8, h: 8 }]) }) it('keeps colliders of objects whose autoGate flag is not set', () => { const exp: MapExportSubset = { objectDefs: [def('d', { x: 0, y: 0 }, [{ x: 0, y: 0, w: 4, h: 4 }])], nodes: [obj('d', { x: 0, y: 0 }, { properties: [{ key: AUTO_GATE_PROPERTY, value: '0' }] })], } expect(collisionBoxesFromExport(exp)).toEqual([{ x: 0, y: 0, w: 4, h: 4 }]) }) }) describe('spawnPointIn', () => { const A = { x: 100, y: 100, w: 80, h: 80 } it('keeps every point inside the area, even past the grid capacity', () => { for (let i = 0; i < 50; i++) { const p = spawnPointIn(A, i) expect(p.x).toBeGreaterThanOrEqual(A.x) expect(p.x).toBeLessThanOrEqual(A.x + A.w) expect(p.y).toBeGreaterThanOrEqual(A.y) expect(p.y).toBeLessThanOrEqual(A.y + A.h) } }) it('is deterministic and spreads consecutive spawners apart', () => { expect(spawnPointIn(A, 0)).toEqual(spawnPointIn(A, 0)) expect(spawnPointIn(A, 0)).not.toEqual(spawnPointIn(A, 1)) }) })