From 0c94e6923a4a9803be7cfddd95abf43ea2d1ed2c Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 20 Apr 2026 15:16:03 +0800 Subject: [PATCH] test: reformat tests to use double quotes and improve style --- tests/core/game-host.test.ts | 1141 +++++++++-------- tests/core/game.test.ts | 348 ++--- tests/samples/regicide.test.ts | 584 +++++---- .../slay-the-spire-like/deck/factory.test.ts | 2 +- .../grid-inventory.test.ts | 935 +++++++------- 5 files changed, 1561 insertions(+), 1449 deletions(-) diff --git a/tests/core/game-host.test.ts b/tests/core/game-host.test.ts index 06cf0d3..a7a5dcc 100644 --- a/tests/core/game-host.test.ts +++ b/tests/core/game-host.test.ts @@ -1,636 +1,653 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; import { - registry, - createInitialState, - start, - TicTacToeState, - WinnerType, - PlayerType -} from '@/samples/tic-tac-toe'; -import { createGameHost, GameHost } from '@/core/game-host'; -import type { PromptEvent } from '@/utils/command'; -import { MutableSignal } from '@/utils/mutable-signal'; -import {IGameContext} from "../../src"; - -type TestGameHost = GameHost & { - _context: IGameContext; - context: IGameContext; -} + registry, + createInitialState, + start, + TicTacToeState, +} from "@/samples/tic-tac-toe"; +import { createGameHost, GameHost } from "@/core/game-host"; +import type { PromptEvent } from "@/utils/command"; function createTestHost() { - const host: TestGameHost = createGameHost( - { registry, createInitialState, start } - ); - host.context = host._context; - return { host }; + const host = createGameHost({ registry, createInitialState, start }); + return { host }; } function waitForPromptEvent(host: GameHost): Promise { - return new Promise(resolve => { - host.context._commands.on('prompt', resolve); - }); + return new Promise((resolve) => { + // @ts-ignore - accessing private _context for testing + host._context._commands.on("prompt", resolve); + }); } -describe('GameHost', () => { - describe('creation', () => { - it('should create host with initial state', () => { - const { host } = createTestHost(); +describe("GameHost", () => { + describe("creation", () => { + it("should create host with initial state", () => { + const { host } = createTestHost(); - expect(host.context._state.value.currentPlayer).toBe('X'); - expect(host.context._state.value.winner).toBeNull(); - expect(host.context._state.value.turn).toBe(0); - expect(Object.keys(host.context._state.value.parts).length).toBe(0); - }); - - it('should have status "created" by default', () => { - const { host } = createTestHost(); - - expect(host.status.value).toBe('created'); - }); - - it('should have null activePromptSchema initially', () => { - const { host } = createTestHost(); - - expect(host.activePromptSchema.value).toBeNull(); - }); + expect(host.state.value.currentPlayer).toBe("X"); + expect(host.state.value.winner).toBeNull(); + expect(host.state.value.turn).toBe(0); + expect(Object.keys(host.state.value.parts).length).toBe(0); }); - describe('tryInput', () => { - it('should return "No active prompt" when no prompt is active', () => { - const { host } = createTestHost(); + it('should have status "created" by default', () => { + const { host } = createTestHost(); - const result = host.tryInput('play X 1 1'); - expect(result).toBe('No active prompt'); - }); - - it('should accept valid input when prompt is active', async () => { - const { host } = createTestHost(); - - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); - - const promptEvent = await promptPromise; - expect(promptEvent.schema.name).toBe('play'); - expect(host.activePromptSchema.value?.name).toBe('play'); - - const error = host.tryInput('play X 1 1'); - expect(error).toBeNull(); - - // Cancel to end the game since start runs until game over - const nextPromptPromise = waitForPromptEvent(host); - const nextPrompt = await nextPromptPromise; - nextPrompt.cancel('test cleanup'); - - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - }); - - it('should reject invalid input', async () => { - const { host } = createTestHost(); - - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); - - const promptEvent = await promptPromise; - - const error = host.tryInput('invalid command'); - expect(error).not.toBeNull(); - - promptEvent.cancel('test cleanup'); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - }); - - it('should return error when disposed', () => { - const { host } = createTestHost(); - host.dispose(); - - const result = host.tryInput('play X 1 1'); - expect(result).toBe('GameHost is disposed'); - }); + expect(host.status.value).toBe("created"); }); - describe('getActivePromptSchema', () => { - it('should return schema when prompt is active', async () => { - const { host } = createTestHost(); + it("should have null activePromptSchema initially", () => { + const { host } = createTestHost(); - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); + expect(host.activePromptSchema.value).toBeNull(); + }); + }); - const promptEvent = await promptPromise; - const schema = host.activePromptSchema.value; + describe("tryInput", () => { + it('should return "No active prompt" when no prompt is active', () => { + const { host } = createTestHost(); - expect(schema).not.toBeNull(); - expect(schema?.name).toBe('play'); - - promptEvent.cancel('test cleanup'); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - }); - - it('should return null when no prompt is active', () => { - const { host } = createTestHost(); - - expect(host.activePromptSchema.value).toBeNull(); - }); + const result = host.tryInput("play X 1 1"); + expect(result).toBe("No active prompt"); }); - describe('start', () => { - it('should reset state and run start command', async () => { - const { host } = createTestHost(); + it("should accept valid input when prompt is active", async () => { + const { host } = createTestHost(); - // First setup - make one move - let promptPromise = waitForPromptEvent(host); - let runPromise = host.start(); - let promptEvent = await promptPromise; + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); - // Make a move - promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); + const promptEvent = await promptPromise; + expect(promptEvent.schema.name).toBe("play"); + expect(host.activePromptSchema.value?.name).toBe("play"); - // Wait for next prompt (next turn) and cancel - promptPromise = waitForPromptEvent(host); - promptEvent = await promptPromise; - promptEvent.cancel('test end'); + const error = host.tryInput("play X 1 1"); + expect(error).toBeNull(); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test end'); - } - expect(Object.keys(host.context._state.value.parts).length).toBe(1); + // Cancel to end the game since start runs until game over + const nextPromptPromise = waitForPromptEvent(host); + const nextPrompt = await nextPromptPromise; + nextPrompt.cancel("test cleanup"); - // Setup listener before calling start - const newPromptPromise = waitForPromptEvent(host); - - // Reset - should reset state and start new game - const newRunPromise = host.start(); - - // State should be back to initial - expect(host.context._state.value.currentPlayer).toBe('X'); - expect(host.context._state.value.winner).toBeNull(); - expect(host.context._state.value.turn).toBe(0); - expect(Object.keys(host.context._state.value.parts).length).toBe(0); - - // New game should be running and prompting - const newPrompt = await newPromptPromise; - expect(newPrompt.schema.name).toBe('play'); - newPrompt.cancel('test end'); - - try { - await newRunPromise; - } catch { - // Expected - cancelled - } - }); - - it('should cancel active prompt during start', async () => { - const { host } = createTestHost(); - - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); - - await promptPromise; - - // Setup should cancel the active prompt and reset state - host.start(); - - // The original runPromise should be rejected due to cancellation - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toContain('Cancelled'); - } - - // State should be reset - expect(host.context._state.value.currentPlayer).toBe('X'); - expect(host.context._state.value.turn).toBe(0); - }); - - it('should throw error when disposed', () => { - const { host } = createTestHost(); - host.dispose(); - - expect(() => host.start()).toThrow('GameHost is disposed'); - }); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } }); - describe('dispose', () => { - it('should change status to disposed', () => { - const { host } = createTestHost(); - host.dispose(); + it("should reject invalid input", async () => { + const { host } = createTestHost(); - expect(host.status.value).toBe('disposed'); - }); + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); - it('should cancel active prompt on dispose', async () => { - const { host } = createTestHost(); + const promptEvent = await promptPromise; - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); + const error = host.tryInput("invalid command"); + expect(error).not.toBeNull(); - await promptPromise; - - host.dispose(); - - // The runPromise should be rejected due to cancellation - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toContain('Cancelled'); - } - }); - - it('should be idempotent', () => { - const { host } = createTestHost(); - host.dispose(); - host.dispose(); // Should not throw - - expect(host.status.value).toBe('disposed'); - }); + promptEvent.cancel("test cleanup"); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } }); - describe('events', () => { - it('should emit start event', async () => { - const { host } = createTestHost(); + it("should return error when disposed", () => { + const { host } = createTestHost(); + host.dispose(); - let setupCount = 0; - host.on('start', () => { - setupCount++; - }); + const result = host.tryInput("play X 1 1"); + expect(result).toBe("GameHost is disposed"); + }); + }); - // Setup listener before calling setup - const promptPromise = waitForPromptEvent(host); + describe("getActivePromptSchema", () => { + it("should return schema when prompt is active", async () => { + const { host } = createTestHost(); - // Initial setup via reset - const runPromise = host.start(); - expect(setupCount).toBe(1); + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); - // State should be running - expect(host.status.value).toBe('running'); + const promptEvent = await promptPromise; + const schema = host.activePromptSchema.value; - // Cancel the background setup command - const prompt = await promptPromise; - prompt.cancel('test end'); + expect(schema).not.toBeNull(); + expect(schema?.name).toBe("play"); - try { - await runPromise; - } catch { - // Expected - cancelled - } - }); - - it('should emit dispose event', () => { - const { host } = createTestHost(); - - let disposeCount = 0; - host.on('dispose', () => { - disposeCount++; - }); - - host.dispose(); - expect(disposeCount).toBe(1); - }); - - it('should allow unsubscribing from events', () => { - const { host } = createTestHost(); - - let setupCount = 0; - const unsubscribe = host.on('start', () => { - setupCount++; - }); - - unsubscribe(); - - // No event should be emitted - // (we can't easily test this without triggering setup, but we verify unsubscribe works) - expect(typeof unsubscribe).toBe('function'); - }); + promptEvent.cancel("test cleanup"); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } }); - describe('reactive state', () => { - it('should have state that reflects game progress', async () => { - const { host } = createTestHost(); + it("should return null when no prompt is active", () => { + const { host } = createTestHost(); - // Initial state - expect(host.context._state.value.currentPlayer).toBe('X'); - expect(host.context._state.value.turn).toBe(0); + expect(host.activePromptSchema.value).toBeNull(); + }); + }); - // Make a move - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); + describe("start", () => { + it("should reset state and run start command", async () => { + const { host } = createTestHost(); - const promptEvent = await promptPromise; - promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); + // First setup - make one move + let promptPromise = waitForPromptEvent(host); + let runPromise = host.start(); + let promptEvent = await promptPromise; - // Wait for next prompt and cancel - const nextPromptPromise = waitForPromptEvent(host); - const nextPrompt = await nextPromptPromise; - nextPrompt.cancel('test end'); + // Make a move + promptEvent.tryCommit({ + name: "play", + params: ["X", 1, 1], + options: {}, + flags: {}, + }); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test end'); - } + // Wait for next prompt (next turn) and cancel + promptPromise = waitForPromptEvent(host); + promptEvent = await promptPromise; + promptEvent.cancel("test end"); - expect(host.context._state.value.currentPlayer).toBe('O'); - expect(host.context._state.value.turn).toBe(1); - expect(Object.keys(host.context._state.value.parts).length).toBe(1); - }); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test end"); + } + expect(Object.keys(host.state.value.parts).length).toBe(1); - it('should update activePromptSchema reactively', async () => { - const { host } = createTestHost(); + // Setup listener before calling start + const newPromptPromise = waitForPromptEvent(host); - // Initially null - expect(host.activePromptSchema.value).toBeNull(); + // Reset - should reset state and start new game + const newRunPromise = host.start(); - // Start a command that triggers prompt - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); + // State should be back to initial + expect(host.state.value.currentPlayer).toBe("X"); + expect(host.state.value.winner).toBeNull(); + expect(host.state.value.turn).toBe(0); + expect(Object.keys(host.state.value.parts).length).toBe(0); - await promptPromise; + // New game should be running and prompting + const newPrompt = await newPromptPromise; + expect(newPrompt.schema.name).toBe("play"); + newPrompt.cancel("test end"); - // Now schema should be set - expect(host.activePromptSchema.value).not.toBeNull(); - expect(host.activePromptSchema.value?.name).toBe('play'); - - // Cancel and wait - const cancelEvent = host.activePromptSchema.value; - host.context._commands._cancel(); - try { - await runPromise; - } catch { - // Expected - } - - // Schema should be null again - expect(host.activePromptSchema.value).toBeNull(); - }); + try { + await newRunPromise; + } catch { + // Expected - cancelled + } }); - describe('full game', () => { - it('should run a complete game of tic-tac-toe with X winning diagonally', async () => { - const { host } = createTestHost(); + it("should cancel active prompt during start", async () => { + const { host } = createTestHost(); - // Initial state - expect(host.context._state.value.currentPlayer).toBe('X'); - expect(host.context._state.value.winner).toBeNull(); - expect(host.context._state.value.turn).toBe(0); - expect(Object.keys(host.context._state.value.parts).length).toBe(0); + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); - // X wins diagonally: (0,0), (1,1), (2,2) - // O plays: (0,1), (2,1) - const moves = [ - 'play X 0 0', // turn 1: X - 'play O 0 1', // turn 2: O - 'play X 1 1', // turn 3: X - 'play O 2 1', // turn 4: O - 'play X 2 2', // turn 5: X wins! - ]; + await promptPromise; - // Track prompt events in a queue - const promptEvents: PromptEvent[] = []; - host.context._commands.on('prompt', (e) => { - promptEvents.push(e); - }); + // Setup should cancel the active prompt and reset state + host.start(); - // Start setup command (runs game loop until completion) - const setupPromise = host.start(); + // The original runPromise should be rejected due to cancellation + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toContain("Cancelled"); + } - for (let i = 0; i < moves.length; i++) { - // Wait until the next prompt event arrives - while (i >= promptEvents.length) { - await new Promise(r => setTimeout(r, 10)); - } - - const promptEvent = promptEvents[i]; - expect(promptEvent.schema.name).toBe('play'); - - // Submit the move - const error = host.tryInput(moves[i]); - expect(error).toBeNull(); - - // Wait for the command to complete before submitting next move - await new Promise(resolve => setImmediate(resolve)); - } - - // Wait for setup to complete (game ended with winner) - try { - const finalState = await setupPromise; - expect(finalState.winner).toBe('X'); - - // Final state checks - expect(host.context._state.value.winner).toBe('X'); - expect(host.context._state.value.currentPlayer).toBe('X'); - expect(Object.keys(host.context._state.value.parts).length).toBe(5); - - // Verify winning diagonal - const parts = Object.values(host.context._state.value.parts); - const xPieces = parts.filter(p => p.player === 'X'); - expect(xPieces).toHaveLength(3); - expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true); - expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([1, 1]))).toBe(true); - expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([2, 2]))).toBe(true); - } catch (e) { - // If setup fails due to cancellation, check state directly - const error = e as Error; - if (!error.message.includes('Cancelled')) { - throw e; - } - } - - host.dispose(); - expect(host.status.value).toBe('disposed'); - }); + // State should be reset + expect(host.state.value.currentPlayer).toBe("X"); + expect(host.state.value.turn).toBe(0); }); - describe('currentPlayer in prompt', () => { - it('should have currentPlayer in PromptEvent', async () => { - const { host } = createTestHost(); + it("should throw error when disposed", () => { + const { host } = createTestHost(); + host.dispose(); - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); + expect(() => host.start()).toThrow("GameHost is disposed"); + }); + }); - const promptEvent = await promptPromise; - expect(promptEvent.currentPlayer).toBe('X'); - expect(host.activePromptPlayer.value).toBe('X'); + describe("dispose", () => { + it("should change status to disposed", () => { + const { host } = createTestHost(); + host.dispose(); - promptEvent.cancel('test cleanup'); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - }); - - it('should update activePromptPlayer reactively', async () => { - const { host } = createTestHost(); - - // Initially null - expect(host.activePromptPlayer.value).toBeNull(); - - // First prompt - X's turn - let promptPromise = waitForPromptEvent(host); - let runPromise = host.start(); - let promptEvent = await promptPromise; - expect(promptEvent.currentPlayer).toBe('X'); - expect(host.activePromptPlayer.value).toBe('X'); - - // Make a move - promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); - - // Second prompt - O's turn - promptPromise = waitForPromptEvent(host); - promptEvent = await promptPromise; - expect(promptEvent.currentPlayer).toBe('O'); - expect(host.activePromptPlayer.value).toBe('O'); - - // Cancel - promptEvent.cancel('test cleanup'); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - - // After prompt ends, player should be null - expect(host.activePromptPlayer.value).toBeNull(); - }); + expect(host.status.value).toBe("disposed"); }); - describe('tryAnswerPrompt', () => { - it('should answer prompt with valid arguments', async () => { - const { host } = createTestHost(); + it("should cancel active prompt on dispose", async () => { + const { host } = createTestHost(); - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); - const promptEvent = await promptPromise; - expect(promptEvent.schema.name).toBe('play'); + await promptPromise; - // Use tryAnswerPrompt with the prompt def - const { prompts } = await import('@/samples/tic-tac-toe'); - const error = host.tryAnswerPrompt(prompts.play, 'X', 1, 1); - expect(error).toBeNull(); + host.dispose(); - // Wait for next prompt and cancel - const nextPromptPromise = waitForPromptEvent(host); - const nextPrompt = await nextPromptPromise; - nextPrompt.cancel('test cleanup'); - - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - }); - - it('should reject invalid arguments', async () => { - const { host } = createTestHost(); - - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); - - const promptEvent = await promptPromise; - - // Use tryAnswerPrompt with invalid position - const { prompts } = await import('@/samples/tic-tac-toe'); - const error = host.tryAnswerPrompt(prompts.play, 'X', 5, 5); - expect(error).not.toBeNull(); - - promptEvent.cancel('test cleanup'); - try { - await runPromise; - } catch (e) { - const error = e as Error; - expect(error.message).toBe('test cleanup'); - } - }); + // The runPromise should be rejected due to cancellation + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toContain("Cancelled"); + } }); - describe('addInterruption and clearInterruptions', () => { - it('should add interruption promise to state', async () => { - const { host } = createTestHost(); + it("should be idempotent", () => { + const { host } = createTestHost(); + host.dispose(); + host.dispose(); // Should not throw - let resolveInterruption: () => void; - const interruptionPromise = new Promise(resolve => { - resolveInterruption = resolve; - }); - - // Add interruption - host.addInterruption(interruptionPromise); - - // Start the game - produceAsync should wait for interruption - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); - - const promptEvent = await promptPromise; - - // Resolve interruption - resolveInterruption!(); - - // Cancel and cleanup - promptEvent.cancel('test cleanup'); - try { - await runPromise; - } catch { - // Expected - } - }); - - it('should clear all pending interruptions', async () => { - const { host } = createTestHost(); - - let resolveInterruption1: () => void; - let resolveInterruption2: () => void; - const interruptionPromise1 = new Promise(resolve => { - resolveInterruption1 = resolve; - }); - const interruptionPromise2 = new Promise(resolve => { - resolveInterruption2 = resolve; - }); - - // Add multiple interruptions - host.addInterruption(interruptionPromise1); - host.addInterruption(interruptionPromise2); - - // Clear all interruptions - host.clearInterruptions(); - - // Start the game - should not wait for cleared interruptions - const promptPromise = waitForPromptEvent(host); - const runPromise = host.start(); - - const promptEvent = await promptPromise; - promptEvent.cancel('test cleanup'); - - try { - await runPromise; - } catch { - // Expected - } - - // Original interruption promises should still be pending - // (they were cleared, not resolved) - }); + expect(host.status.value).toBe("disposed"); }); + }); + + describe("events", () => { + it("should emit start event", async () => { + const { host } = createTestHost(); + + let setupCount = 0; + host.on("start", () => { + setupCount++; + }); + + // Setup listener before calling setup + const promptPromise = waitForPromptEvent(host); + + // Initial setup via reset + const runPromise = host.start(); + expect(setupCount).toBe(1); + + // State should be running + expect(host.status.value).toBe("running"); + + // Cancel the background setup command + const prompt = await promptPromise; + prompt.cancel("test end"); + + try { + await runPromise; + } catch { + // Expected - cancelled + } + }); + + it("should emit dispose event", () => { + const { host } = createTestHost(); + + let disposeCount = 0; + host.on("dispose", () => { + disposeCount++; + }); + + host.dispose(); + expect(disposeCount).toBe(1); + }); + + it("should allow unsubscribing from events", () => { + const { host } = createTestHost(); + + let setupCount = 0; + const unsubscribe = host.on("start", () => { + setupCount++; + }); + + unsubscribe(); + + // No event should be emitted + // (we can't easily test this without triggering setup, but we verify unsubscribe works) + expect(typeof unsubscribe).toBe("function"); + }); + }); + + describe("reactive state", () => { + it("should have state that reflects game progress", async () => { + const { host } = createTestHost(); + + // Initial state + expect(host.state.value.currentPlayer).toBe("X"); + expect(host.state.value.turn).toBe(0); + + // Make a move + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + promptEvent.tryCommit({ + name: "play", + params: ["X", 1, 1], + options: {}, + flags: {}, + }); + + // Wait for next prompt and cancel + const nextPromptPromise = waitForPromptEvent(host); + const nextPrompt = await nextPromptPromise; + nextPrompt.cancel("test end"); + + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test end"); + } + + expect(host.state.value.currentPlayer).toBe("O"); + expect(host.state.value.turn).toBe(1); + expect(Object.keys(host.state.value.parts).length).toBe(1); + }); + + it("should update activePromptSchema reactively", async () => { + const { host } = createTestHost(); + + // Initially null + expect(host.activePromptSchema.value).toBeNull(); + + // Start a command that triggers prompt + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + await promptPromise; + + // Now schema should be set + expect(host.activePromptSchema.value).not.toBeNull(); + expect(host.activePromptSchema.value?.name).toBe("play"); + + // Cancel and wait + // @ts-ignore - accessing private _context for testing + host._context._commands._cancel(); + try { + await runPromise; + } catch { + // Expected + } + + // Schema should be null again + expect(host.activePromptSchema.value).toBeNull(); + }); + }); + + describe("full game", () => { + it("should run a complete game of tic-tac-toe with X winning diagonally", async () => { + const { host } = createTestHost(); + + // Initial state + expect(host.state.value.currentPlayer).toBe("X"); + expect(host.state.value.winner).toBeNull(); + expect(host.state.value.turn).toBe(0); + expect(Object.keys(host.state.value.parts).length).toBe(0); + + // X wins diagonally: (0,0), (1,1), (2,2) + // O plays: (0,1), (2,1) + const moves = [ + "play X 0 0", // turn 1: X + "play O 0 1", // turn 2: O + "play X 1 1", // turn 3: X + "play O 2 1", // turn 4: O + "play X 2 2", // turn 5: X wins! + ]; + + // Track prompt events in a queue + const promptEvents: PromptEvent[] = []; + // @ts-ignore - accessing private _context for testing + host._context._commands.on("prompt", (e) => { + promptEvents.push(e); + }); + + // Start setup command (runs game loop until completion) + const setupPromise = host.start(); + + for (let i = 0; i < moves.length; i++) { + // Wait until the next prompt event arrives + while (i >= promptEvents.length) { + await new Promise((r) => setTimeout(r, 10)); + } + + const promptEvent = promptEvents[i]; + expect(promptEvent.schema.name).toBe("play"); + + // Submit the move + const error = host.tryInput(moves[i]); + expect(error).toBeNull(); + + // Wait for the command to complete before submitting next move + await new Promise((resolve) => setImmediate(resolve)); + } + + // Wait for setup to complete (game ended with winner) + try { + const finalState = await setupPromise; + expect(finalState.winner).toBe("X"); + + // Final state checks + expect(host.state.value.winner).toBe("X"); + expect(host.state.value.currentPlayer).toBe("X"); + expect(Object.keys(host.state.value.parts).length).toBe(5); + + // Verify winning diagonal + const parts = Object.values(host.state.value.parts); + const xPieces = parts.filter((p: any) => p.player === "X"); + expect(xPieces).toHaveLength(3); + expect( + xPieces.some( + (p: any) => JSON.stringify(p.position) === JSON.stringify([0, 0]), + ), + ).toBe(true); + expect( + xPieces.some( + (p: any) => JSON.stringify(p.position) === JSON.stringify([1, 1]), + ), + ).toBe(true); + expect( + xPieces.some( + (p: any) => JSON.stringify(p.position) === JSON.stringify([2, 2]), + ), + ).toBe(true); + } catch (e) { + // If setup fails due to cancellation, check state directly + const error = e as Error; + if (!error.message.includes("Cancelled")) { + throw e; + } + } + + host.dispose(); + expect(host.status.value).toBe("disposed"); + }); + }); + + describe("currentPlayer in prompt", () => { + it("should have currentPlayer in PromptEvent", async () => { + const { host } = createTestHost(); + + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + expect(promptEvent.currentPlayer).toBe("X"); + expect(host.activePromptPlayer.value).toBe("X"); + + promptEvent.cancel("test cleanup"); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } + }); + + it("should update activePromptPlayer reactively", async () => { + const { host } = createTestHost(); + + // Initially null + expect(host.activePromptPlayer.value).toBeNull(); + + // First prompt - X's turn + let promptPromise = waitForPromptEvent(host); + let runPromise = host.start(); + let promptEvent = await promptPromise; + expect(promptEvent.currentPlayer).toBe("X"); + expect(host.activePromptPlayer.value).toBe("X"); + + // Make a move + promptEvent.tryCommit({ + name: "play", + params: ["X", 1, 1], + options: {}, + flags: {}, + }); + + // Second prompt - O's turn + promptPromise = waitForPromptEvent(host); + promptEvent = await promptPromise; + expect(promptEvent.currentPlayer).toBe("O"); + expect(host.activePromptPlayer.value).toBe("O"); + + // Cancel + promptEvent.cancel("test cleanup"); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } + + // After prompt ends, player should be null + expect(host.activePromptPlayer.value).toBeNull(); + }); + }); + + describe("tryAnswerPrompt", () => { + it("should answer prompt with valid arguments", async () => { + const { host } = createTestHost(); + + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + expect(promptEvent.schema.name).toBe("play"); + + // Use tryAnswerPrompt with the prompt def + const { prompts } = await import("@/samples/tic-tac-toe"); + const error = host.tryAnswerPrompt(prompts.play, "X", 1, 1); + expect(error).toBeNull(); + + // Wait for next prompt and cancel + const nextPromptPromise = waitForPromptEvent(host); + const nextPrompt = await nextPromptPromise; + nextPrompt.cancel("test cleanup"); + + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } + }); + + it("should reject invalid arguments", async () => { + const { host } = createTestHost(); + + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + + // Use tryAnswerPrompt with invalid position + const { prompts } = await import("@/samples/tic-tac-toe"); + const error = host.tryAnswerPrompt(prompts.play, "X", 5, 5); + expect(error).not.toBeNull(); + + promptEvent.cancel("test cleanup"); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe("test cleanup"); + } + }); + }); + + describe("addInterruption and clearInterruptions", () => { + it("should add interruption promise to state", async () => { + const { host } = createTestHost(); + + let resolveInterruption: () => void; + const interruptionPromise = new Promise((resolve) => { + resolveInterruption = resolve; + }); + + // Add interruption + host.addInterruption(interruptionPromise); + + // Start the game - produceAsync should wait for interruption + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + + // Resolve interruption + resolveInterruption!(); + + // Cancel and cleanup + promptEvent.cancel("test cleanup"); + try { + await runPromise; + } catch { + // Expected + } + }); + + it("should clear all pending interruptions", async () => { + const { host } = createTestHost(); + + let resolveInterruption1: () => void; + let resolveInterruption2: () => void; + const interruptionPromise1 = new Promise((resolve) => { + resolveInterruption1 = resolve; + }); + const interruptionPromise2 = new Promise((resolve) => { + resolveInterruption2 = resolve; + }); + + // Add multiple interruptions + host.addInterruption(interruptionPromise1); + host.addInterruption(interruptionPromise2); + + // Clear all interruptions + host.clearInterruptions(); + + // Start the game - should not wait for cleared interruptions + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + promptEvent.cancel("test cleanup"); + + try { + await runPromise; + } catch { + // Expected + } + + // Original interruption promises should still be pending + // (they were cleared, not resolved) + }); + }); }); diff --git a/tests/core/game.test.ts b/tests/core/game.test.ts index e8f3467..d2a17bb 100644 --- a/tests/core/game.test.ts +++ b/tests/core/game.test.ts @@ -1,193 +1,215 @@ -import { describe, it, expect } from 'vitest'; -import { createGameContext, createGameCommandRegistry, createPromptDef, IGameContext, PromptDef } from '@/core/game'; -import type { PromptEvent, Command } from '@/utils/command'; +import { describe, it, expect } from "vitest"; +import { + createGameContext, + createGameCommandRegistry, + createPromptDef, + IGameContext, + PromptDef, +} from "@/core/game"; +import type { + PromptEvent, + Command, + CommandRunnerContext, +} from "@/utils/command"; type MyState = { - score: number; - round: number; + score: number; + round: number; }; -describe('createGameContext', () => { - it('should create a game context with state', () => { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry); +describe("createGameContext", () => { + it("should create a game context with state", () => { + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry); - expect(ctx._state).not.toBeNull(); - expect(ctx._state.value).toBeDefined(); + expect(ctx._state).not.toBeNull(); + expect(ctx._state.value).toBeDefined(); + }); + + it("should wire commands to the context", () => { + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry); + + expect(ctx._commands).not.toBeNull(); + expect(ctx._commands.registry).toBe(registry); + }); + + it("should accept initial state as an object", () => { + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry, { + score: 0, + round: 1, }); - it('should wire commands to the context', () => { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry); + expect(ctx._state.value.score).toBe(0); + expect(ctx._state.value.round).toBe(1); + }); - expect(ctx._commands).not.toBeNull(); - expect(ctx._commands.registry).toBe(registry); + it("should accept initial state as a factory function", () => { + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry, () => ({ + score: 10, + round: 3, + })); + + expect(ctx._state.value.score).toBe(10); + expect(ctx._state.value.round).toBe(3); + }); + + it("should forward prompt events via listener", async () => { + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry); + + registry.register( + "test ", + async function (this: CommandRunnerContext, _ctx, value) { + return this.prompt(createPromptDef("prompt "), () => "ok"); + }, + ); + + const promptPromise = new Promise((resolve) => { + ctx._commands.on("prompt", resolve); }); + const runPromise = ctx.run("test hello"); - it('should accept initial state as an object', () => { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry, { - score: 0, - round: 1, - }); + const promptEvent = await promptPromise; + expect(promptEvent).not.toBeNull(); + expect(promptEvent.schema.name).toBe("prompt"); - expect(ctx._state.value.score).toBe(0); - expect(ctx._state.value.round).toBe(1); + const error = promptEvent.tryCommit({ + name: "prompt", + params: ["yes"], + options: {}, + flags: {}, }); + expect(error).toBeNull(); - it('should accept initial state as a factory function', () => { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry, () => ({ - score: 10, - round: 3, - })); - - expect(ctx._state.value.score).toBe(10); - expect(ctx._state.value.round).toBe(3); - }); - - it('should forward prompt events via listener', async () => { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry); - - registry.register('test ', async function (_ctx, value) { - return this.prompt({schema: 'prompt '}, () => 'ok'); - }); - - const promptPromise = new Promise(resolve => { - ctx._commands.on('prompt', resolve); - }); - const runPromise = ctx.run('test hello'); - - const promptEvent = await promptPromise; - expect(promptEvent).not.toBeNull(); - expect(promptEvent.schema.name).toBe('prompt'); - - const error = promptEvent.tryCommit({ name: 'prompt', params: ['yes'], options: {}, flags: {} }); - expect(error).toBeNull(); - - const result = await runPromise; - expect(result.success).toBe(true); - }); + const result = await runPromise; + expect(result.success).toBe(true); + }); }); -describe('createGameCommand', () => { - it('should run a command with access to game context', async () => { - const registry = createGameCommandRegistry<{ marker: string }>(); - - registry.register('set-marker ', async function (ctx, id) { - ctx.produce(state => { - state.marker = id; - }); - return id; +describe("createGameCommand", () => { + it("should run a command with access to game context", async () => { + const registry = createGameCommandRegistry<{ marker: string }>(); + + registry.register("set-marker ", async function (ctx, id) { + ctx.produce((state) => { + state.marker = id; + }); + return id; + }); + + const ctx = createGameContext(registry, { marker: "" }); + + const result = await ctx.run("set-marker board"); + if (!result.success) { + console.error("Error:", result.error); + } + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe("board"); + } + expect(ctx._state.value.marker).toBe("board"); + }); + + it("should run a typed command with extended context", async () => { + const registry = createGameCommandRegistry(); + + registry.register( + "add-score ", + async function (ctx, amount) { + ctx.produce((state) => { + state.score += amount; }); + return ctx.value.score; + }, + ); - const ctx = createGameContext(registry, { marker: '' }); + const ctx = createGameContext(registry, () => ({ + score: 0, + round: 1, + })); - const result = await ctx.run('set-marker board'); - if (!result.success) { - console.error('Error:', result.error); - } - expect(result.success).toBe(true); - if (result.success) { - expect(result.result).toBe('board'); - } - expect(ctx._state.value.marker).toBe('board'); - }); + const result = await ctx.run("add-score 5"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(5); + } + expect(ctx._state.value.score).toBe(5); + }); - it('should run a typed command with extended context', async () => { - const registry = createGameCommandRegistry(); + it("should return error for unknown command", async () => { + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry); - registry.register( - 'add-score ', - async function (ctx, amount) { - ctx.produce(state => { - state.score += amount; - }); - return ctx.value.score; - } - ); - - const ctx = createGameContext(registry, () => ({ - score: 0, - round: 1, - })); - - const result = await ctx.run('add-score 5'); - expect(result.success).toBe(true); - if (result.success) { - expect(result.result).toBe(5); - } - expect(ctx._state.value.score).toBe(5); - }); - - it('should return error for unknown command', async () => { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry); - - const result = await ctx.run('nonexistent'); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('nonexistent'); - } - }); + const result = await ctx.run("nonexistent"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("nonexistent"); + } + }); }); -describe('createPromptDef', () => { - it('should create a PromptDef with string schema', () => { - const promptDef = createPromptDef<[string, number]>('play '); +describe("createPromptDef", () => { + it("should create a PromptDef with string schema", () => { + const promptDef = createPromptDef<[string, number]>( + "play ", + ); - expect(promptDef).toBeDefined(); - expect(promptDef.schema.name).toBe('play'); - expect(promptDef.schema.params).toHaveLength(2); - expect(promptDef.schema.params[0].name).toBe('player'); - expect(promptDef.schema.params[1].name).toBe('score'); + expect(promptDef).toBeDefined(); + expect(promptDef.schema.name).toBe("play"); + expect(promptDef.schema.params).toHaveLength(2); + expect(promptDef.schema.params[0].name).toBe("player"); + expect(promptDef.schema.params[1].name).toBe("score"); + }); + + it("should create a PromptDef with CommandSchema object", () => { + const schemaObj = { + name: "test", + params: [], + options: {}, + flags: {}, + }; + const promptDef = createPromptDef<[]>(schemaObj); + + expect(promptDef.schema).toEqual(schemaObj); + }); + + it("should be usable with game.prompt", async () => { + const registry = createGameCommandRegistry<{ score: number }>(); + + registry.register("test-prompt", async function (ctx) { + const promptDef = createPromptDef<[number]>("input "); + const result = await ctx.prompt(promptDef, (value) => { + if (value < 0) throw "Value must be positive"; + return value; + }); + return result; }); - it('should create a PromptDef with CommandSchema object', () => { - const schemaObj = { - name: 'test', - params: [], - options: {}, - flags: {} - }; - const promptDef = createPromptDef<[]>(schemaObj); - - expect(promptDef.schema).toEqual(schemaObj); + const ctx = createGameContext(registry, { score: 0 }); + + const promptPromise = new Promise((resolve) => { + ctx._commands.on("prompt", resolve); }); + const runPromise = ctx.run("test-prompt"); - it('should be usable with game.prompt', async () => { - const registry = createGameCommandRegistry<{ score: number }>(); - - registry.register('test-prompt', async function(ctx) { - const promptDef = createPromptDef<[number]>('input '); - const result = await ctx.prompt( - promptDef, - (value) => { - if (value < 0) throw 'Value must be positive'; - return value; - } - ); - return result; - }); + const promptEvent = await promptPromise; + expect(promptEvent.schema.name).toBe("input"); - const ctx = createGameContext(registry, { score: 0 }); - - const promptPromise = new Promise(resolve => { - ctx._commands.on('prompt', resolve); - }); - const runPromise = ctx.run('test-prompt'); - - const promptEvent = await promptPromise; - expect(promptEvent.schema.name).toBe('input'); - - const error = promptEvent.tryCommit({ name: 'input', params: [42], options: {}, flags: {} }); - expect(error).toBeNull(); - - const result = await runPromise; - expect(result.success).toBe(true); - if (result.success) { - expect(result.result).toBe(42); - } + const error = promptEvent.tryCommit({ + name: "input", + params: [42], + options: {}, + flags: {}, }); + expect(error).toBeNull(); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(42); + } + }); }); diff --git a/tests/samples/regicide.test.ts b/tests/samples/regicide.test.ts index b8addad..8d7dc21 100644 --- a/tests/samples/regicide.test.ts +++ b/tests/samples/regicide.test.ts @@ -1,353 +1,373 @@ -import {createGameContext} from '@/core/game'; -import {registry} from '@/samples/regicide/commands'; -import {createInitialState} from '@/samples/regicide/state'; +import { describe, it, expect } from "vitest"; +import { createGameContext } from "@/core/game"; +import { registry } from "@/samples/regicide/commands"; +import { createInitialState } from "@/samples/regicide/state"; import { - buildEnemyDeck, - buildTavernDeck, - createAllCards, - createCard, - createEnemy, - getCardValue, - isEnemyDefeated -} from '@/samples/regicide/utils'; -import {Mulberry32RNG} from '@/utils/rng'; -import {CARD_VALUES, ENEMY_COUNT, FACE_CARDS, INITIAL_HAND_SIZE} from '@/samples/regicide/constants'; -import {PlayerType} from '@/samples/regicide/types'; + buildEnemyDeck, + buildTavernDeck, + createAllCards, + createCard, + createEnemy, + getCardValue, + isEnemyDefeated, +} from "@/samples/regicide/utils"; +import { Mulberry32RNG } from "@/utils/rng"; +import { + CARD_VALUES, + ENEMY_COUNT, + FACE_CARDS, + INITIAL_HAND_SIZE, +} from "@/samples/regicide/constants"; +import { PlayerType } from "@/samples/regicide/types"; -describe('Regicide - Utils', () => { - describe('getCardValue', () => { - it('should return correct value for number cards', () => { - expect(getCardValue('A')).toBe(1); - expect(getCardValue('5')).toBe(5); - expect(getCardValue('10')).toBe(10); - }); - - it('should return correct value for face cards', () => { - expect(getCardValue('J')).toBe(10); - expect(getCardValue('Q')).toBe(15); - expect(getCardValue('K')).toBe(20); - }); +describe("Regicide - Utils", () => { + describe("getCardValue", () => { + it("should return correct value for number cards", () => { + expect(getCardValue("A")).toBe(1); + expect(getCardValue("5")).toBe(5); + expect(getCardValue("10")).toBe(10); }); - describe('createCard', () => { - it('should create a card with correct properties', () => { - const card = createCard('spades_A', 'spades', 'A'); - expect(card.id).toBe('spades_A'); - expect(card.suit).toBe('spades'); - expect(card.rank).toBe('A'); - expect(card.value).toBe(1); - }); + it("should return correct value for face cards", () => { + expect(getCardValue("J")).toBe(10); + expect(getCardValue("Q")).toBe(15); + expect(getCardValue("K")).toBe(20); + }); + }); + + describe("createCard", () => { + it("should create a card with correct properties", () => { + const card = createCard("spades_A", "spades", "A"); + expect(card.id).toBe("spades_A"); + expect(card.suit).toBe("spades"); + expect(card.rank).toBe("A"); + expect(card.value).toBe(1); + }); + }); + + describe("createEnemy", () => { + it("should create an enemy with correct HP", () => { + const enemy = createEnemy("enemy_0", "J", "spades"); + expect(enemy.rank).toBe("J"); + expect(enemy.value).toBe(10); + expect(enemy.hp).toBe(20); + expect(enemy.maxHp).toBe(20); }); - describe('createEnemy', () => { - it('should create an enemy with correct HP', () => { - const enemy = createEnemy('enemy_0', 'J', 'spades'); - expect(enemy.rank).toBe('J'); - expect(enemy.value).toBe(10); - expect(enemy.hp).toBe(20); - expect(enemy.maxHp).toBe(20); - }); + it("should create enemy with different values for different ranks", () => { + const jEnemy = createEnemy("enemy_0", "J", "spades"); + const qEnemy = createEnemy("enemy_1", "Q", "hearts"); + const kEnemy = createEnemy("enemy_2", "K", "diamonds"); - it('should create enemy with different values for different ranks', () => { - const jEnemy = createEnemy('enemy_0', 'J', 'spades'); - const qEnemy = createEnemy('enemy_1', 'Q', 'hearts'); - const kEnemy = createEnemy('enemy_2', 'K', 'diamonds'); + expect(jEnemy.value).toBe(10); + expect(qEnemy.value).toBe(15); + expect(kEnemy.value).toBe(20); - expect(jEnemy.value).toBe(10); - expect(qEnemy.value).toBe(15); - expect(kEnemy.value).toBe(20); + expect(jEnemy.hp).toBe(20); + expect(qEnemy.hp).toBe(30); + expect(kEnemy.hp).toBe(40); + }); + }); - expect(jEnemy.hp).toBe(20); - expect(qEnemy.hp).toBe(30); - expect(kEnemy.hp).toBe(40); - }); + describe("createAllCards", () => { + it("should create 52 cards", () => { + const cards = createAllCards(); + expect(Object.keys(cards).length).toBe(52); }); - describe('createAllCards', () => { - it('should create 52 cards', () => { - const cards = createAllCards(); - expect(Object.keys(cards).length).toBe(52); - }); + it("should have all suits and ranks", () => { + const cards = createAllCards(); + const suits = ["spades", "hearts", "diamonds", "clubs"]; + const ranks = [ + "A", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "J", + "Q", + "K", + ]; - it('should have all suits and ranks', () => { - const cards = createAllCards(); - const suits = ['spades', 'hearts', 'diamonds', 'clubs']; - const ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']; + for (const suit of suits) { + for (const rank of ranks) { + const id = `${suit}_${rank}`; + expect(cards[id]).toBeDefined(); + expect(cards[id].suit).toBe(suit); + expect(cards[id].rank).toBe(rank); + } + } + }); + }); - for (const suit of suits) { - for (const rank of ranks) { - const id = `${suit}_${rank}`; - expect(cards[id]).toBeDefined(); - expect(cards[id].suit).toBe(suit); - expect(cards[id].rank).toBe(rank); - } - } - }); + describe("buildEnemyDeck", () => { + it("should create 12 enemies (J/Q/K)", () => { + const rng = new Mulberry32RNG(12345); + const deck = buildEnemyDeck(rng); + expect(deck.length).toBe(12); }); - describe('buildEnemyDeck', () => { - it('should create 12 enemies (J/Q/K)', () => { - const rng = new Mulberry32RNG(12345); - const deck = buildEnemyDeck(rng); - expect(deck.length).toBe(12); - }); + it("should have J at top, Q in middle, K at bottom", () => { + const rng = new Mulberry32RNG(12345); + const deck = buildEnemyDeck(rng); - it('should have J at top, Q in middle, K at bottom', () => { - const rng = new Mulberry32RNG(12345); - const deck = buildEnemyDeck(rng); + for (let i = 0; i < 4; i++) { + expect(deck[i].rank).toBe("J"); + } - for (let i = 0; i < 4; i++) { - expect(deck[i].rank).toBe('J'); - } + for (let i = 4; i < 8; i++) { + expect(deck[i].rank).toBe("Q"); + } - for (let i = 4; i < 8; i++) { - expect(deck[i].rank).toBe('Q'); - } + for (let i = 8; i < 12; i++) { + expect(deck[i].rank).toBe("K"); + } + }); + }); - for (let i = 8; i < 12; i++) { - expect(deck[i].rank).toBe('K'); - } - }); + describe("buildTavernDeck", () => { + it("should create 40 cards (A-10)", () => { + const rng = new Mulberry32RNG(12345); + const deck = buildTavernDeck(rng); + expect(deck.length).toBe(40); }); - describe('buildTavernDeck', () => { - it('should create 40 cards (A-10)', () => { - const rng = new Mulberry32RNG(12345); - const deck = buildTavernDeck(rng); - expect(deck.length).toBe(40); - }); + it("should not contain face cards", () => { + const rng = new Mulberry32RNG(12345); + const deck = buildTavernDeck(rng); - it('should not contain face cards', () => { - const rng = new Mulberry32RNG(12345); - const deck = buildTavernDeck(rng); + for (const card of deck) { + expect(FACE_CARDS.includes(card.rank)).toBe(false); + } + }); + }); - for (const card of deck) { - expect(FACE_CARDS.includes(card.rank)).toBe(false); - } - }); + describe("isEnemyDefeated", () => { + it("should return true when enemy HP <= 0", () => { + const enemy = createEnemy("enemy_0", "J", "spades"); + expect(isEnemyDefeated(enemy)).toBe(false); + + enemy.hp = 0; + expect(isEnemyDefeated(enemy)).toBe(true); + + enemy.hp = -5; + expect(isEnemyDefeated(enemy)).toBe(true); }); - describe('isEnemyDefeated', () => { - it('should return true when enemy HP <= 0', () => { - const enemy = createEnemy('enemy_0', 'J', 'spades'); - expect(isEnemyDefeated(enemy)).toBe(false); - - enemy.hp = 0; - expect(isEnemyDefeated(enemy)).toBe(true); - - enemy.hp = -5; - expect(isEnemyDefeated(enemy)).toBe(true); - }); - - it('should return false for null enemy', () => { - expect(isEnemyDefeated(null)).toBe(false); - }); + it("should return false for null enemy", () => { + expect(isEnemyDefeated(null)).toBe(false); }); + }); }); -describe('Regicide - Commands', () => { - function createTestContext() { - const initialState = createInitialState(); - return createGameContext(registry, initialState); - } +describe("Regicide - Commands", () => { + function createTestContext() { + const initialState = createInitialState(); + return createGameContext(registry, initialState); + } - function setupTestGame(game: ReturnType) { - const cards = createAllCards(); - const rng = new Mulberry32RNG(12345); - const enemyDeck = buildEnemyDeck(rng); - const tavernDeck = buildTavernDeck(rng); + function setupTestGame(game: ReturnType) { + const cards = createAllCards(); + const rng = new Mulberry32RNG(12345); + const enemyDeck = buildEnemyDeck(rng); + const tavernDeck = buildTavernDeck(rng); - game.produce(state => { - state.cards = cards; - state.playerCount = 2; - state.currentPlayerIndex = 0; - state.enemyDeck = [...enemyDeck]; - state.currentEnemy = {...enemyDeck[0]}; + game.produce((state) => { + state.cards = cards; + state.playerCount = 2; + state.currentPlayerIndex = 0; + state.enemyDeck = [...enemyDeck]; + state.currentEnemy = { ...enemyDeck[0] }; - for (const card of tavernDeck) { - state.regions.tavernDeck.childIds.push(card.id); - } + for (const card of tavernDeck) { + state.regions.tavernDeck.childIds.push(card.id); + } - for (let i = 0; i < 6; i++) { - const card1 = tavernDeck[i]; - const card2 = tavernDeck[i + 6]; - card1.regionId = 'hand_player1'; - card2.regionId = 'hand_player2'; - state.playerHands.player1.push(card1.id); - state.playerHands.player2.push(card2.id); - state.regions.hand_player1.childIds.push(card1.id); - state.regions.hand_player2.childIds.push(card2.id); - } - }); - } + for (let i = 0; i < 6; i++) { + const card1 = tavernDeck[i]; + const card2 = tavernDeck[i + 6]; + card1.regionId = "hand_player1"; + card2.regionId = "hand_player2"; + state.playerHands.player1.push(card1.id); + state.playerHands.player2.push(card2.id); + state.regions.hand_player1.childIds.push(card1.id); + state.regions.hand_player2.childIds.push(card2.id); + } + }); + } - describe('play command', () => { - it('should deal damage to current enemy', async () => { - const game = createTestContext(); - setupTestGame(game); + describe("play command", () => { + it("should deal damage to current enemy", async () => { + const game = createTestContext(); + setupTestGame(game); - const enemyHpBefore = game.value.currentEnemy!.hp; - const cardId = game.value.playerHands.player1[0]; - const card = game.value.cards[cardId]; + const enemyHpBefore = game.value.currentEnemy!.hp; + const cardId = game.value.playerHands.player1[0]; + const card = game.value.cards[cardId]; - const result = await game.run(`play player1 ${cardId}`); + const result = await game.run(`play player1 ${cardId}`); - expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value); - }); - - it('should double damage for clubs suit', async () => { - const game = createTestContext(); - setupTestGame(game); - - game.produce(state => { - state.cards['clubs_5'] = createCard('clubs_5', 'clubs', '5'); - state.playerHands.player1.push('clubs_5'); - state.regions.hand_player1.childIds.push('clubs_5'); - }); - - const clubsCardId = 'clubs_5'; - const enemyHpBefore = game.value.currentEnemy!.hp; - const card = game.value.cards[clubsCardId]; - - await game.run(`play player1 ${clubsCardId}`); - - expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value * 2); - }); + expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value); }); - describe('pass command', () => { - it('should allow player to pass', async () => { - const game = createTestContext(); - setupTestGame(game); + it("should double damage for clubs suit", async () => { + const game = createTestContext(); + setupTestGame(game); - const result = await game.run('pass player1'); + game.produce((state) => { + state.cards["clubs_5"] = createCard("clubs_5", "clubs", "5"); + state.playerHands.player1.push("clubs_5"); + state.regions.hand_player1.childIds.push("clubs_5"); + }); - expect(result.success).toBe(true); - }); + const clubsCardId = "clubs_5"; + const enemyHpBefore = game.value.currentEnemy!.hp; + const card = game.value.cards[clubsCardId]; + + await game.run(`play player1 ${clubsCardId}`); + + expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value * 2); + }); + }); + + describe("pass command", () => { + it("should allow player to pass", async () => { + const game = createTestContext(); + setupTestGame(game); + + const result = await game.run("pass player1"); + + expect(result.success).toBe(true); + }); + }); + + describe("check-enemy command", () => { + it("should detect defeated enemy and reveal next", async () => { + const game = createTestContext(); + setupTestGame(game); + + const firstEnemy = game.value.currentEnemy!; + game.produce((state) => { + state.currentEnemy!.hp = 0; + }); + + await game.run("check-enemy"); + + expect(game.value.regions.discardPile.childIds).toContain(firstEnemy.id); + expect(game.value.currentEnemy).not.toBe(firstEnemy); }); - describe('check-enemy command', () => { - it('should detect defeated enemy and reveal next', async () => { - const game = createTestContext(); - setupTestGame(game); + it("should not defeat enemy if HP > 0", async () => { + const game = createTestContext(); + setupTestGame(game); - const firstEnemy = game.value.currentEnemy!; - game.produce(state => { - state.currentEnemy!.hp = 0; - }); + const currentEnemyId = game.value.currentEnemy!.id; - await game.run('check-enemy'); + await game.run("check-enemy"); - expect(game.value.regions.discardPile.childIds).toContain(firstEnemy.id); - expect(game.value.currentEnemy).not.toBe(firstEnemy); - }); + expect(game.value.currentEnemy!.id).toBe(currentEnemyId); + }); + }); - it('should not defeat enemy if HP > 0', async () => { - const game = createTestContext(); - setupTestGame(game); + describe("next-turn command", () => { + it("should switch to next player", async () => { + const game = createTestContext(); + setupTestGame(game); - const currentEnemyId = game.value.currentEnemy!.id; + expect(game.value.currentPlayerIndex).toBe(0); - await game.run('check-enemy'); + await game.run("next-turn"); - expect(game.value.currentEnemy!.id).toBe(currentEnemyId); - }); + expect(game.value.currentPlayerIndex).toBe(1); }); - describe('next-turn command', () => { - it('should switch to next player', async () => { - const game = createTestContext(); - setupTestGame(game); + it("should wrap around to first player", async () => { + const game = createTestContext(); + setupTestGame(game); - expect(game.value.currentPlayerIndex).toBe(0); + game.produce((state) => { + state.currentPlayerIndex = 1; + }); - await game.run('next-turn'); + await game.run("next-turn"); - expect(game.value.currentPlayerIndex).toBe(1); - }); - - it('should wrap around to first player', async () => { - const game = createTestContext(); - setupTestGame(game); - - game.produce(state => { - state.currentPlayerIndex = 1; - }); - - await game.run('next-turn'); - - expect(game.value.currentPlayerIndex).toBe(0); - }); + expect(game.value.currentPlayerIndex).toBe(0); }); + }); }); -describe('Regicide - Game Flow', () => { - function createTestContext() { - const initialState = createInitialState(); - return createGameContext(registry, initialState); - } +describe("Regicide - Game Flow", () => { + function createTestContext() { + const initialState = createInitialState(); + return createGameContext(registry, initialState); + } - it('should complete a full turn cycle', async () => { - const game = createTestContext(); + it("should complete a full turn cycle", async () => { + const game = createTestContext(); - const cards = createAllCards(); - const rng = new Mulberry32RNG(12345); - const enemyDeck = buildEnemyDeck(rng); - const tavernDeck = buildTavernDeck(rng); + const cards = createAllCards(); + const rng = new Mulberry32RNG(12345); + const enemyDeck = buildEnemyDeck(rng); + const tavernDeck = buildTavernDeck(rng); - game.produce(state => { - state.cards = cards; - state.playerCount = 1; - state.currentPlayerIndex = 0; - state.enemyDeck = [...enemyDeck.slice(1)]; - state.currentEnemy = {...enemyDeck[0]}; + game.produce((state) => { + state.cards = cards; + state.playerCount = 1; + state.currentPlayerIndex = 0; + state.enemyDeck = [...enemyDeck.slice(1)]; + state.currentEnemy = { ...enemyDeck[0] }; - for (const card of tavernDeck) { - state.regions.tavernDeck.childIds.push(card.id); - } + for (const card of tavernDeck) { + state.regions.tavernDeck.childIds.push(card.id); + } - for (let i = 0; i < 6; i++) { - const card = tavernDeck[i]; - card.regionId = 'hand_player1'; - state.playerHands.player1.push(card.id); - state.regions.hand_player1.childIds.push(card.id); - } - }); - - const cardId = game.value.playerHands.player1[0]; - const card = game.value.cards[cardId]; - const enemyHpBefore = game.value.currentEnemy!.hp; - - await game.run(`play player1 ${cardId}`); - - expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore); + for (let i = 0; i < 6; i++) { + const card = tavernDeck[i]; + card.regionId = "hand_player1"; + state.playerHands.player1.push(card.id); + state.regions.hand_player1.childIds.push(card.id); + } }); - it('should win game when all enemies defeated', async () => { - const game = createTestContext(); + const cardId = game.value.playerHands.player1[0]; + const card = game.value.cards[cardId]; + const enemyHpBefore = game.value.currentEnemy!.hp; - const cards = createAllCards(); - const rng = new Mulberry32RNG(12345); - const tavernDeck = buildTavernDeck(rng); + await game.run(`play player1 ${cardId}`); - game.produce(state => { - state.cards = cards; - state.playerCount = 1; - state.currentPlayerIndex = 0; - state.enemyDeck = []; - state.currentEnemy = null; + expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore); + }); - for (const card of tavernDeck) { - state.regions.tavernDeck.childIds.push(card.id); - } - }); + it("should win game when all enemies defeated", async () => { + const game = createTestContext(); - game.produce(state => { - state.phase = 'victory'; - state.winner = true; - }); + const cards = createAllCards(); + const rng = new Mulberry32RNG(12345); + const tavernDeck = buildTavernDeck(rng); - expect(game.value.phase).toBe('victory'); - expect(game.value.winner).toBe(true); + game.produce((state) => { + state.cards = cards; + state.playerCount = 1; + state.currentPlayerIndex = 0; + state.enemyDeck = []; + state.currentEnemy = null; + + for (const card of tavernDeck) { + state.regions.tavernDeck.childIds.push(card.id); + } }); + + game.produce((state) => { + state.phase = "victory"; + state.winner = true; + }); + + expect(game.value.phase).toBe("victory"); + expect(game.value.winner).toBe(true); + }); }); diff --git a/tests/samples/slay-the-spire-like/deck/factory.test.ts b/tests/samples/slay-the-spire-like/deck/factory.test.ts index 19b2552..c43d443 100644 --- a/tests/samples/slay-the-spire-like/deck/factory.test.ts +++ b/tests/samples/slay-the-spire-like/deck/factory.test.ts @@ -14,7 +14,7 @@ import type { GridInventory, InventoryItem, } from "@/samples/slay-the-spire-like/system/grid-inventory"; -import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/encounter/types"; +import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; import { IDENTITY_TRANSFORM } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; import type { diff --git a/tests/samples/slay-the-spire-like/grid-inventory.test.ts b/tests/samples/slay-the-spire-like/grid-inventory.test.ts index 0ac905a..62e78af 100644 --- a/tests/samples/slay-the-spire-like/grid-inventory.test.ts +++ b/tests/samples/slay-the-spire-like/grid-inventory.test.ts @@ -1,485 +1,538 @@ -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 { 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 GridInventory, - type InventoryItem, -} from '@/samples/slay-the-spire-like/system/grid-inventory'; + createGridInventory, + placeItem, + removeItem, + moveItem, + rotateItem, + flipItem, + getOccupiedCellSet, + getItemAtCell, + getAdjacentItems, + validatePlacement, + type GridInventory, + type InventoryItem, +} from "@/samples/slay-the-spire-like/system/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 }, - }; +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("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); }); - describe('placeItem', () => { - it('should place a single-cell item', () => { - const inv = createGridInventory(6, 4); - const item = createTestItem('sword', 'o'); - placeItem(inv, item); + 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.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); - }); + 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); }); - describe('removeItem', () => { - it('should remove an item and free its cells', () => { - const inv = createGridInventory(6, 4); - const item = createTestItem('sword', 'oee'); - placeItem(inv, item); + 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); - removeItem(inv, 'sword'); + 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); + }); + }); - expect(inv.items.size).toBe(0); - expect(inv.occupiedCells.size).toBe(0); - }); + describe("removeItem", () => { + it("should remove an item and free its cells", () => { + const inv = createGridInventory(6, 4); + const item = createTestItem("sword", "oee"); + placeItem(inv, item); - 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, "sword"); - 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); - }); + expect(inv.items.size).toBe(0); + expect(inv.occupiedCells.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 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); - 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: '超出边界' }); - }); + removeItem(inv, "a"); - 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 }); - }); + 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); }); - describe('moveItem', () => { - it('should move item to a new position', () => { - const inv = createGridInventory(6, 4); - const item = createTestItem('sword', 'o'); - placeItem(inv, item); + it("should do nothing for non-existent item", () => { + const inv = createGridInventory(6, 4); + removeItem(inv, "nonexistent"); + expect(inv.items.size).toBe(0); + }); + }); - 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("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 }); }); - 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: '物品不存在' }); - }); + 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: "超出边界" }); }); - describe('flipItem', () => { - it('should flip item horizontally', () => { - const inv = createGridInventory(6, 4); - const item = createTestItem('bar', 'oe'); - placeItem(inv, item); + it("should return invalid for collision with existing item", () => { + const inv = createGridInventory(6, 4); + const existing = createTestItem("a", "oee"); + placeItem(inv, existing); - 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: '物品不存在' }); - }); + const shape = parseShapeString("o"); + const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM); + expect(result).toEqual({ valid: 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); + it("should return valid when there is room nearby", () => { + const inv = createGridInventory(6, 4); + const existing = createTestItem("a", "o"); + placeItem(inv, existing); - const cells = getOccupiedCellSet(inv); - expect(cells).toEqual(new Set(['0,0', '1,0'])); + const shape = parseShapeString("o"); + const result = validatePlacement(inv, shape, { + ...IDENTITY_TRANSFORM, + offset: { x: 1, y: 0 }, + }); + expect(result).toEqual({ valid: true }); + }); + }); - // Mutating the copy should not affect the original - cells.clear(); - expect(inv.occupiedCells.size).toBe(2); - }); + 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 }); }); - describe('getItemAtCell', () => { - it('should return item at occupied cell', () => { - const inv = createGridInventory(6, 4); - const item = createTestItem('sword', 'oee'); - placeItem(inv, item); + it("should reject move that goes out of bounds", () => { + const inv = createGridInventory(6, 4); + const item = createTestItem("sword", "o"); + placeItem(inv, item); - const found = getItemAtCell(inv, 1, 0); - expect(found).toBeDefined(); - expect(found!.id).toBe('sword'); - }); + const result = moveItem(inv, "sword", { + ...IDENTITY_TRANSFORM, + offset: { x: 6, y: 0 }, + }); - 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'); - }); + expect(result).toEqual({ success: false, reason: "超出边界" }); + expect(inv.occupiedCells.has("0,0")).toBe(true); + expect(item.transform.offset).toEqual({ x: 0, y: 0 }); }); - 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 } }); + 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); - placeItem(inv, center); - placeItem(inv, top); - placeItem(inv, left); - placeItem(inv, right); - placeItem(inv, bottom); - placeItem(inv, diagonal); + const result = moveItem(inv, "b", { + ...IDENTITY_TRANSFORM, + offset: { x: 0, y: 0 }, + }); - 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); - }); + expect(result).toEqual({ success: false, reason: "与已有物品重叠" }); + expect(inv.occupiedCells.has("2,0")).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); - }); + 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); + }); + }); });