import { describe, it, expect } from "vitest"; import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; import { IDENTITY_TRANSFORM } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; import { createGridInventory, placeItem, removeItem, moveItem, rotateItem, flipItem, getOccupiedCellSet, getItemAtCell, getAdjacentItems, validatePlacement, type InventoryItem, } from "@/samples/slay-the-spire-like/system/grid-inventory"; import { createItemIn } from "@/samples/slay-the-spire-like/system/grid-inventory/factory"; import type { GameItem, GameItemMeta, } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; import type { CardData, ItemData, } from "@/samples/slay-the-spire-like/system/types"; /** * Helper: create a minimal CardData for testing. */ function createTestCardData(id: string, name: string, desc: string): CardData { return { id, name, desc, type: "item", costType: "energy", costCount: 1, targetType: "enemy", effects: [], }; } /** * Helper: create a minimal ItemData for testing. */ function createTestItemData( id: string, name: string, shapeStr: string, desc: string, ): ItemData { return { id, type: "weapon", name, shape: shapeStr, card: createTestCardData(id, name, desc), price: 10, description: desc, }; } /** * 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); }); }); describe("createItemIn", () => { it("should place item at first valid position", () => { const inv = createGridInventory(6, 4); const itemData = createTestItemData("sword", "长剑", "oee", "攻击"); const result = createItemIn(inv, "sword-1", itemData); expect(result).toEqual({ success: true }); expect(inv.items.size).toBe(1); expect(inv.occupiedCells.size).toBe(3); // shape "oee" has 3 cells expect(inv.items.get("sword-1")?.meta?.itemData.id).toBe("sword"); }); it("should skip occupied cells and find next valid position", () => { const inv = createGridInventory(6, 4); // Place first item manually at origin const firstItem = createTestItem( "existing", "oee", ) as unknown as GameItem; placeItem(inv, firstItem); const itemData = createTestItemData("shield", "盾牌", "o", "防御"); const result = createItemIn(inv, "shield-1", itemData); expect(result).toEqual({ success: true }); // Shield should be at x=3, y=0 (first available spot after "oee" at x=0-2) const placedItem = inv.items.get("shield-1"); expect(placedItem?.transform.offset).toEqual({ x: 3, y: 0 }); }); it("should return error when no valid placement exists", () => { const inv = createGridInventory(3, 3); // Fill the grid completely with 1x1 items for (let i = 0; i < 9; i++) { const itemData = createTestItemData(`item${i}`, `物品${i}`, "o", ""); createItemIn(inv, `item-${i}`, itemData); } // Try to place one more item const itemData = createTestItemData("overflow", "溢出", "oee", ""); const result = createItemIn(inv, "overflow-1", itemData); expect(result).toEqual({ success: false, reason: "无可用位置" }); }); it("should handle multi-cell items correctly", () => { const inv = createGridInventory(4, 4); const itemData = createTestItemData("lshape", "L形", "oes", "特殊"); const result = createItemIn(inv, "lshape-1", itemData); expect(result).toEqual({ success: true }); expect(inv.items.size).toBe(1); // Shape "oes" has 3 cells expect(inv.occupiedCells.size).toBe(3); }); it("should not place item when inventory is completely full", () => { const inv = createGridInventory(2, 2); // Fill with 1x1 items const itemData = createTestItemData("small", "小物品", "o", ""); createItemIn(inv, "small-1", itemData); createItemIn(inv, "small-2", itemData); createItemIn(inv, "small-3", itemData); createItemIn(inv, "small-4", itemData); // Grid is now full const overflowItem = createTestItemData("overflow", "溢出", "o", ""); const result = createItemIn(inv, "overflow-5", overflowItem); expect(result).toEqual({ success: false, reason: "无可用位置" }); expect(inv.items.size).toBe(4); }); }); });