import { describe, it, expect } from 'vitest'; import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape'; import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision'; import { createGridInventory, placeItem, removeItem, moveItem, rotateItem, flipItem, getOccupiedCellSet, getItemAtCell, getAdjacentItems, validatePlacement, type GridInventory, type InventoryItem, } from '@/samples/slay-the-spire-like/grid-inventory'; /** * Helper: create a test inventory item. */ function createTestItem(id: string, shapeStr: string, transform = IDENTITY_TRANSFORM): InventoryItem { const shape = parseShapeString(shapeStr); return { id, shape, transform: { ...transform }, }; } describe('grid-inventory', () => { describe('createGridInventory', () => { it('should create an empty inventory with correct dimensions', () => { const inv = createGridInventory(6, 4); expect(inv.width).toBe(6); expect(inv.height).toBe(4); expect(inv.items.size).toBe(0); expect(inv.occupiedCells.size).toBe(0); }); }); describe('placeItem', () => { it('should place a single-cell item', () => { const inv = createGridInventory(6, 4); const item = createTestItem('sword', 'o'); placeItem(inv, item); expect(inv.items.size).toBe(1); expect(inv.items.has('sword')).toBe(true); expect(inv.occupiedCells.has('0,0')).toBe(true); }); it('should place a multi-cell item', () => { const inv = createGridInventory(6, 4); const item = createTestItem('axe', 'oee'); placeItem(inv, item); expect(inv.items.size).toBe(1); expect(inv.occupiedCells.size).toBe(3); expect(inv.occupiedCells.has('0,0')).toBe(true); expect(inv.occupiedCells.has('1,0')).toBe(true); expect(inv.occupiedCells.has('2,0')).toBe(true); }); it('should place multiple items', () => { const inv = createGridInventory(6, 4); const itemA = createTestItem('a', 'o'); const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } }); placeItem(inv, itemA); placeItem(inv, itemB); expect(inv.items.size).toBe(2); expect(inv.occupiedCells.size).toBe(2); expect(inv.occupiedCells.has('0,0')).toBe(true); expect(inv.occupiedCells.has('3,0')).toBe(true); }); }); describe('removeItem', () => { it('should remove an item and free its cells', () => { const inv = createGridInventory(6, 4); const item = createTestItem('sword', 'oee'); placeItem(inv, item); removeItem(inv, 'sword'); expect(inv.items.size).toBe(0); expect(inv.occupiedCells.size).toBe(0); }); it('should only free the removed item\'s cells', () => { const inv = createGridInventory(6, 4); const itemA = createTestItem('a', 'o'); const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } }); placeItem(inv, itemA); placeItem(inv, itemB); removeItem(inv, 'a'); expect(inv.items.size).toBe(1); expect(inv.occupiedCells.size).toBe(1); expect(inv.occupiedCells.has('0,0')).toBe(false); expect(inv.occupiedCells.has('2,0')).toBe(true); }); it('should do nothing for non-existent item', () => { const inv = createGridInventory(6, 4); removeItem(inv, 'nonexistent'); expect(inv.items.size).toBe(0); }); }); describe('validatePlacement', () => { it('should return valid for empty board', () => { const inv = createGridInventory(6, 4); const shape = parseShapeString('o'); const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM); expect(result).toEqual({ valid: true }); }); it('should return invalid for out of bounds', () => { const inv = createGridInventory(6, 4); const shape = parseShapeString('o'); const result = validatePlacement(inv, shape, { ...IDENTITY_TRANSFORM, offset: { x: 6, y: 0 }, }); expect(result).toEqual({ valid: false, reason: '超出边界' }); }); it('should return invalid for collision with existing item', () => { const inv = createGridInventory(6, 4); const existing = createTestItem('a', 'oee'); placeItem(inv, existing); const shape = parseShapeString('o'); const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM); expect(result).toEqual({ valid: false, reason: '与已有物品重叠' }); }); it('should return valid when there is room nearby', () => { const inv = createGridInventory(6, 4); const existing = createTestItem('a', 'o'); placeItem(inv, existing); const shape = parseShapeString('o'); const result = validatePlacement(inv, shape, { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 }, }); expect(result).toEqual({ valid: true }); }); }); describe('moveItem', () => { it('should move item to a new position', () => { const inv = createGridInventory(6, 4); const item = createTestItem('sword', 'o'); placeItem(inv, item); const result = moveItem(inv, 'sword', { ...IDENTITY_TRANSFORM, offset: { x: 5, y: 3 }, }); expect(result).toEqual({ success: true }); expect(inv.occupiedCells.has('0,0')).toBe(false); expect(inv.occupiedCells.has('5,3')).toBe(true); expect(item.transform.offset).toEqual({ x: 5, y: 3 }); }); it('should reject move that goes out of bounds', () => { const inv = createGridInventory(6, 4); const item = createTestItem('sword', 'o'); placeItem(inv, item); const result = moveItem(inv, 'sword', { ...IDENTITY_TRANSFORM, offset: { x: 6, y: 0 }, }); expect(result).toEqual({ success: false, reason: '超出边界' }); expect(inv.occupiedCells.has('0,0')).toBe(true); expect(item.transform.offset).toEqual({ x: 0, y: 0 }); }); it('should reject move that collides with another item', () => { const inv = createGridInventory(6, 4); const itemA = createTestItem('a', 'o'); const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } }); placeItem(inv, itemA); placeItem(inv, itemB); const result = moveItem(inv, 'b', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 }, }); expect(result).toEqual({ success: false, reason: '与已有物品重叠' }); expect(inv.occupiedCells.has('2,0')).toBe(true); }); it('should return error for non-existent item', () => { const inv = createGridInventory(6, 4); const result = moveItem(inv, 'ghost', IDENTITY_TRANSFORM); expect(result).toEqual({ success: false, reason: '物品不存在' }); }); it('should move multi-cell item correctly', () => { const inv = createGridInventory(6, 4); // oes: cells at (0,0), (1,0), (1,1) const item = createTestItem('axe', 'oes'); placeItem(inv, item); const newTransform = { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 1 } }; moveItem(inv, 'axe', newTransform); // Old cells should be freed expect(inv.occupiedCells.has('0,0')).toBe(false); expect(inv.occupiedCells.has('1,0')).toBe(false); expect(inv.occupiedCells.has('1,1')).toBe(false); // New cells: (0,0)+offset(3,1)=(3,1), (1,0)+(3,1)=(4,1), (1,1)+(3,1)=(4,2) expect(inv.occupiedCells.has('3,1')).toBe(true); expect(inv.occupiedCells.has('4,1')).toBe(true); expect(inv.occupiedCells.has('4,2')).toBe(true); }); }); describe('rotateItem', () => { it('should rotate item by 90 degrees', () => { const inv = createGridInventory(6, 4); // Horizontal line: (0,0), (1,0) const item = createTestItem('bar', 'oe', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 }, // Place away from edge so rotation stays in bounds }); placeItem(inv, item); const result = rotateItem(inv, 'bar', 90); expect(result).toEqual({ success: true }); expect(item.transform.rotation).toBe(90); }); it('should reject rotation that goes out of bounds', () => { const inv = createGridInventory(3, 3); // Item at the edge: place a 2-wide item at x=1 const item = createTestItem('bar', 'oe', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 }, }); placeItem(inv, item); // Rotating 90° would make it vertical starting at (1,0), going to (1,-1) -> out of bounds const result = rotateItem(inv, 'bar', 90); expect(result).toEqual({ success: false, reason: '超出边界' }); }); it('should reject rotation that collides', () => { const inv = createGridInventory(4, 4); const itemA = createTestItem('a', 'o'); const itemB = createTestItem('b', 'oe', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } }); placeItem(inv, itemA); placeItem(inv, itemB); // Rotating b 90° would place cells at (2,0) and (2,-1) -> (2,-1) is out of bounds // Let's try a different scenario: rotate b 270° -> (2,0) and (2,1) which is fine // But rotating to collide with a at (0,0)... need item close to a const itemC = createTestItem('c', 'os', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } }); placeItem(inv, itemC); // Rotating c 90° would give cells at (1,0) and (0,0) -> collision with a const result = rotateItem(inv, 'c', 90); expect(result).toEqual({ success: false, reason: '与已有物品重叠' }); }); it('should return error for non-existent item', () => { const inv = createGridInventory(6, 4); const result = rotateItem(inv, 'ghost', 90); expect(result).toEqual({ success: false, reason: '物品不存在' }); }); }); describe('flipItem', () => { it('should flip item horizontally', () => { const inv = createGridInventory(6, 4); const item = createTestItem('bar', 'oe'); placeItem(inv, item); const result = flipItem(inv, 'bar', 'x'); expect(result).toEqual({ success: true }); expect(item.transform.flipX).toBe(true); }); it('should flip item vertically', () => { const inv = createGridInventory(6, 4); const item = createTestItem('bar', 'os'); placeItem(inv, item); const result = flipItem(inv, 'bar', 'y'); expect(result).toEqual({ success: true }); expect(item.transform.flipY).toBe(true); }); it('should reject flip that causes collision', () => { // oes local cells: (0,0),(1,0),(1,1). flipY: (0,1),(1,1),(1,0). // Place flipper at offset (0,2): world cells (0,2),(1,2),(1,3). // flipY gives local (0,1),(1,1),(1,0) + offset(0,2) = (0,3),(1,3),(1,2) — same cells rearranged. // Need asymmetric shape where flip changes world position. // Use oes at offset (0,0): cells (0,0),(1,0),(1,1). flipY: (0,1),(1,1),(1,0). // Place blocker at (0,1) — which is NOT occupied by oes initially. const inv = createGridInventory(4, 4); const blocker = createTestItem('blocker', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 } }); // oes at (0,1): cells (0,1),(1,1),(1,2). This overlaps blocker at (0,1)! // Let me try: blocker at (1,0), flipper at offset (0,2). // flipper oes at (0,2): (0,2),(1,2),(1,3). blocker at (1,0) — no overlap. // flipY: local (0,1),(1,1),(1,0) + offset(0,2) = (0,3),(1,3),(1,2). No collision with (1,0). // // Simpler: oe shape (width=2, height=1). flipY with height=1 is identity. Use os (width=1, height=2). // os: (0,0),(0,1). flipY: (0,1),(0,0) — same cells. // Need width>1 and height>1 asymmetric shape: oes // // Place flipper at (0,0): cells (0,0),(1,0),(1,1). Place blocker at (0,1) — but (0,1) is not occupied. // flipY: (0,1),(1,1),(1,0). (0,1) hits blocker! const inv2 = createGridInventory(4, 4); const blocker2 = createTestItem('blocker', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 } }); const flipper2 = createTestItem('flipper', 'oes'); // at (0,0): (0,0),(1,0),(1,1) placeItem(inv2, blocker2); placeItem(inv2, flipper2); const result = flipItem(inv2, 'flipper', 'y'); expect(result).toEqual({ success: false, reason: '与已有物品重叠' }); }); it('should return error for non-existent item', () => { const inv = createGridInventory(6, 4); const result = flipItem(inv, 'ghost', 'x'); expect(result).toEqual({ success: false, reason: '物品不存在' }); }); }); describe('getOccupiedCellSet', () => { it('should return a copy of occupied cells', () => { const inv = createGridInventory(6, 4); const item = createTestItem('a', 'oe'); placeItem(inv, item); const cells = getOccupiedCellSet(inv); expect(cells).toEqual(new Set(['0,0', '1,0'])); // Mutating the copy should not affect the original cells.clear(); expect(inv.occupiedCells.size).toBe(2); }); }); describe('getItemAtCell', () => { it('should return item at occupied cell', () => { const inv = createGridInventory(6, 4); const item = createTestItem('sword', 'oee'); placeItem(inv, item); const found = getItemAtCell(inv, 1, 0); expect(found).toBeDefined(); expect(found!.id).toBe('sword'); }); it('should return undefined for empty cell', () => { const inv = createGridInventory(6, 4); const item = createTestItem('sword', 'o'); placeItem(inv, item); const found = getItemAtCell(inv, 5, 5); expect(found).toBeUndefined(); }); it('should return correct item when multiple items exist', () => { const inv = createGridInventory(6, 4); const itemA = createTestItem('a', 'o'); const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 2 } }); placeItem(inv, itemA); placeItem(inv, itemB); expect(getItemAtCell(inv, 0, 0)!.id).toBe('a'); expect(getItemAtCell(inv, 3, 2)!.id).toBe('b'); }); }); describe('getAdjacentItems', () => { it('should return orthogonally adjacent items', () => { const inv = createGridInventory(6, 4); const center = createTestItem('center', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 2 }, }); const top = createTestItem('top', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 1 } }); const left = createTestItem('left', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 2 } }); const right = createTestItem('right', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 2 } }); const bottom = createTestItem('bottom', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 3 } }); const diagonal = createTestItem('diagonal', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 1 } }); placeItem(inv, center); placeItem(inv, top); placeItem(inv, left); placeItem(inv, right); placeItem(inv, bottom); placeItem(inv, diagonal); const adj = getAdjacentItems(inv, 'center'); expect(adj.size).toBe(4); expect(adj.has('top')).toBe(true); expect(adj.has('left')).toBe(true); expect(adj.has('right')).toBe(true); expect(adj.has('bottom')).toBe(true); expect(adj.has('diagonal')).toBe(false); }); it('should return empty for item with no neighbors', () => { const inv = createGridInventory(6, 4); const item = createTestItem('alone', 'o'); placeItem(inv, item); const adj = getAdjacentItems(inv, 'alone'); expect(adj.size).toBe(0); }); it('should return empty for non-existent item', () => { const inv = createGridInventory(6, 4); const adj = getAdjacentItems(inv, 'ghost'); expect(adj.size).toBe(0); }); it('should handle multi-cell items with multiple adjacencies', () => { const inv = createGridInventory(6, 4); // Horizontal bar at (0,0)-(1,0) const bar = createTestItem('bar', 'oe'); // Item above left cell const topA = createTestItem('topA', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: -1 } }); // Item above right cell const topB = createTestItem('topB', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: -1 } }); placeItem(inv, bar); placeItem(inv, topA); placeItem(inv, topB); const adj = getAdjacentItems(inv, 'bar'); expect(adj.size).toBe(2); expect(adj.has('topA')).toBe(true); expect(adj.has('topB')).toBe(true); }); }); describe('integration: fill a 4x6 backpack', () => { it('should place items fitting a slay-the-spire-like backpack', () => { const inv = createGridInventory(4, 6); // Sword: 1x3 horizontal at (0,0) const sword = createTestItem('sword', 'oee'); // Shield: 2x2 at (0,1) const shield = createTestItem('shield', 'oes', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 }, }); expect(validatePlacement(inv, sword.shape, sword.transform)).toEqual({ valid: true }); placeItem(inv, sword); expect(validatePlacement(inv, shield.shape, shield.transform)).toEqual({ valid: true }); placeItem(inv, shield); expect(inv.items.size).toBe(2); expect(inv.occupiedCells.size).toBe(6); // sword(3) + shield(3) // Adjacent items should detect each other const adjSword = getAdjacentItems(inv, 'sword'); expect(adjSword.has('shield')).toBe(true); const adjShield = getAdjacentItems(inv, 'shield'); expect(adjShield.has('sword')).toBe(true); }); }); });