Compare commits

...

6 Commits

Author SHA1 Message Date
hyper 4bfc6db60f chore: add data import test 2026-04-12 21:04:38 +08:00
hyper 238254c4e5 chore: add tests for shape stuff 2026-04-12 20:51:19 +08:00
hyper 6281044f14 feat: add som shape parsing 2026-04-12 20:44:35 +08:00
hyper 18eae59891 feat: add hero fighter item pool 1 2026-04-12 20:34:08 +08:00
hyper 6dc85b443e feat: shape parsing 2026-04-12 20:26:46 +08:00
hyper d5f65fa6cd chore: export TicTactToePart 2026-04-12 16:28:50 +08:00
9 changed files with 818 additions and 3 deletions

View File

@ -25,9 +25,9 @@ Zone based Point Crawl:
3种牌攻击/防御/强化攻击克强化克防御克攻击强化比sts强化窄但效果更大 3种牌攻击/防御/强化攻击克强化克防御克攻击强化比sts强化窄但效果更大
受伤时伤口先占格子再扣血,牌少的角色受掉血影响大但血更厚 洗牌堆时洗入两张《疲劳》1费/消耗)
背包物品指定相邻物品,对其所有卡牌生效 背包物品可能会引用相邻的物品、同一行的物品、同一列的物品等
部分物品带使用次数限制,药瓶喝完可以在篝火补 部分物品带使用次数限制,药瓶喝完可以在篝火补

View File

@ -0,0 +1,36 @@
# type can be one of: weapon, armor, consumable, tool
#
# shape represents a tetris-like shape, consult ../utils/parse-shape.ts
# n
# w o e
# s
# costType can be one of: energy, uses
#
# targetType can be one of: single, none
type,name,shape,costType,costCount,targetType,desc
string,string,string,string,int,string,string
weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
weapon,长斧,oees,energy,2,none,对全体【攻击5】
weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
weapon,短刀,oe,energy,1,single,【攻击3】【攻击3】
weapon,飞镖,o,energy,0,single,【攻击1】抓一张牌
weapon,十字弩,onrersrw,energy,2,single,【攻击6】。对同一目标打出其他十字弩
armor,盾,oesw,energy,1,none,【防御3】
armor,斗笠,oerwrn,energy,2,none,【防御8】
armor,披风,oers,energy,1,none,【防御2】下回合【防御2】
armor,护腕,o,energy,0,none,【防御1】抓1张牌
armor,大盾,oesswn,energy,1,none,【防御5】
armor,锁子甲,oers,energy,1,none,本回合受到伤害-3
consumable,绷带,o,uses,3,none,从牌堆或弃牌堆随机移除1张伤口
consumable,淬毒药剂,o,uses,3,none,周围物品的【攻击】+2
consumable,强固药剂,o,uses,3,none,周围物品的【防御】+2
consumable,活力药剂,o,uses,3,none,获得1点能量
consumable,集中药剂,o,uses,3,none,抓2张牌
consumable,治疗药剂,o,uses,3,none,从牌堆或弃牌堆移除3张伤口
tool,水袋,os,energy,1,none,下回合开始时获得1能量抓2张牌
tool,绳索,ose,energy,1,none,周围物品的牌【防御】+2直到打出
tool,腰带,owre,energy,0,none,从牌堆周围物品的牌当中选择一张加入手牌
tool,火把,on,energy,1,none,下次打出周围物品的牌时将其消耗并获得1能量
tool,磨刀石,o,energy,1,none,周围物品的牌【攻击】+3直到打出
tool,铁匠锤,oers,energy,1,none,从牌堆/弃牌堆选择一张牌,随机变为一张周围物品的牌
1 # type can be one of: weapon, armor, consumable, tool
2 #
3 # shape represents a tetris-like shape, consult ../utils/parse-shape.ts
4 # n
5 # w o e
6 # s
7 # costType can be one of: energy, uses
8 #
9 # targetType can be one of: single, none
10 type,name,shape,costType,costCount,targetType,desc
11 string,string,string,string,int,string,string
12 weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
13 weapon,长斧,oees,energy,2,none,对全体【攻击5】
14 weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
15 weapon,短刀,oe,energy,1,single,【攻击3】【攻击3】
16 weapon,飞镖,o,energy,0,single,【攻击1】,抓一张牌
17 weapon,十字弩,onrersrw,energy,2,single,【攻击6】。对同一目标打出其他十字弩
18 armor,盾,oesw,energy,1,none,【防御3】
19 armor,斗笠,oerwrn,energy,2,none,【防御8】
20 armor,披风,oers,energy,1,none,【防御2】,下回合【防御2】
21 armor,护腕,o,energy,0,none,【防御1】,抓1张牌
22 armor,大盾,oesswn,energy,1,none,【防御5】
23 armor,锁子甲,oers,energy,1,none,本回合受到伤害-3
24 consumable,绷带,o,uses,3,none,从牌堆或弃牌堆随机移除1张伤口
25 consumable,淬毒药剂,o,uses,3,none,周围物品的【攻击】+2
26 consumable,强固药剂,o,uses,3,none,周围物品的【防御】+2
27 consumable,活力药剂,o,uses,3,none,获得1点能量
28 consumable,集中药剂,o,uses,3,none,抓2张牌
29 consumable,治疗药剂,o,uses,3,none,从牌堆或弃牌堆移除3张伤口
30 tool,水袋,os,energy,1,none,下回合开始时,获得1能量,抓2张牌
31 tool,绳索,ose,energy,1,none,周围物品的牌【防御】+2直到打出
32 tool,腰带,owre,energy,0,none,从牌堆周围物品的牌当中选择一张加入手牌
33 tool,火把,on,energy,1,none,下次打出周围物品的牌时,将其消耗并获得1能量
34 tool,磨刀石,o,energy,1,none,周围物品的牌【攻击】+3直到打出
35 tool,铁匠锤,oers,energy,1,none,从牌堆/弃牌堆选择一张牌,随机变为一张周围物品的牌

View File

@ -0,0 +1,12 @@
type HeroItemFighter1Table = readonly {
readonly type: string;
readonly name: string;
readonly shape: string;
readonly costType: string;
readonly costCount: number;
readonly targetType: string;
readonly desc: string;
}[];
declare const data: HeroItemFighter1Table;
export default data;

View File

@ -0,0 +1,3 @@
import heroItemFighter1Csv from './heroItemFighter1.csv';
export const heroItemFighter1Data = heroItemFighter1Csv;

View File

@ -0,0 +1,109 @@
/**
* Parsed shape result containing the grid and sizing information.
*/
export interface ParsedShape {
/** 2D boolean grid representing filled cells */
grid: boolean[][];
/** Grid width (number of columns) */
width: number;
/** Grid height (number of rows) */
height: number;
/** Number of occupied (filled) cells */
count: number;
/** Origin X coordinate within the grid */
originX: number;
/** Origin Y coordinate within the grid */
originY: number;
}
/**
* Parses a movement string into a 2D boolean grid.
* Rules:
* o: Fill current cell and set origin
* n, s, e, w: Move one step and fill the cell
* r: Return to the previous cell
*/
export function parseShapeString(input: string): ParsedShape {
let curX = 0;
let curY = 0;
let originX = 0;
let originY = 0;
let originSet = false;
// Track unique filled coordinates
const filledPoints = new Set<string>();
// Stack to track movement history for the 'r' command
const history: { x: number; y: number }[] = [];
const fill = (x: number, y: number) => {
filledPoints.add(`${x},${y}`);
history.push({ x, y });
};
for (const char of input.toLowerCase()) {
switch (char) {
case 'o':
if (!originSet) {
originX = curX;
originY = curY;
originSet = true;
}
fill(curX, curY);
break;
case 'n':
curY -= 1;
fill(curX, curY);
break;
case 's':
curY += 1;
fill(curX, curY);
break;
case 'e':
curX += 1;
fill(curX, curY);
break;
case 'w':
curX -= 1;
fill(curX, curY);
break;
case 'r':
if (history.length > 1) {
history.pop(); // Remove current position
const prev = history[history.length - 1];
curX = prev.x;
curY = prev.y;
}
break;
}
}
if (filledPoints.size === 0) {
return { grid: [[]], width: 0, height: 1, count: 0, originX: 0, originY: 0 };
}
// Calculate bounding box to normalize the array
const coords = Array.from(filledPoints).map(p => p.split(',').map(Number));
const minX = Math.min(...coords.map(c => c[0]));
const maxX = Math.max(...coords.map(c => c[0]));
const minY = Math.min(...coords.map(c => c[1]));
const maxY = Math.max(...coords.map(c => c[1]));
const width = maxX - minX + 1;
const height = maxY - minY + 1;
// Initialize the grid
const grid: boolean[][] = Array.from({ length: height }, () =>
Array(width).fill(false)
);
// Map the points into the grid using offsets
for (const [x, y] of coords) {
grid[y - minY][x - minX] = true;
}
// Normalize origin coordinates relative to the grid
const normalizedOriginX = originX - minX;
const normalizedOriginY = originY - minY;
return { grid, width, height, count: filledPoints.size, originX: normalizedOriginX, originY: normalizedOriginY };
}

View File

@ -0,0 +1,227 @@
import type { ParsedShape } from './parse-shape';
/**
* Represents a 2D point in grid coordinates.
*/
export interface Point2D {
x: number;
y: number;
}
/**
* 2D transformation to apply to a shape.
*/
export interface Transform2D {
/** Translation offset in grid units */
offset: Point2D;
/** Rotation in degrees (0, 90, 180, 270) */
rotation: number;
/** Whether to flip horizontally */
flipX: boolean;
/** Whether to flip vertically */
flipY: boolean;
}
/**
* Default transform (identity).
*/
export const IDENTITY_TRANSFORM: Transform2D = {
offset: { x: 0, y: 0 },
rotation: 0,
flipX: false,
flipY: false,
};
/**
* Gets all occupied cell coordinates from a shape.
*/
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
const cells: Point2D[] = [];
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
cells.push({ x, y });
}
}
}
return cells;
}
/**
* Applies a 2D transformation to a point.
*/
export function transformPoint(
point: Point2D,
transform: Transform2D,
shapeWidth: number,
shapeHeight: number
): Point2D {
let { x, y } = point;
// Apply flips
if (transform.flipX) {
x = shapeWidth - 1 - x;
}
if (transform.flipY) {
y = shapeHeight - 1 - y;
}
// Apply rotation (around origin 0,0)
const rotation = ((transform.rotation % 360) + 360) % 360;
let rotatedX = x;
let rotatedY = y;
switch (rotation) {
case 90:
rotatedX = y;
rotatedY = -x;
break;
case 180:
rotatedX = -x;
rotatedY = -y;
break;
case 270:
rotatedX = -y;
rotatedY = x;
break;
}
// Apply offset
return {
x: rotatedX + transform.offset.x,
y: rotatedY + transform.offset.y,
};
}
/**
* Transforms a shape and returnss its occupied cells in world coordinates.
*/
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] {
const cells = getOccupiedCells(shape);
return cells.map(cell =>
transformPoint(cell, transform, shape.width, shape.height)
);
}
/**
* Checks if two transformed shapes collide (share any occupied cell).
*/
export function checkCollision(
shapeA: ParsedShape,
transformA: Transform2D,
shapeB: ParsedShape,
transformB: Transform2D
): boolean {
const cellsA = transformShape(shapeA, transformA);
const cellsB = transformShape(shapeB, transformB);
const setA = new Set(cellsA.map(c => `${c.x},${c.y}`));
for (const cell of cellsB) {
if (setA.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape collides with any occupied cells on a board.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param occupiedCells Set of occupied board cells in "x,y" format
*/
export function checkBoardCollision(
shape: ParsedShape,
transform: Transform2D,
occupiedCells: Set<string>
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape is within bounds.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param boardWidth Board width
* @param boardHeight Board height
*/
export function checkBounds(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) {
return false;
}
}
return true;
}
/**
* Validates that a placement is both in bounds and collision-free.
* @returns Object with `valid` flag and optional `reason` string
*/
export function validatePlacement(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number,
occupiedCells: Set<string>
): { valid: true } | { valid: false; reason: string } {
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
return { valid: false, reason: '超出边界' };
}
if (checkBoardCollision(shape, transform, occupiedCells)) {
return { valid: false, reason: '与已有形状重叠' };
}
return { valid: true };
}
/**
* Rotates a transform by the given degrees.
* @param current The current transform
* @param degrees Degrees to rotate (typically 90, 180, or 270)
*/
export function rotateTransform(current: Transform2D, degrees: number): Transform2D {
return {
...current,
rotation: ((current.rotation + degrees) % 360 + 360) % 360,
};
}
/**
* Flips a transform horizontally.
*/
export function flipXTransform(current: Transform2D): Transform2D {
return {
...current,
flipX: !current.flipX,
};
}
/**
* Flips a transform vertically.
*/
export function flipYTransform(current: Transform2D): Transform2D {
return {
...current,
flipY: !current.flipY,
};
}

View File

@ -17,7 +17,7 @@ const WINNING_LINES: number[][][] = [
export type PlayerType = 'X' | 'O'; export type PlayerType = 'X' | 'O';
export type WinnerType = PlayerType | 'draw' | null; export type WinnerType = PlayerType | 'draw' | null;
type TicTacToePart = Part<{ player: PlayerType }>; export type TicTacToePart = Part<{ player: PlayerType }>;
export function createInitialState() { export function createInitialState() {
return { return {

View File

@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { heroItemFighter1Data } from '@/samples/slay-the-spire-like/data';
describe('heroItemFighter1.csv import', () => {
it('should import data as an array', () => {
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
expect(heroItemFighter1Data.length).toBeGreaterThan(0);
});
it('should have expected number of items', () => {
// CSV has 24 data rows (excluding header and type rows)
expect(heroItemFighter1Data.length).toBe(24);
});
it('should have correct fields for each item', () => {
for (const item of heroItemFighter1Data) {
expect(item).toHaveProperty('type');
expect(item).toHaveProperty('name');
expect(item).toHaveProperty('shape');
expect(item).toHaveProperty('costType');
expect(item).toHaveProperty('costCount');
expect(item).toHaveProperty('targetType');
expect(item).toHaveProperty('desc');
}
});
it('should parse costCount as number', () => {
for (const item of heroItemFighter1Data) {
expect(typeof item.costCount).toBe('number');
}
});
it('should contain expected items by name', () => {
const names = heroItemFighter1Data.map(item => item.name);
expect(names).toContain('剑');
expect(names).toContain('盾');
expect(names).toContain('绷带');
expect(names).toContain('火把');
});
it('should have valid type values', () => {
const validTypes = ['weapon', 'armor', 'consumable', 'tool'];
for (const item of heroItemFighter1Data) {
expect(validTypes).toContain(item.type);
}
});
it('should have valid costType values', () => {
const validCostTypes = ['energy', 'uses'];
for (const item of heroItemFighter1Data) {
expect(validCostTypes).toContain(item.costType);
}
});
it('should have valid targetType values', () => {
const validTargetTypes = ['single', 'none'];
for (const item of heroItemFighter1Data) {
expect(validTargetTypes).toContain(item.targetType);
}
});
it('should have correct item counts by type', () => {
const typeCounts = heroItemFighter1Data.reduce((acc, item) => {
acc[item.type] = (acc[item.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
expect(typeCounts['weapon']).toBe(6);
expect(typeCounts['armor']).toBe(6);
expect(typeCounts['consumable']).toBe(6);
expect(typeCounts['tool']).toBe(6);
});
});

View File

@ -0,0 +1,355 @@
import { describe, it, expect } from 'vitest';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import {
checkCollision,
checkBoardCollision,
checkBounds,
validatePlacement,
transformShape,
getOccupiedCells,
IDENTITY_TRANSFORM,
} from '@/samples/slay-the-spire-like/utils/shape-collision';
describe('parseShapeString', () => {
it('should parse a single cell with o', () => {
const result = parseShapeString('o');
expect(result.grid).toEqual([[true]]);
expect(result.width).toBe(1);
expect(result.height).toBe(1);
expect(result.count).toBe(1);
expect(result.originX).toBe(0);
expect(result.originY).toBe(0);
});
it('should parse a horizontal line', () => {
const result = parseShapeString('oee');
expect(result.width).toBe(3);
expect(result.height).toBe(1);
expect(result.count).toBe(3);
expect(result.grid).toEqual([[true, true, true]]);
expect(result.originX).toBe(0);
expect(result.originY).toBe(0);
});
it('should parse a vertical line', () => {
const result = parseShapeString('oss');
expect(result.width).toBe(1);
expect(result.height).toBe(3);
expect(result.count).toBe(3);
expect(result.grid).toEqual([[true], [true], [true]]);
expect(result.originX).toBe(0);
expect(result.originY).toBe(0);
});
it('should parse an L shape', () => {
const result = parseShapeString('oes');
expect(result.width).toBe(2);
expect(result.height).toBe(2);
expect(result.count).toBe(3);
expect(result.grid).toEqual([
[true, true],
[false, true],
]);
});
it('should handle return command', () => {
const result = parseShapeString('oeerww');
expect(result.width).toBe(4);
expect(result.height).toBe(1);
expect(result.count).toBe(4);
expect(result.grid).toEqual([[true, true, true, true]]);
});
it('should handle case insensitivity', () => {
const resultLower = parseShapeString('oes');
const resultUpper = parseShapeString('OES');
expect(resultLower.grid).toEqual(resultUpper.grid);
expect(resultLower.count).toBe(resultUpper.count);
});
it('should return empty grid for empty input', () => {
const result = parseShapeString('');
expect(result.grid).toEqual([[]]);
expect(result.width).toBe(0);
expect(result.height).toBe(1);
expect(result.count).toBe(0);
});
it('should track origin correctly', () => {
// eeso: e(1,0), e(2,0), s(2,1), o sets origin at (2,1)
// After normalization: minX=1, minY=0, so originX = 2-1 = 1, originY = 1-0 = 1
const result = parseShapeString('eeso');
expect(result.originX).toBe(1);
expect(result.originY).toBe(1);
});
it('should track origin at first o only', () => {
const result = parseShapeString('oes');
expect(result.originX).toBe(0);
expect(result.originY).toBe(0);
});
it('should handle complex T shape', () => {
// oewers: o(0,0), e(1,0), w(0,0), e(1,0), r->(0,0), s(0,1)
// Filled: (0,0), (1,0), (0,1) - 3 cells
const result = parseShapeString('oewers');
expect(result.width).toBe(2);
expect(result.height).toBe(2);
expect(result.count).toBe(3);
expect(result.grid).toEqual([
[true, true],
[true, false],
]);
});
});
describe('shape-collision', () => {
describe('getOccupiedCells', () => {
it('should return cells for a single cell shape', () => {
const shape = parseShapeString('o');
const cells = getOccupiedCells(shape);
expect(cells).toEqual([{ x: 0, y: 0 }]);
});
it('should return cells for a horizontal line', () => {
const shape = parseShapeString('oe');
const cells = getOccupiedCells(shape);
expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]);
});
it('should return cells for an L shape', () => {
const shape = parseShapeString('oes');
const cells = getOccupiedCells(shape);
expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
]);
});
});
describe('checkCollision', () => {
it('should detect collision between overlapping shapes', () => {
const shapeA = parseShapeString('o');
const shapeB = parseShapeString('o');
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } }
);
expect(result).toBe(true);
});
it('should not detect collision between non-overlapping shapes', () => {
const shapeA = parseShapeString('o');
const shapeB = parseShapeString('o');
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } }
);
expect(result).toBe(false);
});
it('should detect collision with adjacent shapes', () => {
const shapeA = parseShapeString('o');
const shapeB = parseShapeString('o');
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } }
);
expect(result).toBe(false);
});
it('should detect collision with rotation', () => {
const shapeA = parseShapeString('oe');
const shapeB = parseShapeString('os');
// shapeA is horizontal at (0,0)-(1,0)
// shapeB rotated 90° becomes vertical at (0,0)-(0,1)
// They should collide at (0,0)
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, rotation: 90, offset: { x: 0, y: 0 } }
);
expect(result).toBe(true);
});
});
describe('checkBoardCollision', () => {
it('should detect collision with occupied cells', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['0,0', '1,0']);
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied);
expect(result).toBe(true);
});
it('should not detect collision with empty board', () => {
const shape = parseShapeString('oe');
const occupied = new Set<string>();
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied);
expect(result).toBe(false);
});
it('should detect collision after translation', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['5,5', '6,5']);
const result = checkBoardCollision(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 5, y: 5 } },
occupied
);
expect(result).toBe(true);
});
});
describe('checkBounds', () => {
it('should return true for shape within bounds', () => {
const shape = parseShapeString('oe');
const result = checkBounds(shape, IDENTITY_TRANSFORM, 10, 10);
expect(result).toBe(true);
});
it('should return false for shape outside bounds', () => {
const shape = parseShapeString('oe');
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10
);
expect(result).toBe(false);
});
it('should return false for negative coordinates', () => {
const shape = parseShapeString('oe');
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: -1, y: 0 } },
10,
10
);
expect(result).toBe(false);
});
it('should return true for shape at boundary edge', () => {
const shape = parseShapeString('o');
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 9 } },
10,
10
);
expect(result).toBe(true);
});
});
describe('validatePlacement', () => {
it('should return valid for good placement', () => {
const shape = parseShapeString('oe');
const occupied = new Set<string>();
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied);
expect(result).toEqual({ valid: true });
});
it('should return invalid for out of bounds', () => {
const shape = parseShapeString('oe');
const occupied = new Set<string>();
const result = validatePlacement(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10,
occupied
);
expect(result).toEqual({ valid: false, reason: '超出边界' });
});
it('should return invalid for collision', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['0,0', '1,0']);
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied);
expect(result).toEqual({ valid: false, reason: '与已有形状重叠' });
});
});
describe('transformShape', () => {
it('should apply translation correctly', () => {
const shape = parseShapeString('o');
const transform = { ...IDENTITY_TRANSFORM, offset: { x: 5, y: 3 } };
const cells = transformShape(shape, transform);
expect(cells).toEqual([{ x: 5, y: 3 }]);
});
it('should apply 90° rotation correctly', () => {
const shape = parseShapeString('oe');
const transform = { ...IDENTITY_TRANSFORM, rotation: 90 };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 0, y: -1 },
]);
});
it('should apply horizontal flip correctly', () => {
const shape = parseShapeString('oe');
const transform = { ...IDENTITY_TRANSFORM, flipX: true };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 1, y: 0 },
{ x: 0, y: 0 },
]);
});
it('should apply vertical flip correctly', () => {
const shape = parseShapeString('os');
const transform = { ...IDENTITY_TRANSFORM, flipY: true };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 0, y: 1 },
{ x: 0, y: 0 },
]);
});
it('should combine rotation and translation', () => {
const shape = parseShapeString('os');
const transform = {
...IDENTITY_TRANSFORM,
rotation: 90,
offset: { x: 10, y: 10 },
};
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 10, y: 10 },
{ x: 11, y: 10 },
]);
});
});
});