feat: add grid-inventory

This commit is contained in:
hypercross 2026-04-13 11:07:47 +08:00
parent 4bfc6db60f
commit 2a4383ff10
6 changed files with 779 additions and 3 deletions

View File

@ -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】

1 # type can be one of: weapon, armor, consumable, tool
9 # targetType can be one of: single, none
10 type,name,shape,costType,costCount,targetType,desc
11 string,string,string,string,int,string,string string,string,string,'energy'|'uses',int,'single'|'none',string
12 weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
13 weapon,长斧,oees,energy,2,none,对全体【攻击5】
14 weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
15 weapon,短刀,oe,energy,1,single,【攻击3】【攻击3】

View File

@ -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;

View File

@ -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';

View File

@ -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<string, InventoryItem>(),
occupiedCells: new Set<string>(),
};
}
/**
* Builds a Set of occupied cell keys from a shape and transform.
*/
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<string> {
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<string> {
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<string, InventoryItem> {
const item = inventory.items.get(itemId);
if (!item) {
return new Map();
}
const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem>();
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;
}

View File

@ -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<string, unknown>;
}
/**
* 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<string, InventoryItem>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<string>;
}

View File

@ -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);
});
});
});