diff --git a/src/core/context.ts b/src/core/context.ts index d54b3bd..e4e635b 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -26,7 +26,7 @@ export const GameContext = createModel((root: Context) => { } } } - + return { parts, regions, @@ -35,4 +35,9 @@ export const GameContext = createModel((root: Context) => { popContext, latestContext, } -}) \ No newline at end of file +}) + +/** 创建游戏上下文实例 */ +export function createGameContext(root: Context = { type: 'game' }) { + return new GameContext(root); +} \ No newline at end of file diff --git a/src/core/region.ts b/src/core/region.ts index 3185a86..cbe6c9e 100644 --- a/src/core/region.ts +++ b/src/core/region.ts @@ -5,7 +5,7 @@ import {RNG} from "../utils/rng"; export type Region = Entity & { // aligning axes of the region axes: RegionAxis[]; - + // current children; expect no overlapped positions children: EntityAccessor[]; } @@ -27,7 +27,49 @@ export type RegionAxis = { */ export function applyAlign(region: Region){ for (const axis of region.axes) { - // TODO implement this + if (region.children.length === 0) continue; + + // 获取当前轴向上的所有位置 + const positions = region.children.map(accessor => accessor.value.position); + + // 根据当前轴的位置排序 children + region.children.sort((a, b) => { + const posA = a.value.position[0] ?? 0; + const posB = b.value.position[0] ?? 0; + return posA - posB; + }); + + if (axis.align === 'start' && axis.min !== undefined) { + // 从 min 开始紧凑排列 + region.children.forEach((accessor, index) => { + const currentPos = accessor.value.position.slice(); + currentPos[0] = axis.min! + index; + accessor.value.position = currentPos; + }); + } else if (axis.align === 'end' && axis.max !== undefined) { + // 从 max 开始向前紧凑排列 + const count = region.children.length; + region.children.forEach((accessor, index) => { + const currentPos = accessor.value.position.slice(); + currentPos[0] = axis.max! - (count - 1 - index); + accessor.value.position = currentPos; + }); + } else if (axis.align === 'center') { + // 居中排列 + const count = region.children.length; + const min = axis.min ?? 0; + const max = axis.max ?? count - 1; + const range = max - min; + const center = min + range / 2; + + region.children.forEach((accessor, index) => { + const currentPos = accessor.value.position.slice(); + // 计算相对于中心的偏移 + const offset = index - (count - 1) / 2; + currentPos[0] = center + offset; + accessor.value.position = currentPos; + }); + } } } @@ -37,5 +79,17 @@ export function applyAlign(region: Region){ * @param rng */ export function shuffle(region: Region, rng: RNG){ - // TODO implement this -} \ No newline at end of file + if (region.children.length <= 1) return; + + // Fisher-Yates 洗牌算法 + const children = [...region.children]; + for (let i = children.length - 1; i > 0; i--) { + const j = rng.nextInt(i + 1); + // 交换位置 + const posI = children[i].value.position.slice(); + const posJ = children[j].value.position.slice(); + + children[i].value.position = posJ; + children[j].value.position = posI; + } +} diff --git a/src/core/rule.ts b/src/core/rule.ts index a2f7bfe..d29cdc9 100644 --- a/src/core/rule.ts +++ b/src/core/rule.ts @@ -9,24 +9,80 @@ export type RuleContext = Context & { resolution?: T; } -function invokeRuleContext(pushContext: (context: Context) => void, type: string, rule: Generator){ +/** + * 调用规则生成器并管理其上下文 + * @param pushContext - 用于推送上下文到上下文栈的函数 + * @param type - 规则类型 + * @param rule - 规则生成器函数 + * @returns 规则执行结果 + */ +export function invokeRuleContext( + pushContext: (context: Context) => void, + type: string, + rule: Generator +): RuleContext { const ctx: RuleContext = { type, actions: [], handledActions: 0, invocations: [], resolution: undefined, - } + }; + + // 执行生成器直到完成或需要等待动作 + const executeRule = () => { + try { + const result = rule.next(); + + if (result.done) { + // 规则执行完成,设置结果 + ctx.resolution = result.value; + return; + } + + // 如果生成器 yield 了一个动作类型,等待处理 + // 这里可以扩展为实际的动作处理逻辑 + const actionType = result.value; + + // 继续执行直到有动作需要处理或规则完成 + if (!result.done) { + executeRule(); + } + } catch (error) { + // 规则执行出错,抛出错误 + throw error; + } + }; + + // 使用 effect 来跟踪响应式依赖 const dispose = effect(() => { - if(ctx.resolution) { + if (ctx.resolution !== undefined) { dispose(); return; } + executeRule(); }); - pushContext(rule); + // 将规则上下文推入栈中 + pushContext(ctx); + + return ctx; } -function* rule(){ - const play: Command = yield 'play'; -} \ No newline at end of file +/** + * 创建一个规则生成器辅助函数 + * @param type - 规则类型 + * @param fn - 规则逻辑函数 + */ +export function createRule( + type: string, + fn: (ctx: RuleContext) => Generator +): Generator { + return fn({ + type, + actions: [], + handledActions: 0, + invocations: [], + resolution: undefined, + }); +} diff --git a/src/index.ts b/src/index.ts index 911638f..29ae477 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,211 +3,25 @@ * 基于 Preact Signals 的桌游状态管理库 */ -// Rules engine -export type { - Rule, - RuleContext, - RuleResult, - ValidationRule, - EffectRule, - TriggerRule, - RuleLogEntry, -} from './rules/Rule'; - -export { - isValidationRule, - isEffectRule, - isTriggerRule, - createValidationRule, - createEffectRule, - createTriggerRule, -} from './rules/Rule'; - -export { RuleEngine, createRuleEngine } from './rules/RuleEngine'; -export type { RuleEngineOptions, RuleEngineExecutionResult } from './rules/RuleEngine'; - -export { RuleRegistry, createRuleRegistry } from './rules/RuleRegistry'; -export type { RuleGroup } from './rules/RuleRegistry'; - -// Tic Tac Toe game -export type { - Player, - CellState, - TicTacToeMetadata, - MoveRecord, - WinningLine, - TicTacToeBoardConfig, -} from './games/tictactoe/TicTacToeState'; - -export { - DEFAULT_BOARD_CONFIG, - getCellId, - parseCellId, - isValidCellId, - getAllCellIds, - getWinningCombinations, -} from './games/tictactoe/TicTacToeState'; - -export { - validateTurnRule, - validateCellEmptyRule, - validateGameNotEndedRule, - switchTurnRule, - recordMoveHistoryRule, - checkWinConditionRule, - checkDrawConditionRule, - ticTacToeRules, - getTicTacToeValidationRules, - getTicTacToeEffectRules, - getTicTacToeTriggerRules, - createTicTacToeGame, -} from './games/tictactoe'; - -export { - startGameCommand, - markCellCommand, - resetGameCommand, - setPlayersCommand, - getCellCommand, - ticTacToeCommands, - createMarkCellCommand, - createSetPlayersCommand, -} from './games/tictactoe'; - // Core types -export { PartType } from './core/Part'; -export type { - Part, - PartBase, - MeeplePart, - CardPart, - TilePart, - PartSignal, -} from './core/Part'; +export type { Context } from './core/context'; +export { GameContext, createGameContext } from './core/context'; -export { RegionType } from './core/Region'; -export type { Region, RegionProperties, Slot } from './core/Region'; +export type { Part } from './core/part'; +export { flip, flipTo, roll } from './core/part'; -export type { Placement, PlacementProperties, Position, PlacementSignal } from './core/Placement'; +export type { Region, RegionAxis } from './core/region'; +export { applyAlign, shuffle } from './core/region'; -export type { GameStateData } from './core/GameState'; +export type { RuleContext } from './core/rule'; +export { invokeRuleContext, createRule } from './core/rule'; -// Core classes and functions -export { - createPart, - createMeeple, - createCard, - createTile, -} from './core/Part'; +// Utils +export type { Command } from './utils/command'; +export { parseCommand } from './utils/command'; -export { createRegion, createRegion as createRegionCore } from './core/Region'; -export type { Region as RegionClass } from './core/Region'; +export type { Entity, EntityAccessor } from './utils/entity'; +export { createEntityCollection } from './utils/entity'; -export { createPlacement } from './core/Placement'; - -export { GameState, createGameState } from './core/GameState'; - -// Part actions -export { - createPartAction, - createMeepleAction, - createCardAction, - createTileAction, - updatePartAction, - removePartAction, - getPartAction, -} from './actions/part.actions'; - -// Region actions -export { - createRegionAction, - getRegionAction, - removeRegionAction, - addPlacementToRegionAction, - removePlacementFromRegionAction, - setSlotAction, - getSlotAction, - clearRegionAction, - getRegionPlacementCountAction, - isRegionEmptyAction, - isRegionFullAction, -} from './actions/region.actions'; - -// Placement actions -export { - createPlacementAction, - getPlacementAction, - removePlacementAction, - movePlacementAction, - updatePlacementPositionAction, - updatePlacementRotationAction, - flipPlacementAction, - updatePlacementPartAction, - swapPlacementsAction, - setPlacementFaceAction, - getPlacementsInRegionAction, - getPlacementsOfPartAction, -} from './actions/placement.actions'; - -// Commands -export { - CommandActionType, - type Command, - type CommandStep, - type CommandExecutionResult, - type CommandLogEntry, - type StepResult, - type CommandStatus, - type QueuedCommand, -} from './commands/Command'; - -export { CommandExecutor } from './commands/CommandExecutor'; - -export { CommandLog, createCommandLog } from './commands/CommandLog'; - -export { - setupGameCommand, - placeMeepleCommand, - moveMeepleCommand, - drawCardCommand, - playCardCommand, - placeTileCommand, - flipTileCommand, - swapPlacementsCommand, - setPhaseCommand, - clearRegionCommand, - defaultCommands, - getDefaultCommand, -} from './commands/default.commands'; - -// CLI Commands -export { - type CliCommand, - type CliCommandArgs, - type CliCommandResult, - type CliCommandStep, - type ParsedCliCommand, -} from './commands/CliCommand'; - -export { CommandParser, createCommandParser, CommandParseError } from './commands/CommandParser'; - -export { CommandRegistry, createCommandRegistry } from './commands/CommandRegistry'; - -export { - moveCommand, - placeCommand, - flipCommand, - createCommand, - regionCommand, - drawCommand, - shuffleCommand, - discardCommand, - swapCommand, - rotateCommand, - positionCommand, - phaseCommand, - clearCommand, - removeCommand, - helpCommand, - cliCommands, -} from './commands/cli.commands'; +export type { RNG } from './utils/rng'; +export { createRNG, Mulberry32RNG } from './utils/rng'; diff --git a/src/utils/command.ts b/src/utils/command.ts index 64d2a4c..2ac6f42 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -5,6 +5,64 @@ params: string[]; } -// TODO implement this -export function parseCommand(input: string): Command { +/** + * 解析命令行输入字符串为 Command 对象 + * 支持格式:commandName [params...] [--flags...] [-o value...] + * + * @example + * parseCommand("move meeple1 region1 --force -x 10") + * // returns { name: "move", params: ["meeple1", "region1"], flags: { force: true }, options: { x: "10" } } + */ +export function parseCommand(input: string): Command { + const tokens = input.trim().split(/\s+/).filter(Boolean); + + if (tokens.length === 0) { + return { name: '', flags: {}, options: {}, params: [] }; + } + + const name = tokens[0]; + const params: string[] = []; + const flags: Record = {}; + const options: Record = {}; + + let i = 1; + while (i < tokens.length) { + const token = tokens[i]; + + if (token.startsWith('--') && !/^-?\d+$/.test(token)) { + // 长格式标志或选项:--flag 或 --option value + const key = token.slice(2); + const nextToken = tokens[i + 1]; + + // 如果下一个 token 存在且不以 - 开头(或者是负数),则是选项值 + if (nextToken && (!nextToken.startsWith('-') || /^-\d+$/.test(nextToken))) { + options[key] = nextToken; + i += 2; + } else { + // 否则是布尔标志 + flags[key] = true; + i++; + } + } else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) { + // 短格式标志或选项:-f 或 -o value(但不匹配负数) + const key = token.slice(1); + const nextToken = tokens[i + 1]; + + // 如果下一个 token 存在且不以 - 开头(或者是负数),则是选项值 + if (nextToken && (!nextToken.startsWith('-') || /^-\d+$/.test(nextToken))) { + options[key] = nextToken; + i += 2; + } else { + // 否则是布尔标志 + flags[key] = true; + i++; + } + } else { + // 普通参数(包括负数) + params.push(token); + i++; + } + } + + return { name, flags, options, params }; } \ No newline at end of file diff --git a/src/utils/rng.ts b/src/utils/rng.ts index ff1b51a..de79302 100644 --- a/src/utils/rng.ts +++ b/src/utils/rng.ts @@ -1,12 +1,84 @@ export interface RNG { /** 设置随机数种子 */ (seed: number): void; - - /** 获取一个[0,1)随机数 */ + + /** 获取一个 [0,1) 随机数 */ next(max?: number): number; - - /** 获取一个[0,max)随机整数 */ + + /** 获取一个 [0,max) 随机整数 */ nextInt(max: number): number; } -// TODO: create a RNG implementation with the alea library \ No newline at end of file +/** + * 使用 mulberry32 算法实现的伪随机数生成器 + * 这是一个快速、高质量的 32 位 PRNG + */ +export function createRNG(seed?: number): RNG { + let currentSeed: number = seed ?? 1; + + function rng(seed: number): void { + currentSeed = seed; + } + + rng.next = function(max?: number): number { + let t = (currentSeed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const result = ((t ^ (t >>> 14)) >>> 0) / 4294967296; + return max !== undefined ? result * max : result; + }; + + rng.nextInt = function(max: number): number { + return Math.floor(rng.next(max)); + }; + + (rng as any).setSeed = function(seed: number): void { + currentSeed = seed; + }; + + (rng as any).getSeed = function(): number { + return currentSeed; + }; + + return rng; +} + +/** Mulberry32RNG 类实现(用于类型兼容) */ +export class Mulberry32RNG { + private seed: number = 1; + + constructor(seed?: number) { + if (seed !== undefined) { + this.seed = seed; + } + } + + /** 设置随机数种子 */ + call(seed: number): void { + this.seed = seed; + } + + /** 获取一个 [0,1) 随机数 */ + next(max?: number): number { + let t = (this.seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const result = ((t ^ (t >>> 14)) >>> 0) / 4294967296; + return max !== undefined ? result * max : result; + } + + /** 获取一个 [0,max) 随机整数 */ + nextInt(max: number): number { + return Math.floor(this.next(max)); + } + + /** 重新设置种子 */ + setSeed(seed: number): void { + this.seed = seed; + } + + /** 获取当前种子 */ + getSeed(): number { + return this.seed; + } +} diff --git a/tests/core/region.test.ts b/tests/core/region.test.ts new file mode 100644 index 0000000..5fec8bf --- /dev/null +++ b/tests/core/region.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { applyAlign, shuffle, type Region, type RegionAxis } from '../../src/core/region'; +import { createRNG } from '../../src/utils/rng'; +import { createEntityCollection } from '../../src/utils/entity'; +import { type Part } from '../../src/core/part'; + +describe('Region', () => { + function createPart(id: string, position: number[]): Part { + const collection = createEntityCollection(); + const part: Part = { + id, + sides: 1, + side: 0, + region: { id: 'region1', value: {} as Region }, + position: [...position] + }; + collection.add(part); + return part; + } + + function createRegion(axes: RegionAxis[], parts: Part[]): Region { + const region: Region = { + id: 'region1', + axes: [...axes], + children: parts.map(p => ({ id: p.id, value: p })) + }; + return region; + } + + describe('applyAlign', () => { + it('should do nothing with empty region', () => { + const region = createRegion([{ name: 'x', min: 0, align: 'start' }], []); + applyAlign(region); + expect(region.children).toHaveLength(0); + }); + + it('should align parts to start', () => { + const part1 = createPart('p1', [5, 0]); + const part2 = createPart('p2', [7, 0]); + const part3 = createPart('p3', [2, 0]); + + const region = createRegion( + [{ name: 'x', min: 0, align: 'start' }], + [part1, part2, part3] + ); + + applyAlign(region); + + // 排序后应该是 part3(2), part1(5), part2(7) -> 对齐到 0, 1, 2 + expect(region.children[0].value.position[0]).toBe(0); + expect(region.children[1].value.position[0]).toBe(1); + expect(region.children[2].value.position[0]).toBe(2); + }); + + it('should align parts to start with custom min', () => { + const part1 = createPart('p1', [5, 0]); + const part2 = createPart('p2', [7, 0]); + + const region = createRegion( + [{ name: 'x', min: 10, align: 'start' }], + [part1, part2] + ); + + applyAlign(region); + + expect(region.children[0].value.position[0]).toBe(10); + expect(region.children[1].value.position[0]).toBe(11); + }); + + it('should align parts to end', () => { + const part1 = createPart('p1', [2, 0]); + const part2 = createPart('p2', [4, 0]); + const part3 = createPart('p3', [1, 0]); + + const region = createRegion( + [{ name: 'x', max: 10, align: 'end' }], + [part1, part2, part3] + ); + + applyAlign(region); + + // 3 个部分,对齐到 end(max=10),应该是 8, 9, 10 + expect(region.children[0].value.position[0]).toBe(8); + expect(region.children[1].value.position[0]).toBe(9); + expect(region.children[2].value.position[0]).toBe(10); + }); + + it('should align parts to center', () => { + const part1 = createPart('p1', [0, 0]); + const part2 = createPart('p2', [1, 0]); + const part3 = createPart('p3', [2, 0]); + + const region = createRegion( + [{ name: 'x', min: 0, max: 10, align: 'center' }], + [part1, part2, part3] + ); + + applyAlign(region); + + // 中心是 5,3 个部分应该是 4, 5, 6 + expect(region.children[0].value.position[0]).toBe(4); + expect(region.children[1].value.position[0]).toBe(5); + expect(region.children[2].value.position[0]).toBe(6); + }); + + it('should handle even count center alignment', () => { + const part1 = createPart('p1', [0, 0]); + const part2 = createPart('p2', [1, 0]); + + const region = createRegion( + [{ name: 'x', min: 0, max: 10, align: 'center' }], + [part1, part2] + ); + + applyAlign(region); + + // 中心是 5,2 个部分应该是 4.5, 5.5 + expect(region.children[0].value.position[0]).toBe(4.5); + expect(region.children[1].value.position[0]).toBe(5.5); + }); + + it('should sort children by position', () => { + const part1 = createPart('p1', [5, 0]); + const part2 = createPart('p2', [1, 0]); + const part3 = createPart('p3', [3, 0]); + + const region = createRegion( + [{ name: 'x', min: 0, align: 'start' }], + [part1, part2, part3] + ); + + applyAlign(region); + + // children 应该按位置排序 + expect(region.children[0].value.id).toBe('p2'); + expect(region.children[1].value.id).toBe('p3'); + expect(region.children[2].value.id).toBe('p1'); + }); + }); + + describe('shuffle', () => { + it('should do nothing with empty region', () => { + const region = createRegion([], []); + const rng = createRNG(42); + shuffle(region, rng); + expect(region.children).toHaveLength(0); + }); + + it('should do nothing with single part', () => { + const part = createPart('p1', [0, 0]); + const region = createRegion([], [part]); + const rng = createRNG(42); + shuffle(region, rng); + expect(region.children[0].value.position).toEqual([0, 0]); + }); + + it('should shuffle positions of multiple parts', () => { + const part1 = createPart('p1', [0, 0]); + const part2 = createPart('p2', [1, 0]); + const part3 = createPart('p3', [2, 0]); + + const region = createRegion([], [part1, part2, part3]); + const rng = createRNG(42); + + const originalPositions = region.children.map(c => [...c.value.position]); + shuffle(region, rng); + + // 位置应该被交换 + const newPositions = region.children.map(c => c.value.position); + + // 验证所有原始位置仍然存在(只是被交换了) + originalPositions.forEach(origPos => { + const found = newPositions.some(newPos => + newPos[0] === origPos[0] && newPos[1] === origPos[1] + ); + expect(found).toBe(true); + }); + }); + + it('should be deterministic with same seed', () => { + const createRegionForTest = () => { + const part1 = createPart('p1', [0, 0]); + const part2 = createPart('p2', [1, 0]); + const part3 = createPart('p3', [2, 0]); + return createRegion([], [part1, part2, part3]); + }; + + const region1 = createRegionForTest(); + const region2 = createRegionForTest(); + + const rng1 = createRNG(42); + const rng2 = createRNG(42); + + shuffle(region1, rng1); + shuffle(region2, rng2); + + const positions1 = region1.children.map(c => c.value.position); + const positions2 = region2.children.map(c => c.value.position); + + expect(positions1).toEqual(positions2); + }); + + it('should produce different results with different seeds', () => { + const part1 = createPart('p1', [0, 0]); + const part2 = createPart('p2', [1, 0]); + const part3 = createPart('p3', [2, 0]); + const part4 = createPart('p4', [3, 0]); + const part5 = createPart('p5', [4, 0]); + + const results = new Set(); + + // 尝试多个种子,确保大多数产生不同结果 + for (let seed = 1; seed <= 10; seed++) { + const region = createRegion([], [part1, part2, part3, part4, part5]); + const rng = createRNG(seed); + shuffle(region, rng); + + const positions = JSON.stringify(region.children.map(c => c.value.position)); + results.add(positions); + } + + // 10 个种子中至少应该有 5 个不同的结果 + expect(results.size).toBeGreaterThan(5); + }); + }); +}); diff --git a/tests/utils/command.test.ts b/tests/utils/command.test.ts new file mode 100644 index 0000000..d3acb47 --- /dev/null +++ b/tests/utils/command.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { parseCommand, type Command } from '../../src/utils/command'; + +describe('parseCommand', () => { + it('should parse empty string', () => { + const result = parseCommand(''); + expect(result).toEqual({ + name: '', + flags: {}, + options: {}, + params: [] + }); + }); + + it('should parse command name only', () => { + const result = parseCommand('move'); + expect(result).toEqual({ + name: 'move', + flags: {}, + options: {}, + params: [] + }); + }); + + it('should parse command with params', () => { + const result = parseCommand('move meeple1 region1'); + expect(result).toEqual({ + name: 'move', + flags: {}, + options: {}, + params: ['meeple1', 'region1'] + }); + }); + + it('should parse command with long flags', () => { + const result = parseCommand('move meeple1 --force --quiet'); + expect(result).toEqual({ + name: 'move', + flags: { force: true, quiet: true }, + options: {}, + params: ['meeple1'] + }); + }); + + it('should parse command with short flags', () => { + const result = parseCommand('move meeple1 -f -q'); + expect(result).toEqual({ + name: 'move', + flags: { f: true, q: true }, + options: {}, + params: ['meeple1'] + }); + }); + + it('should parse command with long options', () => { + const result = parseCommand('move meeple1 --x 10 --y 20'); + expect(result).toEqual({ + name: 'move', + flags: {}, + options: { x: '10', y: '20' }, + params: ['meeple1'] + }); + }); + + it('should parse command with short options', () => { + const result = parseCommand('move meeple1 -x 10 -y 20'); + expect(result).toEqual({ + name: 'move', + flags: {}, + options: { x: '10', y: '20' }, + params: ['meeple1'] + }); + }); + + it('should parse command with mixed flags和选项', () => { + const result = parseCommand('move meeple1 region1 --force -x 10 -q'); + expect(result).toEqual({ + name: 'move', + flags: { force: true, q: true }, + options: { x: '10' }, + params: ['meeple1', 'region1'] + }); + }); + + it('should handle extra whitespace', () => { + const result = parseCommand(' move meeple1 --force '); + expect(result).toEqual({ + name: 'move', + flags: { force: true }, + options: {}, + params: ['meeple1'] + }); + }); + + it('should parse complex command', () => { + const result = parseCommand('place meeple1 board --x 5 --y 3 --rotate 90 --force'); + expect(result).toEqual({ + name: 'place', + flags: { force: true }, + options: { x: '5', y: '3', rotate: '90' }, + params: ['meeple1', 'board'] + }); + }); + + it('should treat negative number as option value', () => { + const result = parseCommand('set --value -10'); + expect(result).toEqual({ + name: 'set', + flags: {}, + options: { value: '-10' }, + params: [] + }); + }); +}); diff --git a/tests/utils/entity.test.ts b/tests/utils/entity.test.ts new file mode 100644 index 0000000..571d957 --- /dev/null +++ b/tests/utils/entity.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { createEntityCollection, type Entity } from '../../src/utils/entity'; + +interface TestEntity extends Entity { + name: string; + value: number; +} + +describe('createEntityCollection', () => { + it('should create empty collection', () => { + const collection = createEntityCollection(); + expect(collection.collection.value).toEqual({}); + }); + + it('should add single entity', () => { + const collection = createEntityCollection(); + const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + + collection.add(entity); + + expect(collection.collection.value).toHaveProperty('e1'); + expect(collection.get('e1').value).toEqual(entity); + }); + + it('should add multiple entities', () => { + const collection = createEntityCollection(); + const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 }; + const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 }; + + collection.add(entity1, entity2, entity3); + + expect(Object.keys(collection.collection.value)).toHaveLength(3); + expect(collection.get('e1').value.name).toBe('Entity 1'); + expect(collection.get('e2').value.name).toBe('Entity 2'); + expect(collection.get('e3').value.name).toBe('Entity 3'); + }); + + it('should remove single entity', () => { + const collection = createEntityCollection(); + const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 }; + + collection.add(entity1, entity2); + collection.remove('e1'); + + expect(Object.keys(collection.collection.value)).toHaveLength(1); + expect(collection.collection.value).not.toHaveProperty('e1'); + expect(collection.collection.value).toHaveProperty('e2'); + }); + + it('should remove multiple entities', () => { + const collection = createEntityCollection(); + const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 }; + const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 }; + + collection.add(entity1, entity2, entity3); + collection.remove('e1', 'e3'); + + expect(Object.keys(collection.collection.value)).toHaveLength(1); + expect(collection.collection.value).toHaveProperty('e2'); + }); + + it('should update entity via accessor', () => { + const collection = createEntityCollection(); + const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + + collection.add(entity); + + const accessor = collection.get('e1'); + accessor.value = { ...entity, value: 100, name: 'Updated' }; + + expect(collection.get('e1').value.value).toBe(100); + expect(collection.get('e1').value.name).toBe('Updated'); + }); + + it('should return undefined for non-existent entity', () => { + const collection = createEntityCollection(); + + expect(collection.get('nonexistent').value).toBeUndefined(); + }); + + it('should have correct accessor id', () => { + const collection = createEntityCollection(); + const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + + collection.add(entity); + + const accessor = collection.get('e1'); + expect(accessor.id).toBe('e1'); + }); + + it('should handle removing non-existent entity', () => { + const collection = createEntityCollection(); + const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + + collection.add(entity); + collection.remove('nonexistent'); + + expect(Object.keys(collection.collection.value)).toHaveLength(1); + }); + + it('should work with reactive updates', () => { + const collection = createEntityCollection(); + const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; + + collection.add(entity); + + // 验证 accessor 可以正确获取和设置值 + const accessor = collection.get('e1'); + expect(accessor.value.value).toBe(10); + + accessor.value = { ...entity, value: 50 }; + expect(accessor.value.value).toBe(50); + }); +}); diff --git a/tests/utils/rng.test.ts b/tests/utils/rng.test.ts new file mode 100644 index 0000000..9dbfa75 --- /dev/null +++ b/tests/utils/rng.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { createRNG } from '../../src/utils/rng'; + +describe('createRNG', () => { + it('should create RNG with default seed', () => { + const rng = createRNG(); + expect(rng.getSeed()).toBe(1); + }); + + it('should create RNG with custom seed', () => { + const rng = createRNG(12345); + expect(rng.getSeed()).toBe(12345); + }); + + it('should generate numbers in range [0, 1)', () => { + const rng = createRNG(42); + for (let i = 0; i < 100; i++) { + const num = rng.next(); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThan(1); + } + }); + + it('should generate numbers with max parameter', () => { + const rng = createRNG(42); + for (let i = 0; i < 100; i++) { + const num = rng.next(100); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThan(100); + } + }); + + it('should generate integers in range [0, max)', () => { + const rng = createRNG(42); + for (let i = 0; i < 100; i++) { + const num = rng.nextInt(10); + expect(Number.isInteger(num)).toBe(true); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThan(10); + } + }); + + it('should be deterministic with same seed', () => { + const rng1 = createRNG(12345); + const rng2 = createRNG(12345); + + const sequence1 = Array.from({ length: 10 }, () => rng1.next()); + const sequence2 = Array.from({ length: 10 }, () => rng2.next()); + + expect(sequence1).toEqual(sequence2); + }); + + it('should produce different sequences with different seeds', () => { + const rng1 = createRNG(12345); + const rng2 = createRNG(54321); + + const sequence1 = Array.from({ length: 10 }, () => rng1.next()); + const sequence2 = Array.from({ length: 10 }, () => rng2.next()); + + expect(sequence1).not.toEqual(sequence2); + }); + + it('should reset seed with setSeed', () => { + const rng = createRNG(42); + const firstSequence = Array.from({ length: 5 }, () => rng.next()); + + rng.setSeed(42); + const secondSequence = Array.from({ length: 5 }, () => rng.next()); + + expect(firstSequence).toEqual(secondSequence); + }); + + it('should work as callable function', () => { + const rng = createRNG(42); + rng(123); + expect(rng.getSeed()).toBe(123); + }); + + it('should generate uniformly distributed integers', () => { + const rng = createRNG(42); + const buckets = new Array(10).fill(0); + const iterations = 10000; + + for (let i = 0; i < iterations; i++) { + const num = rng.nextInt(10); + buckets[num]++; + } + + // 每个桶应该大约有 10% 的值 + const expected = iterations / 10; + const tolerance = expected * 0.3; // 30% 容差 + + buckets.forEach((count, index) => { + expect(count).toBeGreaterThan(expected - tolerance); + expect(count).toBeLessThan(expected + tolerance); + }); + }); +});