diff --git a/src/samples/slay-the-spire-like/data/heroItemFighter1.csv b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv index 3a36120..3677b35 100644 --- a/src/samples/slay-the-spire-like/data/heroItemFighter1.csv +++ b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv @@ -9,7 +9,7 @@ # targetType can be one of: single, none type,name,shape,costType,costCount,targetType,desc -string,string,string,string,int,string,string +string,string,string,'energy'|'uses',int,'single'|'none',string weapon,剑,oee,energy,1,single,【攻击2】【攻击2】 weapon,长斧,oees,energy,2,none,对全体【攻击5】 weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】 diff --git a/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts index bdcce42..35ee97a 100644 --- a/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts @@ -2,11 +2,13 @@ type HeroItemFighter1Table = readonly { readonly type: string; readonly name: string; readonly shape: string; - readonly costType: string; + readonly costType: "energy" | "uses"; readonly costCount: number; - readonly targetType: string; + readonly targetType: "single" | "none"; readonly desc: string; }[]; +export type HeroItemFighter1 = HeroItemFighter1Table[number]; + declare const data: HeroItemFighter1Table; export default data; diff --git a/src/samples/slay-the-spire-like/grid-inventory/index.ts b/src/samples/slay-the-spire-like/grid-inventory/index.ts new file mode 100644 index 0000000..936bd7f --- /dev/null +++ b/src/samples/slay-the-spire-like/grid-inventory/index.ts @@ -0,0 +1,13 @@ +export type { CellCoordinate, GridInventory, InventoryItem, PlacementResult } from './types'; +export { + createGridInventory, + flipItem, + getAdjacentItems, + getItemAtCell, + getOccupiedCellSet, + moveItem, + placeItem, + removeItem, + rotateItem, + validatePlacement, +} from './transform'; diff --git a/src/samples/slay-the-spire-like/grid-inventory/transform.ts b/src/samples/slay-the-spire-like/grid-inventory/transform.ts new file mode 100644 index 0000000..624c894 --- /dev/null +++ b/src/samples/slay-the-spire-like/grid-inventory/transform.ts @@ -0,0 +1,232 @@ +import type { ParsedShape } from '../utils/parse-shape'; +import type { Transform2D } from '../utils/shape-collision'; +import { + checkBoardCollision, + checkBounds, + flipXTransform, + flipYTransform, + rotateTransform, + transformShape, +} from '../utils/shape-collision'; +import type { GridInventory, InventoryItem, PlacementResult } from './types'; + +/** + * Creates a new empty grid inventory. + * Note: When used inside `.produce()`, call this before returning the draft. + */ +export function createGridInventory(width: number, height: number): GridInventory { + return { + width, + height, + items: new Map(), + occupiedCells: new Set(), + }; +} + +/** + * Builds a Set of occupied cell keys from a shape and transform. + */ +function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set { + const cells = transformShape(shape, transform); + return new Set(cells.map(c => `${c.x},${c.y}`)); +} + +/** + * Validates whether an item can be placed at the given transform. + * Checks bounds and collision with all other items. + */ +export function validatePlacement( + inventory: GridInventory, + shape: InventoryItem['shape'], + transform: Transform2D +): PlacementResult { + if (!checkBounds(shape, transform, inventory.width, inventory.height)) { + return { valid: false, reason: '超出边界' }; + } + + if (checkBoardCollision(shape, transform, inventory.occupiedCells)) { + return { valid: false, reason: '与已有物品重叠' }; + } + + return { valid: true }; +} + +/** + * Places an item onto the grid. + * **Mutates directly** — call inside a `.produce()` callback. + * Does not validate; call `validatePlacement` first. + */ +export function placeItem(inventory: GridInventory, item: InventoryItem): void { + const cells = getShapeCellKeys(item.shape, item.transform); + for (const cellKey of cells) { + inventory.occupiedCells.add(cellKey); + } + inventory.items.set(item.id, item); +} + +/** + * Removes an item from the grid by its ID. + * **Mutates directly** — call inside a `.produce()` callback. + */ +export function removeItem(inventory: GridInventory, itemId: string): void { + const item = inventory.items.get(itemId); + if (!item) return; + + const cells = getShapeCellKeys(item.shape, item.transform); + for (const cellKey of cells) { + inventory.occupiedCells.delete(cellKey); + } + inventory.items.delete(itemId); +} + +/** + * Moves an item to a new position with a new transform. + * **Mutates directly** — call inside a `.produce()` callback. + * Validates before applying; returns result indicating success. + */ +export function moveItem( + inventory: GridInventory, + itemId: string, + newTransform: Transform2D +): { success: true } | { success: false; reason: string } { + const item = inventory.items.get(itemId); + if (!item) { + return { success: false, reason: '物品不存在' }; + } + + // Temporarily remove item's cells for validation + const oldCells = getShapeCellKeys(item.shape, item.transform); + for (const cellKey of oldCells) { + inventory.occupiedCells.delete(cellKey); + } + + // Validate new position + const validation = validatePlacement(inventory, item.shape, newTransform); + if (!validation.valid) { + // Restore old cells + for (const cellKey of oldCells) { + inventory.occupiedCells.add(cellKey); + } + return { success: false, reason: validation.reason }; + } + + // Apply new transform + item.transform = newTransform; + + // Add new cells + const newCells = getShapeCellKeys(item.shape, item.transform); + for (const cellKey of newCells) { + inventory.occupiedCells.add(cellKey); + } + + return { success: true }; +} + +/** + * Rotates an item by the given degrees (typically 90, 180, or 270). + * **Mutates directly** — call inside a `.produce()` callback. + * Validates before applying; returns result indicating success. + */ +export function rotateItem( + inventory: GridInventory, + itemId: string, + degrees: number +): { success: true } | { success: false; reason: string } { + const item = inventory.items.get(itemId); + if (!item) { + return { success: false, reason: '物品不存在' }; + } + + const rotatedTransform = rotateTransform(item.transform, degrees); + return moveItem(inventory, itemId, rotatedTransform); +} + +/** + * Flips an item horizontally or vertically. + * **Mutates directly** — call inside a `.produce()` callback. + * Validates before applying; returns result indicating success. + */ +export function flipItem( + inventory: GridInventory, + itemId: string, + axis: 'x' | 'y' +): { success: true } | { success: false; reason: string } { + const item = inventory.items.get(itemId); + if (!item) { + return { success: false, reason: '物品不存在' }; + } + + const flippedTransform = axis === 'x' + ? flipXTransform(item.transform) + : flipYTransform(item.transform); + + return moveItem(inventory, itemId, flippedTransform); +} + +/** + * Returns a copy of the occupied cells set. + */ +export function getOccupiedCellSet(inventory: GridInventory): Set { + return new Set(inventory.occupiedCells); +} + +/** + * Finds the item occupying the given cell, if any. + */ +export function getItemAtCell( + inventory: GridInventory, + x: number, + y: number +): InventoryItem | undefined { + const cellKey = `${x},${y}`; + if (!inventory.occupiedCells.has(cellKey)) { + return undefined; + } + + for (const item of inventory.items.values()) { + const cells = getShapeCellKeys(item.shape, item.transform); + if (cells.has(cellKey)) { + return item; + } + } + + return undefined; +} + +/** + * Gets all items adjacent to the given item (orthogonally, not diagonally). + * Returns a Map of itemId -> item for deduplication. + */ +export function getAdjacentItems( + inventory: GridInventory, + itemId: string +): Map { + const item = inventory.items.get(itemId); + if (!item) { + return new Map(); + } + + const ownCells = getShapeCellKeys(item.shape, item.transform); + const adjacent = new Map(); + + for (const cellKey of ownCells) { + const [cx, cy] = cellKey.split(',').map(Number); + const neighbors = [ + `${cx + 1},${cy}`, + `${cx - 1},${cy}`, + `${cx},${cy + 1}`, + `${cx},${cy - 1}`, + ]; + + for (const neighborKey of neighbors) { + if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) { + const neighborItem = getItemAtCell(inventory, ...neighborKey.split(',').map(Number) as [number, number]); + if (neighborItem) { + adjacent.set(neighborItem.id, neighborItem); + } + } + } + } + + return adjacent; +} diff --git a/src/samples/slay-the-spire-like/grid-inventory/types.ts b/src/samples/slay-the-spire-like/grid-inventory/types.ts new file mode 100644 index 0000000..ff0866a --- /dev/null +++ b/src/samples/slay-the-spire-like/grid-inventory/types.ts @@ -0,0 +1,44 @@ +import type { ParsedShape } from '../utils/parse-shape'; +import type { Transform2D } from '../utils/shape-collision'; + +/** + * Simple 2D coordinate for grid cells. + */ +export interface CellCoordinate { + x: number; + y: number; +} + +/** + * An item placed on the grid inventory. + */ +export interface InventoryItem { + /** Unique item identifier */ + id: string; + /** Reference to the item's shape definition */ + shape: ParsedShape; + /** Current transformation (position, rotation, flips) */ + transform: Transform2D; + /** Optional metadata for game-specific data */ + meta?: Record; +} + +/** + * Result of a placement validation check. + */ +export type PlacementResult = { valid: true } | { valid: false; reason: string }; + +/** + * Grid inventory state. + * Designed to be mutated directly inside a `mutative .produce()` callback. + */ +export interface GridInventory { + /** Board width in cells */ + width: number; + /** Board height in cells */ + height: number; + /** Map of itemId -> InventoryItem for all placed items */ + items: Map; + /** Set of occupied cells in "x,y" format for O(1) collision lookups */ + occupiedCells: Set; +} diff --git a/tests/samples/slay-the-spire-like/grid-inventory.test.ts b/tests/samples/slay-the-spire-like/grid-inventory.test.ts new file mode 100644 index 0000000..2600ce5 --- /dev/null +++ b/tests/samples/slay-the-spire-like/grid-inventory.test.ts @@ -0,0 +1,485 @@ +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); + }); + }); +});