Compare commits
No commits in common. "4761806a02afecda0e630909e985f1b37139b155" and "033fb6c89427338b5583e04bce5c22f804615802" have entirely different histories.
4761806a02
...
033fb6c894
186
README.md
186
README.md
|
|
@ -1,186 +1,8 @@
|
||||||
# boardgame-core
|
# boardgame-core
|
||||||
|
|
||||||
A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/).
|
基于 Preact Signals 的桌游状态管理库。
|
||||||
|
|
||||||
## Features
|
## 特性
|
||||||
|
|
||||||
- **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/)
|
- **响应式状态管理**: 使用 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 实现细粒度响应式
|
||||||
- **Type Safe**: Full TypeScript support with strict mode
|
- **类型安全**: 完整的 TypeScript 支持
|
||||||
- **Entity Collections**: Signal-backed collections for managing game pieces (cards, dice, tokens, meeples, etc.)
|
|
||||||
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling
|
|
||||||
- **Command Parsing**: CLI-style command parsing with schema validation and type coercion
|
|
||||||
- **Rule Engine**: Generator-based rule system with reactive context management
|
|
||||||
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install boardgame-core
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Game Context
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createGameContext } from 'boardgame-core';
|
|
||||||
|
|
||||||
const game = createGameContext({ type: 'game' });
|
|
||||||
|
|
||||||
// Access entity collections
|
|
||||||
game.parts.add({ id: 'card1', sides: 2, side: 0, region: /* ... */, position: [0] });
|
|
||||||
game.regions.add({ id: 'hand', axes: [{ name: 'slot', min: 0, max: 7, align: 'start' }], children: [] });
|
|
||||||
|
|
||||||
// Context stack for nested rule scopes
|
|
||||||
game.pushContext({ type: 'combat' });
|
|
||||||
const combatCtx = game.latestContext('combat');
|
|
||||||
game.popContext();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parts (Cards, Dice, Tokens)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { flip, flipTo, roll, createRNG } from 'boardgame-core';
|
|
||||||
|
|
||||||
const rng = createRNG(42);
|
|
||||||
|
|
||||||
flip(card); // cycle to next side
|
|
||||||
flipTo(card, 0); // set to specific side
|
|
||||||
roll(dice, rng); // random side using seeded RNG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Regions & Alignment
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { applyAlign, shuffle } from 'boardgame-core';
|
|
||||||
|
|
||||||
// Compact cards in a hand towards the start
|
|
||||||
applyAlign(handRegion);
|
|
||||||
|
|
||||||
// Shuffle positions of all parts in a region
|
|
||||||
shuffle(handRegion, rng);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Parsing
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { parseCommand, parseCommandSchema, validateCommand } from 'boardgame-core';
|
|
||||||
|
|
||||||
// Parse a command string
|
|
||||||
const cmd = parseCommand('move card1 hand --force -x 10');
|
|
||||||
// { name: 'move', params: ['card1', 'hand'], flags: { force: true }, options: { x: '10' } }
|
|
||||||
|
|
||||||
// Define and validate against a schema
|
|
||||||
const schema = parseCommandSchema('move <from> <to> [--force] [-x: number]');
|
|
||||||
const result = validateCommand(cmd, schema);
|
|
||||||
// { valid: true }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entity Collections
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createEntityCollection } from 'boardgame-core';
|
|
||||||
|
|
||||||
const collection = createEntityCollection();
|
|
||||||
collection.add({ id: 'a', name: 'Item A' }, { id: 'b', name: 'Item B' });
|
|
||||||
|
|
||||||
const accessor = collection.get('a');
|
|
||||||
console.log(accessor.value); // reactive access
|
|
||||||
|
|
||||||
collection.remove('a');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rule Engine
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRule, invokeRuleContext } from 'boardgame-core';
|
|
||||||
|
|
||||||
const myRule = createRule('drawCard', (ctx) => {
|
|
||||||
// yield action types to pause and wait for external handling
|
|
||||||
const action = yield 'draw';
|
|
||||||
ctx.resolution = action;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = invokeRuleContext(
|
|
||||||
game.pushContext.bind(game),
|
|
||||||
'drawCard',
|
|
||||||
myRule({ type: 'drawCard', actions: [], handledActions: 0, invocations: [] })
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Random Number Generation
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRNG } from 'boardgame-core';
|
|
||||||
|
|
||||||
const rng = createRNG(12345);
|
|
||||||
rng.nextInt(6); // 0-5
|
|
||||||
rng.next(); // [0, 1)
|
|
||||||
rng.next(100); // [0, 100)
|
|
||||||
rng.setSeed(999); // reseed
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Core
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `createGameContext(root?)` | Create a new game context instance |
|
|
||||||
| `GameContext` | The game context model class |
|
|
||||||
| `Context` | Base context type |
|
|
||||||
|
|
||||||
### Parts
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `Part` | Entity type representing a game piece (card, die, token, etc.) |
|
|
||||||
| `flip(part)` | Cycle to the next side |
|
|
||||||
| `flipTo(part, side)` | Set to a specific side |
|
|
||||||
| `roll(part, rng)` | Randomize side using RNG |
|
|
||||||
|
|
||||||
### Regions
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `Region` | Entity type for spatial grouping of parts |
|
|
||||||
| `RegionAxis` | Axis definition with min/max/align |
|
|
||||||
| `applyAlign(region)` | Compact parts according to axis alignment |
|
|
||||||
| `shuffle(region, rng)` | Randomize part positions |
|
|
||||||
|
|
||||||
### Rules
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `RuleContext<T>` | Rule execution context type |
|
|
||||||
| `createRule(type, fn)` | Create a rule generator factory |
|
|
||||||
| `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management |
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `parseCommand(input)` | Parse a command string into a `Command` object |
|
|
||||||
| `parseCommandSchema(schema)` | Parse a schema string into a `CommandSchema` |
|
|
||||||
| `validateCommand(cmd, schema)` | Validate a command against a schema |
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `createEntityCollection<T>()` | Create a reactive entity collection |
|
|
||||||
| `createRNG(seed?)` | Create a seeded RNG instance |
|
|
||||||
| `Mulberry32RNG` | Mulberry32 PRNG class |
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build # Build with tsup
|
|
||||||
npm run test # Run tests in watch mode
|
|
||||||
npm run test:run # Run tests once
|
|
||||||
npm run typecheck # Type check with TypeScript
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run"
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"boardgame-core": "file:",
|
||||||
"inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema"
|
"inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import {createModel, Signal, signal} from '@preact/signals-core';
|
||||||
import {createEntityCollection} from "../utils/entity";
|
import {createEntityCollection} from "../utils/entity";
|
||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {Region} from "./region";
|
import {Region} from "./region";
|
||||||
import {RuleRegistry, RuleContext, dispatchCommand as dispatchRuleCommand} from "./rule";
|
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -11,50 +10,30 @@ export type Context = {
|
||||||
export const GameContext = createModel((root: Context) => {
|
export const GameContext = createModel((root: Context) => {
|
||||||
const parts = createEntityCollection<Part>();
|
const parts = createEntityCollection<Part>();
|
||||||
const regions = createEntityCollection<Region>();
|
const regions = createEntityCollection<Region>();
|
||||||
const rules = signal<RuleRegistry>(new Map());
|
const contexts = signal([signal(root)]);
|
||||||
const ruleContexts = signal<RuleContext<unknown>[]>([]);
|
|
||||||
const contexts = signal<Signal<Context>[]>([]);
|
|
||||||
contexts.value = [signal(root)];
|
|
||||||
|
|
||||||
function pushContext(context: Context) {
|
function pushContext(context: Context) {
|
||||||
const ctxSignal = signal(context);
|
const ctxSignal = signal(context);
|
||||||
contexts.value = [...contexts.value, ctxSignal];
|
contexts.value = [...contexts.value, ctxSignal];
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function popContext() {
|
function popContext() {
|
||||||
if (contexts.value.length > 1) {
|
|
||||||
contexts.value = contexts.value.slice(0, -1);
|
contexts.value = contexts.value.slice(0, -1);
|
||||||
}
|
}
|
||||||
}
|
function latestContext<T extends Context>(type: T['type']){
|
||||||
|
|
||||||
function latestContext<T extends Context>(type: T['type']): Signal<T> | undefined {
|
|
||||||
for(let i = contexts.value.length - 1; i >= 0; i--){
|
for(let i = contexts.value.length - 1; i >= 0; i--){
|
||||||
if(contexts.value[i].value.type === type){
|
if(contexts.value[i].value.type === type){
|
||||||
return contexts.value[i] as Signal<T>;
|
return contexts.value[i] as Signal<T>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchCommand(input: string) {
|
|
||||||
return dispatchRuleCommand({
|
|
||||||
rules: rules.value,
|
|
||||||
ruleContexts: ruleContexts.value,
|
|
||||||
contexts,
|
|
||||||
}, input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parts,
|
parts,
|
||||||
regions,
|
regions,
|
||||||
rules,
|
|
||||||
ruleContexts,
|
|
||||||
contexts,
|
contexts,
|
||||||
pushContext,
|
pushContext,
|
||||||
popContext,
|
popContext,
|
||||||
latestContext,
|
latestContext,
|
||||||
dispatchCommand,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,36 +28,36 @@ export type RegionAxis = {
|
||||||
export function applyAlign(region: Region){
|
export function applyAlign(region: Region){
|
||||||
if (region.children.length === 0) return;
|
if (region.children.length === 0) return;
|
||||||
|
|
||||||
// Process each axis independently while preserving spatial relationships
|
// 对每个 axis 分别处理,但保持空间关系
|
||||||
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
||||||
const axis = region.axes[axisIndex];
|
const axis = region.axes[axisIndex];
|
||||||
if (!axis.align) continue;
|
if (!axis.align) continue;
|
||||||
|
|
||||||
// Collect all unique position values on this axis, preserving original order
|
// 收集当前轴上的所有唯一位置值,保持原有顺序
|
||||||
const positionValues = new Set<number>();
|
const positionValues = new Set<number>();
|
||||||
for (const accessor of region.children) {
|
for (const accessor of region.children) {
|
||||||
positionValues.add(accessor.value.position[axisIndex] ?? 0);
|
positionValues.add(accessor.value.position[axisIndex] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort position values
|
// 排序位置值
|
||||||
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
|
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
|
||||||
|
|
||||||
// Create position mapping: old position -> new position
|
// 创建位置映射:原位置 -> 新位置
|
||||||
const positionMap = new Map<number, number>();
|
const positionMap = new Map<number, number>();
|
||||||
|
|
||||||
if (axis.align === 'start' && axis.min !== undefined) {
|
if (axis.align === 'start' && axis.min !== undefined) {
|
||||||
// Compact from min, preserving relative order
|
// 从 min 开始紧凑排列,保持相对顺序
|
||||||
sortedPositions.forEach((pos, index) => {
|
sortedPositions.forEach((pos, index) => {
|
||||||
positionMap.set(pos, axis.min! + index);
|
positionMap.set(pos, axis.min! + index);
|
||||||
});
|
});
|
||||||
} else if (axis.align === 'end' && axis.max !== undefined) {
|
} else if (axis.align === 'end' && axis.max !== undefined) {
|
||||||
// Compact towards max
|
// 从 max 开始向前紧凑排列
|
||||||
const count = sortedPositions.length;
|
const count = sortedPositions.length;
|
||||||
sortedPositions.forEach((pos, index) => {
|
sortedPositions.forEach((pos, index) => {
|
||||||
positionMap.set(pos, axis.max! - (count - 1 - index));
|
positionMap.set(pos, axis.max! - (count - 1 - index));
|
||||||
});
|
});
|
||||||
} else if (axis.align === 'center') {
|
} else if (axis.align === 'center') {
|
||||||
// Center alignment
|
// 居中排列
|
||||||
const count = sortedPositions.length;
|
const count = sortedPositions.length;
|
||||||
const min = axis.min ?? 0;
|
const min = axis.min ?? 0;
|
||||||
const max = axis.max ?? count - 1;
|
const max = axis.max ?? count - 1;
|
||||||
|
|
@ -70,14 +70,14 @@ export function applyAlign(region: Region){
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply position mapping to all parts
|
// 应用位置映射到所有 part
|
||||||
for (const accessor of region.children) {
|
for (const accessor of region.children) {
|
||||||
const currentPos = accessor.value.position[axisIndex] ?? 0;
|
const currentPos = accessor.value.position[axisIndex] ?? 0;
|
||||||
accessor.value.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
|
accessor.value.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort children by all axes at the end
|
// 最后按所有轴排序 children
|
||||||
region.children.sort((a, b) => {
|
region.children.sort((a, b) => {
|
||||||
for (let i = 0; i < region.axes.length; i++) {
|
for (let i = 0; i < region.axes.length; i++) {
|
||||||
const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
|
const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
|
||||||
|
|
|
||||||
216
src/core/rule.ts
216
src/core/rule.ts
|
|
@ -1,164 +1,88 @@
|
||||||
import {Command, CommandSchema, parseCommand, parseCommandSchema} from "../utils/command";
|
import {Context} from "./context";
|
||||||
|
import {Command} from "../utils/command";
|
||||||
|
import {effect} from "@preact/signals-core";
|
||||||
|
|
||||||
export type RuleState = 'running' | 'yielded' | 'waiting' | 'done';
|
export type RuleContext<T> = Context & {
|
||||||
|
actions: Command[];
|
||||||
export type RuleContext<T = unknown> = {
|
handledActions: number;
|
||||||
type: string;
|
invocations: RuleContext<unknown>[];
|
||||||
schema?: CommandSchema;
|
|
||||||
generator: Generator<string | CommandSchema, T, Command>;
|
|
||||||
parent?: RuleContext<unknown>;
|
|
||||||
children: RuleContext<unknown>[];
|
|
||||||
state: RuleState;
|
|
||||||
resolution?: T;
|
resolution?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuleDef<T = unknown> = {
|
/**
|
||||||
schema: CommandSchema;
|
* 调用规则生成器并管理其上下文
|
||||||
create: (cmd: Command) => Generator<string | CommandSchema, T, Command>;
|
* @param pushContext - 用于推送上下文到上下文栈的函数
|
||||||
};
|
* @param type - 规则类型
|
||||||
|
* @param rule - 规则生成器函数
|
||||||
export type RuleRegistry = Map<string, RuleDef<unknown>>;
|
* @returns 规则执行结果
|
||||||
|
*/
|
||||||
export function createRule<T>(
|
export function invokeRuleContext<T>(
|
||||||
schemaStr: string,
|
pushContext: (context: Context) => void,
|
||||||
fn: (cmd: Command) => Generator<string | CommandSchema, T, Command>
|
type: string,
|
||||||
): RuleDef<T> {
|
rule: Generator<string, T, Command>
|
||||||
return {
|
|
||||||
schema: parseCommandSchema(schemaStr, ''),
|
|
||||||
create: fn as RuleDef<T>['create'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseYieldedSchema(value: string | CommandSchema): CommandSchema {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return parseCommandSchema(value, '');
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushContextToGame(game: GameContextLike, ctx: RuleContext<unknown>) {
|
|
||||||
game.contexts.value = [...game.contexts.value, { value: ctx } as any];
|
|
||||||
game.ruleContexts.push(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function discardChildren(game: GameContextLike, parent: RuleContext<unknown>) {
|
|
||||||
for (const child of parent.children) {
|
|
||||||
const idx = game.ruleContexts.indexOf(child);
|
|
||||||
if (idx !== -1) game.ruleContexts.splice(idx, 1);
|
|
||||||
|
|
||||||
const ctxIdx = game.contexts.value.findIndex((c: any) => c.value === child);
|
|
||||||
if (ctxIdx !== -1) {
|
|
||||||
const arr = [...game.contexts.value];
|
|
||||||
arr.splice(ctxIdx, 1);
|
|
||||||
game.contexts.value = arr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parent.children = [];
|
|
||||||
parent.state = 'yielded';
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateYieldedSchema(command: Command, schema: CommandSchema): boolean {
|
|
||||||
const requiredParams = schema.params.filter(p => p.required);
|
|
||||||
const variadicParam = schema.params.find(p => p.variadic);
|
|
||||||
|
|
||||||
if (command.params.length < requiredParams.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!variadicParam && command.params.length > schema.params.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiredOptions = schema.options.filter(o => o.required);
|
|
||||||
for (const opt of requiredOptions) {
|
|
||||||
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
|
||||||
if (!hasOption) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function invokeRule<T>(
|
|
||||||
game: GameContextLike,
|
|
||||||
command: Command,
|
|
||||||
ruleDef: RuleDef<T>,
|
|
||||||
parent?: RuleContext<unknown>
|
|
||||||
): RuleContext<T> {
|
): RuleContext<T> {
|
||||||
const ctx: RuleContext<T> = {
|
const ctx: RuleContext<T> = {
|
||||||
type: ruleDef.schema.name,
|
type,
|
||||||
schema: undefined,
|
actions: [],
|
||||||
generator: ruleDef.create(command),
|
handledActions: 0,
|
||||||
parent,
|
invocations: [],
|
||||||
children: [],
|
|
||||||
state: 'running',
|
|
||||||
resolution: undefined,
|
resolution: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parent) {
|
// 执行生成器直到完成或需要等待动作
|
||||||
discardChildren(game, parent);
|
const executeRule = () => {
|
||||||
parent.children.push(ctx as RuleContext<unknown>);
|
try {
|
||||||
parent.state = 'waiting';
|
const result = rule.next();
|
||||||
}
|
|
||||||
|
|
||||||
pushContextToGame(game, ctx as RuleContext<unknown>);
|
|
||||||
|
|
||||||
const result = ctx.generator.next();
|
|
||||||
if (result.done) {
|
if (result.done) {
|
||||||
|
// 规则执行完成,设置结果
|
||||||
ctx.resolution = result.value;
|
ctx.resolution = result.value;
|
||||||
ctx.state = 'done';
|
return;
|
||||||
} else {
|
|
||||||
ctx.schema = parseYieldedSchema(result.value);
|
|
||||||
ctx.state = 'yielded';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果生成器 yield 了一个动作类型,等待处理
|
||||||
|
// 这里可以扩展为实际的动作处理逻辑
|
||||||
|
const actionType = result.value;
|
||||||
|
|
||||||
|
// 继续执行直到有动作需要处理或规则完成
|
||||||
|
if (!result.done) {
|
||||||
|
executeRule();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 规则执行出错,抛出错误
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 effect 来跟踪响应式依赖
|
||||||
|
const dispose = effect(() => {
|
||||||
|
if (ctx.resolution !== undefined) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeRule();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 将规则上下文推入栈中
|
||||||
|
pushContext(ctx);
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatchCommand(game: GameContextLike, input: string): RuleContext<unknown> | undefined {
|
/**
|
||||||
const command = parseCommand(input);
|
* 创建一个规则生成器辅助函数
|
||||||
|
* @param type - 规则类型
|
||||||
if (game.rules.has(command.name)) {
|
* @param fn - 规则逻辑函数
|
||||||
const ruleDef = game.rules.get(command.name)!;
|
*/
|
||||||
|
export function createRule<T>(
|
||||||
const parent = findYieldedParent(game);
|
type: string,
|
||||||
|
fn: (ctx: RuleContext<T>) => Generator<string, T, Command>
|
||||||
return invokeRule(game, command, ruleDef, parent);
|
): Generator<string, T, Command> {
|
||||||
}
|
return fn({
|
||||||
|
type,
|
||||||
for (let i = game.ruleContexts.length - 1; i >= 0; i--) {
|
actions: [],
|
||||||
const ctx = game.ruleContexts[i];
|
handledActions: 0,
|
||||||
if (ctx.state === 'yielded' && ctx.schema) {
|
invocations: [],
|
||||||
if (validateYieldedSchema(command, ctx.schema)) {
|
resolution: undefined,
|
||||||
const result = ctx.generator.next(command);
|
});
|
||||||
if (result.done) {
|
|
||||||
ctx.resolution = result.value;
|
|
||||||
ctx.state = 'done';
|
|
||||||
} else {
|
|
||||||
ctx.schema = parseYieldedSchema(result.value);
|
|
||||||
ctx.state = 'yielded';
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findYieldedParent(game: GameContextLike): RuleContext<unknown> | undefined {
|
|
||||||
for (let i = game.ruleContexts.length - 1; i >= 0; i--) {
|
|
||||||
const ctx = game.ruleContexts[i];
|
|
||||||
if (ctx.state === 'yielded') {
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameContextLike = {
|
|
||||||
rules: RuleRegistry;
|
|
||||||
ruleContexts: RuleContext<unknown>[];
|
|
||||||
contexts: { value: any[] };
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ export { flip, flipTo, roll } from './core/part';
|
||||||
export type { Region, RegionAxis } from './core/region';
|
export type { Region, RegionAxis } from './core/region';
|
||||||
export { applyAlign, shuffle } from './core/region';
|
export { applyAlign, shuffle } from './core/region';
|
||||||
|
|
||||||
export type { RuleContext, RuleState, RuleDef, RuleRegistry } from './core/rule';
|
export type { RuleContext } from './core/rule';
|
||||||
export { createRule, dispatchCommand } from './core/rule';
|
export { invokeRuleContext, createRule } from './core/rule';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,690 @@
|
||||||
export { parseCommand } from './command/command-parse.js';
|
import { defineSchema, type ParsedSchema, ParseError } from 'inline-schema';
|
||||||
export { parseCommandSchema } from './command/schema-parse.js';
|
|
||||||
export { validateCommand, parseCommandWithSchema } from './command/command-validate.js';
|
export type Command = {
|
||||||
export type {
|
name: string;
|
||||||
Command,
|
flags: Record<string, true>;
|
||||||
CommandParamSchema,
|
options: Record<string, unknown>;
|
||||||
CommandOptionSchema,
|
params: unknown[];
|
||||||
CommandFlagSchema,
|
}
|
||||||
CommandSchema,
|
|
||||||
} from './command/types.js';
|
/**
|
||||||
|
* 命令参数 schema 定义
|
||||||
|
*/
|
||||||
|
export type CommandParamSchema = {
|
||||||
|
/** 参数名称 */
|
||||||
|
name: string;
|
||||||
|
/** 是否必需 */
|
||||||
|
required: boolean;
|
||||||
|
/** 是否可变参数(可以接收多个值) */
|
||||||
|
variadic: boolean;
|
||||||
|
/** 参数类型 schema(用于解析和验证) */
|
||||||
|
schema?: ParsedSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令选项 schema 定义
|
||||||
|
*/
|
||||||
|
export type CommandOptionSchema = {
|
||||||
|
/** 选项名称(长格式,不含 --) */
|
||||||
|
name: string;
|
||||||
|
/** 短格式名称(不含 -) */
|
||||||
|
short?: string;
|
||||||
|
/** 是否必需 */
|
||||||
|
required: boolean;
|
||||||
|
/** 默认值 */
|
||||||
|
defaultValue?: unknown;
|
||||||
|
/** 选项类型 schema(用于解析和验证) */
|
||||||
|
schema?: ParsedSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令标志 schema 定义
|
||||||
|
*/
|
||||||
|
export type CommandFlagSchema = {
|
||||||
|
/** 标志名称(长格式,不含 --) */
|
||||||
|
name: string;
|
||||||
|
/** 短格式名称(不含 -) */
|
||||||
|
short?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令完整 schema 定义
|
||||||
|
*/
|
||||||
|
export type CommandSchema = {
|
||||||
|
/** 命令名称 */
|
||||||
|
name: string;
|
||||||
|
/** 参数定义列表 */
|
||||||
|
params: CommandParamSchema[];
|
||||||
|
/** 选项定义列表 */
|
||||||
|
options: CommandOptionSchema[];
|
||||||
|
/** 标志定义列表 */
|
||||||
|
flags: CommandFlagSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析命令行输入字符串为 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" } }
|
||||||
|
* parseCommand('place tile "large castle" --x 5')
|
||||||
|
* // returns { name: "place", params: ["tile", "large castle"], flags: {}, options: { x: "5" } }
|
||||||
|
*/
|
||||||
|
export function parseCommand(input: string): Command {
|
||||||
|
const tokens = tokenize(input);
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return { name: '', flags: {}, options: {}, params: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = tokens[0];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const flags: Record<string, true> = {};
|
||||||
|
const options: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将输入字符串分解为 tokens,支持引号和转义
|
||||||
|
*/
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuote: string | null = null;
|
||||||
|
let inBracket = false;
|
||||||
|
let bracketDepth = 0;
|
||||||
|
let escaped = false;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < input.length) {
|
||||||
|
const char = input[i];
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
current += char;
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (inQuote) {
|
||||||
|
if (char === inQuote) {
|
||||||
|
inQuote = null;
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
} else if (char === '"' || char === "'") {
|
||||||
|
inQuote = char;
|
||||||
|
} else if (char === '[') {
|
||||||
|
if (inBracket) {
|
||||||
|
bracketDepth++;
|
||||||
|
current += char;
|
||||||
|
} else {
|
||||||
|
if (current.length > 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
inBracket = true;
|
||||||
|
bracketDepth = 1;
|
||||||
|
current = '[';
|
||||||
|
}
|
||||||
|
} else if (char === ']') {
|
||||||
|
if (inBracket) {
|
||||||
|
bracketDepth--;
|
||||||
|
current += char;
|
||||||
|
if (bracketDepth === 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
inBracket = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
} else if (/\s/.test(char)) {
|
||||||
|
if (inBracket) {
|
||||||
|
current += char;
|
||||||
|
} else if (current.length > 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析命令 schema 字符串为 CommandSchema 对象
|
||||||
|
* 支持语法:
|
||||||
|
* - <param> 必需参数
|
||||||
|
* - [param] 可选参数
|
||||||
|
* - <param...> 必需可变参数
|
||||||
|
* - [param...] 可选可变参数
|
||||||
|
* - <param: type> 带类型定义的必需参数
|
||||||
|
* - [param: type] 带类型定义的可选参数
|
||||||
|
* - --flag 长格式标志(布尔类型)
|
||||||
|
* - --flag: boolean 长格式标志(布尔类型,与上面等价)
|
||||||
|
* - -f 短格式标志
|
||||||
|
* - --option: type 带类型的长格式选项
|
||||||
|
* - --option: type = default 带默认值的选项
|
||||||
|
* - --option: type -o 带短别名的选项
|
||||||
|
* - --option: type -o = default 带短别名和默认值的选项
|
||||||
|
* - -o: type 带类型的短格式选项
|
||||||
|
*
|
||||||
|
* 类型语法使用 inline-schema 格式(使用 ; 而非 ,):
|
||||||
|
* - string, number, boolean
|
||||||
|
* - [string; number] 元组
|
||||||
|
* - string[] 数组
|
||||||
|
* - [string; number][] 元组数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseCommandSchema('move <from> [to...] [--force] [-f] [--speed: number]')
|
||||||
|
* parseCommandSchema('move <from: [x: string; y: string]> <to: string> [--all]')
|
||||||
|
* parseCommandSchema('move <from> <to> [--speed: number = 10 -s]')
|
||||||
|
*/
|
||||||
|
export function parseCommandSchema(schemaStr: string): CommandSchema {
|
||||||
|
const schema: CommandSchema = {
|
||||||
|
name: '',
|
||||||
|
params: [],
|
||||||
|
options: [],
|
||||||
|
flags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = tokenizeSchema(schemaStr);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
schema.name = tokens[0];
|
||||||
|
|
||||||
|
let i = 1;
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const token = tokens[i];
|
||||||
|
|
||||||
|
if (token.startsWith('[') && token.endsWith(']')) {
|
||||||
|
const inner = token.slice(1, -1).trim();
|
||||||
|
|
||||||
|
if (inner.startsWith('--')) {
|
||||||
|
const result = parseOptionToken(inner.slice(2), false);
|
||||||
|
if (result.isFlag) {
|
||||||
|
schema.flags.push({ name: result.name, short: result.short });
|
||||||
|
} else {
|
||||||
|
schema.options.push({
|
||||||
|
name: result.name,
|
||||||
|
short: result.short,
|
||||||
|
required: false,
|
||||||
|
defaultValue: result.defaultValue,
|
||||||
|
schema: result.schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (inner.startsWith('-') && inner.length > 1 && !inner.includes('--')) {
|
||||||
|
const result = parseOptionToken(inner.slice(1), false);
|
||||||
|
if (result.isFlag) {
|
||||||
|
schema.flags.push({ name: result.name, short: result.short || result.name });
|
||||||
|
} else {
|
||||||
|
schema.options.push({
|
||||||
|
name: result.name,
|
||||||
|
short: result.short || result.name,
|
||||||
|
required: false,
|
||||||
|
defaultValue: result.defaultValue,
|
||||||
|
schema: result.schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isVariadic = inner.endsWith('...');
|
||||||
|
let paramContent = isVariadic ? inner.slice(0, -3) : inner;
|
||||||
|
let parsedSchema: ParsedSchema | undefined;
|
||||||
|
|
||||||
|
if (paramContent.includes(':')) {
|
||||||
|
const [name, typeStr] = paramContent.split(':').map(s => s.trim());
|
||||||
|
try {
|
||||||
|
parsedSchema = defineSchema(typeStr);
|
||||||
|
} catch {
|
||||||
|
// 不是有效的 schema
|
||||||
|
}
|
||||||
|
paramContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
schema.params.push({
|
||||||
|
name: paramContent,
|
||||||
|
required: false,
|
||||||
|
variadic: isVariadic,
|
||||||
|
schema: parsedSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
} else if (token.startsWith('--')) {
|
||||||
|
const result = parseOptionToken(token.slice(2), true);
|
||||||
|
if (result.isFlag) {
|
||||||
|
schema.flags.push({ name: result.name, short: result.short });
|
||||||
|
} else {
|
||||||
|
schema.options.push({
|
||||||
|
name: result.name,
|
||||||
|
short: result.short,
|
||||||
|
required: true,
|
||||||
|
defaultValue: result.defaultValue,
|
||||||
|
schema: result.schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
|
||||||
|
const result = parseOptionToken(token.slice(1), true);
|
||||||
|
if (result.isFlag) {
|
||||||
|
schema.flags.push({ name: result.name, short: result.short || result.name });
|
||||||
|
} else {
|
||||||
|
schema.options.push({
|
||||||
|
name: result.name,
|
||||||
|
short: result.short || result.name,
|
||||||
|
required: true,
|
||||||
|
defaultValue: result.defaultValue,
|
||||||
|
schema: result.schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
} else if (token.startsWith('<') && token.endsWith('>')) {
|
||||||
|
const isVariadic = token.endsWith('...>');
|
||||||
|
let paramContent = token.replace(/^[<]+|[>.>]+$/g, '');
|
||||||
|
let parsedSchema: ParsedSchema | undefined;
|
||||||
|
|
||||||
|
if (paramContent.includes(':')) {
|
||||||
|
const colonIndex = paramContent.indexOf(':');
|
||||||
|
const name = paramContent.slice(0, colonIndex).trim();
|
||||||
|
const typeStr = paramContent.slice(colonIndex + 1).trim();
|
||||||
|
try {
|
||||||
|
parsedSchema = defineSchema(typeStr);
|
||||||
|
} catch (e) {
|
||||||
|
// 不是有效的 schema
|
||||||
|
}
|
||||||
|
paramContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
schema.params.push({
|
||||||
|
name: paramContent,
|
||||||
|
required: true,
|
||||||
|
variadic: isVariadic,
|
||||||
|
schema: parsedSchema,
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析选项/标志 token 的结果
|
||||||
|
*/
|
||||||
|
interface ParsedOptionResult {
|
||||||
|
name: string;
|
||||||
|
short?: string;
|
||||||
|
isFlag: boolean;
|
||||||
|
schema?: ParsedSchema;
|
||||||
|
defaultValue?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析单个选项/标志 token
|
||||||
|
* 支持格式:
|
||||||
|
* - flag → 标志
|
||||||
|
* - flag: boolean → 标志(统一处理)
|
||||||
|
* - option: type → 选项
|
||||||
|
* - option: type -s → 选项带短别名
|
||||||
|
* - option: type = default → 选项带默认值
|
||||||
|
* - option: type -s = default → 选项带短别名和默认值
|
||||||
|
*/
|
||||||
|
function parseOptionToken(token: string, required: boolean): ParsedOptionResult {
|
||||||
|
const parts = token.split(/\s+/);
|
||||||
|
const mainPart = parts[0];
|
||||||
|
|
||||||
|
let name: string;
|
||||||
|
let typeStr: string | undefined;
|
||||||
|
let isFlag = false;
|
||||||
|
|
||||||
|
if (mainPart.endsWith(':')) {
|
||||||
|
name = mainPart.slice(0, -1).trim();
|
||||||
|
typeStr = parts[1] || 'string';
|
||||||
|
} else if (mainPart.includes(':')) {
|
||||||
|
const [optName, optType] = mainPart.split(':').map(s => s.trim());
|
||||||
|
name = optName;
|
||||||
|
typeStr = optType;
|
||||||
|
} else {
|
||||||
|
name = mainPart;
|
||||||
|
isFlag = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeStr === 'boolean') {
|
||||||
|
isFlag = true;
|
||||||
|
typeStr = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let short: string | undefined;
|
||||||
|
let defaultValue: unknown;
|
||||||
|
let schema: ParsedSchema | undefined;
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
|
||||||
|
if (part.startsWith('-') && part.length === 2) {
|
||||||
|
short = part.slice(1);
|
||||||
|
} else if (part === '=') {
|
||||||
|
const valuePart = parts[i + 1];
|
||||||
|
if (valuePart) {
|
||||||
|
try {
|
||||||
|
defaultValue = JSON.parse(valuePart);
|
||||||
|
} catch {
|
||||||
|
defaultValue = valuePart;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else if (part.startsWith('=')) {
|
||||||
|
const valuePart = part.slice(1);
|
||||||
|
try {
|
||||||
|
defaultValue = JSON.parse(valuePart);
|
||||||
|
} catch {
|
||||||
|
defaultValue = valuePart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeStr && !isFlag) {
|
||||||
|
try {
|
||||||
|
schema = defineSchema(typeStr);
|
||||||
|
} catch {
|
||||||
|
// 不是有效的 schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, short, isFlag, schema, defaultValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 token 是否是值占位符(如 <value> 或 [value])
|
||||||
|
*/
|
||||||
|
function isValuePlaceholder(token: string): boolean {
|
||||||
|
return (token.startsWith('<') && token.endsWith('>')) ||
|
||||||
|
(token.startsWith('[') && token.endsWith(']'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 token 是否是参数占位符
|
||||||
|
*/
|
||||||
|
function isParamPlaceholder(token: string): boolean {
|
||||||
|
// 参数占位符必须以 < 或 [ 开头
|
||||||
|
if (!token.startsWith('<') && !token.startsWith('[')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查是否是选项的值占位符(如 <--opt <val> 中的 <val>)
|
||||||
|
// 这种情况应该由选项处理逻辑处理,不作为独立参数
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 schema 字符串分解为 tokens
|
||||||
|
* 支持方括号分组:[...args] [--flag] 等
|
||||||
|
* 支持尖括号分组:<param> <param: type> 等
|
||||||
|
*/
|
||||||
|
function tokenizeSchema(input: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inBracket = false;
|
||||||
|
let bracketContent = '';
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < input.length) {
|
||||||
|
const char = input[i];
|
||||||
|
|
||||||
|
if (inBracket) {
|
||||||
|
if (char === ']') {
|
||||||
|
tokens.push(`[${bracketContent}]`);
|
||||||
|
inBracket = false;
|
||||||
|
bracketContent = '';
|
||||||
|
current = '';
|
||||||
|
} else if (char === '[') {
|
||||||
|
bracketContent += char;
|
||||||
|
} else {
|
||||||
|
bracketContent += char;
|
||||||
|
}
|
||||||
|
} else if (/\s/.test(char)) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
} else if (char === '[') {
|
||||||
|
if (current.length > 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
inBracket = true;
|
||||||
|
bracketContent = '';
|
||||||
|
} else if (char === '<') {
|
||||||
|
let angleContent = '<';
|
||||||
|
i++;
|
||||||
|
while (i < input.length && input[i] !== '>') {
|
||||||
|
angleContent += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
angleContent += '>';
|
||||||
|
tokens.push(angleContent);
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bracketContent.length > 0) {
|
||||||
|
tokens.push(`[${bracketContent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 schema 验证命令
|
||||||
|
* @returns 验证结果,valid 为 true 表示通过,否则包含错误信息
|
||||||
|
*/
|
||||||
|
export function validateCommand(
|
||||||
|
command: Command,
|
||||||
|
schema: CommandSchema
|
||||||
|
): { valid: true } | { valid: false; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 验证命令名称
|
||||||
|
if (command.name !== schema.name) {
|
||||||
|
errors.push(`命令名称不匹配:期望 "${schema.name}",实际 "${command.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证参数数量
|
||||||
|
const requiredParams = schema.params.filter(p => p.required);
|
||||||
|
const variadicParam = schema.params.find(p => p.variadic);
|
||||||
|
|
||||||
|
if (command.params.length < requiredParams.length) {
|
||||||
|
errors.push(`参数不足:至少需要 ${requiredParams.length} 个参数,实际 ${command.params.length} 个`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有可变参数,参数数量可以超过必需参数数量
|
||||||
|
// 否则,检查是否有多余参数
|
||||||
|
if (!variadicParam && command.params.length > schema.params.length) {
|
||||||
|
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length} 个`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必需的选项
|
||||||
|
const requiredOptions = schema.options.filter(o => o.required);
|
||||||
|
for (const opt of requiredOptions) {
|
||||||
|
// 检查长格式或短格式
|
||||||
|
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
||||||
|
if (!hasOption) {
|
||||||
|
errors.push(`缺少必需选项:--${opt.name}${opt.short ? ` 或 -${opt.short}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证标志(标志都是可选的,除非未来扩展支持必需标志)
|
||||||
|
// 目前只检查是否有未定义的标志(可选的严格模式)
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 schema 解析并验证命令,返回类型化的命令对象
|
||||||
|
* 如果 schema 中定义了类型,会自动解析参数和选项的值
|
||||||
|
*
|
||||||
|
* @param input 命令行输入字符串
|
||||||
|
* @param schemaStr 命令 schema 字符串
|
||||||
|
* @returns 解析后的命令对象和验证结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = parseCommandWithSchema(
|
||||||
|
* 'move [1; 2] region1 --all true',
|
||||||
|
* 'move <from: [x: string; y: string]> <to: string> [--all: boolean]'
|
||||||
|
* );
|
||||||
|
* // result.command.params[0] = ['1', '2'] (已解析为元组)
|
||||||
|
* // result.command.options.all = true (已解析为布尔值)
|
||||||
|
*/
|
||||||
|
export function parseCommandWithSchema(
|
||||||
|
input: string,
|
||||||
|
schemaStr: string
|
||||||
|
): { command: Command; valid: true } | { command: Command; valid: false; errors: string[] } {
|
||||||
|
const schema = parseCommandSchema(schemaStr);
|
||||||
|
const command = parseCommand(input);
|
||||||
|
|
||||||
|
// 验证命令名称
|
||||||
|
if (command.name !== schema.name) {
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
valid: false,
|
||||||
|
errors: [`命令名称不匹配:期望 "${schema.name}",实际 "${command.name}"`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 验证参数数量
|
||||||
|
const requiredParams = schema.params.filter(p => p.required);
|
||||||
|
const variadicParam = schema.params.find(p => p.variadic);
|
||||||
|
|
||||||
|
if (command.params.length < requiredParams.length) {
|
||||||
|
errors.push(`参数不足:至少需要 ${requiredParams.length} 个参数,实际 ${command.params.length} 个`);
|
||||||
|
return { command, valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variadicParam && command.params.length > schema.params.length) {
|
||||||
|
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length} 个`);
|
||||||
|
return { command, valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必需的选项
|
||||||
|
const requiredOptions = schema.options.filter(o => o.required);
|
||||||
|
for (const opt of requiredOptions) {
|
||||||
|
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
||||||
|
if (!hasOption) {
|
||||||
|
errors.push(`缺少必需选项:--${opt.name}${opt.short ? ` 或 -${opt.short}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { command, valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 schema 解析参数值
|
||||||
|
const parsedParams: unknown[] = [];
|
||||||
|
for (let i = 0; i < command.params.length; i++) {
|
||||||
|
const paramValue = command.params[i];
|
||||||
|
const paramSchema = schema.params[i]?.schema;
|
||||||
|
|
||||||
|
if (paramSchema) {
|
||||||
|
try {
|
||||||
|
// 如果是字符串值,使用 schema 解析
|
||||||
|
const parsed = typeof paramValue === 'string'
|
||||||
|
? paramSchema.parse(paramValue)
|
||||||
|
: paramValue;
|
||||||
|
parsedParams.push(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ParseError;
|
||||||
|
errors.push(`参数 "${schema.params[i]?.name}" 解析失败:${err.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedParams.push(paramValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 schema 解析选项值
|
||||||
|
const parsedOptions: Record<string, unknown> = { ...command.options };
|
||||||
|
for (const [key, value] of Object.entries(command.options)) {
|
||||||
|
const optSchema = schema.options.find(o => o.name === key || o.short === key);
|
||||||
|
if (optSchema?.schema && typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
parsedOptions[key] = optSchema.schema.parse(value);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ParseError;
|
||||||
|
errors.push(`选项 "--${key}" 解析失败:${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { command: { ...command, params: parsedParams, options: parsedOptions }, valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: { ...command, params: parsedParams, options: parsedOptions },
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import type { Command } from './types.js';
|
|
||||||
|
|
||||||
export function parseCommand(input: string): Command {
|
|
||||||
const tokens = tokenize(input);
|
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
return { name: '', flags: {}, options: {}, params: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = tokens[0];
|
|
||||||
const params: unknown[] = [];
|
|
||||||
const flags: Record<string, true> = {};
|
|
||||||
const options: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
let i = 1;
|
|
||||||
while (i < tokens.length) {
|
|
||||||
const token = tokens[i];
|
|
||||||
|
|
||||||
if (token.startsWith('--') && !/^-?\d+$/.test(token)) {
|
|
||||||
const key = token.slice(2);
|
|
||||||
const nextToken = tokens[i + 1];
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
const key = token.slice(1);
|
|
||||||
const nextToken = tokens[i + 1];
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(input: string): string[] {
|
|
||||||
const tokens: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuote: string | null = null;
|
|
||||||
let inBracket = false;
|
|
||||||
let bracketDepth = 0;
|
|
||||||
let escaped = false;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < input.length) {
|
|
||||||
const char = input[i];
|
|
||||||
|
|
||||||
if (escaped) {
|
|
||||||
current += char;
|
|
||||||
escaped = false;
|
|
||||||
} else if (char === '\\') {
|
|
||||||
escaped = true;
|
|
||||||
} else if (inQuote) {
|
|
||||||
if (char === inQuote) {
|
|
||||||
inQuote = null;
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
} else if (char === '"' || char === "'") {
|
|
||||||
inQuote = char;
|
|
||||||
} else if (char === '[') {
|
|
||||||
if (inBracket) {
|
|
||||||
bracketDepth++;
|
|
||||||
current += char;
|
|
||||||
} else {
|
|
||||||
if (current.length > 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
inBracket = true;
|
|
||||||
bracketDepth = 1;
|
|
||||||
current = '[';
|
|
||||||
}
|
|
||||||
} else if (char === ']') {
|
|
||||||
if (inBracket) {
|
|
||||||
bracketDepth--;
|
|
||||||
current += char;
|
|
||||||
if (bracketDepth === 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
current = '';
|
|
||||||
inBracket = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
} else if (/\s/.test(char)) {
|
|
||||||
if (inBracket) {
|
|
||||||
current += char;
|
|
||||||
} else if (current.length > 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.length > 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { type ParseError } from 'inline-schema';
|
|
||||||
import type { Command, CommandSchema } from './types.js';
|
|
||||||
import { parseCommand } from './command-parse.js';
|
|
||||||
import { parseCommandSchema } from './schema-parse.js';
|
|
||||||
|
|
||||||
export function validateCommand(
|
|
||||||
command: Command,
|
|
||||||
schema: CommandSchema
|
|
||||||
): { valid: true } | { valid: false; errors: string[] } {
|
|
||||||
const errors = validateCommandCore(command, schema);
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
return { valid: false, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCommandCore(command: Command, schema: CommandSchema): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (command.name !== schema.name) {
|
|
||||||
errors.push(`命令名称不匹配:期望 "${schema.name}",实际 "${command.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiredParams = schema.params.filter(p => p.required);
|
|
||||||
const variadicParam = schema.params.find(p => p.variadic);
|
|
||||||
|
|
||||||
if (command.params.length < requiredParams.length) {
|
|
||||||
errors.push(`参数不足:至少需要 ${requiredParams.length} 个参数,实际 ${command.params.length} 个`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!variadicParam && command.params.length > schema.params.length) {
|
|
||||||
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length} 个`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiredOptions = schema.options.filter(o => o.required);
|
|
||||||
for (const opt of requiredOptions) {
|
|
||||||
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
|
||||||
if (!hasOption) {
|
|
||||||
errors.push(`缺少必需选项:--${opt.name}${opt.short ? ` 或 -${opt.short}` : ''}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCommandWithSchema(
|
|
||||||
input: string,
|
|
||||||
schemaStr: string
|
|
||||||
): { command: Command; valid: true } | { command: Command; valid: false; errors: string[] } {
|
|
||||||
const schema = parseCommandSchema(schemaStr);
|
|
||||||
const command = parseCommand(input);
|
|
||||||
|
|
||||||
const errors = validateCommandCore(command, schema);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
return { command, valid: false, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseErrors: string[] = [];
|
|
||||||
|
|
||||||
const parsedParams: unknown[] = [];
|
|
||||||
for (let i = 0; i < command.params.length; i++) {
|
|
||||||
const paramValue = command.params[i];
|
|
||||||
const paramSchema = schema.params[i]?.schema;
|
|
||||||
|
|
||||||
if (paramSchema) {
|
|
||||||
try {
|
|
||||||
const parsed = typeof paramValue === 'string'
|
|
||||||
? paramSchema.parse(paramValue)
|
|
||||||
: paramValue;
|
|
||||||
parsedParams.push(parsed);
|
|
||||||
} catch (e) {
|
|
||||||
const err = e as ParseError;
|
|
||||||
parseErrors.push(`参数 "${schema.params[i]?.name}" 解析失败:${err.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsedParams.push(paramValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedOptions: Record<string, unknown> = { ...command.options };
|
|
||||||
for (const [key, value] of Object.entries(command.options)) {
|
|
||||||
const optSchema = schema.options.find(o => o.name === key || o.short === key);
|
|
||||||
if (optSchema?.schema && typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
parsedOptions[key] = optSchema.schema.parse(value);
|
|
||||||
} catch (e) {
|
|
||||||
const err = e as ParseError;
|
|
||||||
parseErrors.push(`选项 "--${key}" 解析失败:${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parseErrors.length > 0) {
|
|
||||||
return { command: { ...command, params: parsedParams, options: parsedOptions }, valid: false, errors: parseErrors };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: { ...command, params: parsedParams, options: parsedOptions },
|
|
||||||
valid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
import { defineSchema, type ParsedSchema } from 'inline-schema';
|
|
||||||
import type { CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema, ParsedOptionResult } from './types.js';
|
|
||||||
|
|
||||||
export function parseCommandSchema(schemaStr: string, name?: string): CommandSchema {
|
|
||||||
const schema: CommandSchema = {
|
|
||||||
name: name ?? '',
|
|
||||||
params: [],
|
|
||||||
options: [],
|
|
||||||
flags: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const tokens = tokenizeSchema(schemaStr);
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startIdx = name !== undefined ? 0 : 1;
|
|
||||||
schema.name = name ?? tokens[0];
|
|
||||||
|
|
||||||
let i = startIdx;
|
|
||||||
while (i < tokens.length) {
|
|
||||||
const token = tokens[i];
|
|
||||||
|
|
||||||
if (token.startsWith('[') && token.endsWith(']')) {
|
|
||||||
const inner = token.slice(1, -1).trim();
|
|
||||||
|
|
||||||
if (inner.startsWith('--')) {
|
|
||||||
const result = parseOptionToken(inner.slice(2), false);
|
|
||||||
if (result.isFlag) {
|
|
||||||
schema.flags.push({ name: result.name, short: result.short });
|
|
||||||
} else {
|
|
||||||
schema.options.push({
|
|
||||||
name: result.name,
|
|
||||||
short: result.short,
|
|
||||||
required: false,
|
|
||||||
defaultValue: result.defaultValue,
|
|
||||||
schema: result.schema,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (inner.startsWith('-') && inner.length > 1 && !inner.includes('--')) {
|
|
||||||
const result = parseOptionToken(inner.slice(1), false);
|
|
||||||
if (result.isFlag) {
|
|
||||||
schema.flags.push({ name: result.name, short: result.short || result.name });
|
|
||||||
} else {
|
|
||||||
schema.options.push({
|
|
||||||
name: result.name,
|
|
||||||
short: result.short || result.name,
|
|
||||||
required: false,
|
|
||||||
defaultValue: result.defaultValue,
|
|
||||||
schema: result.schema,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const isVariadic = inner.endsWith('...');
|
|
||||||
let paramContent = isVariadic ? inner.slice(0, -3) : inner;
|
|
||||||
let parsedSchema: ParsedSchema | undefined;
|
|
||||||
|
|
||||||
if (paramContent.includes(':')) {
|
|
||||||
const colonIdx = paramContent.indexOf(':');
|
|
||||||
const name = paramContent.slice(0, colonIdx).trim();
|
|
||||||
const typeStr = paramContent.slice(colonIdx + 1).trim();
|
|
||||||
try {
|
|
||||||
parsedSchema = defineSchema(typeStr);
|
|
||||||
} catch (e) {
|
|
||||||
// 不是有效的 schema
|
|
||||||
}
|
|
||||||
paramContent = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
schema.params.push({
|
|
||||||
name: paramContent,
|
|
||||||
required: false,
|
|
||||||
variadic: isVariadic,
|
|
||||||
schema: parsedSchema,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
} else if (token.startsWith('--')) {
|
|
||||||
const result = parseOptionToken(token.slice(2), true);
|
|
||||||
if (result.isFlag) {
|
|
||||||
schema.flags.push({ name: result.name, short: result.short });
|
|
||||||
} else {
|
|
||||||
schema.options.push({
|
|
||||||
name: result.name,
|
|
||||||
short: result.short,
|
|
||||||
required: true,
|
|
||||||
defaultValue: result.defaultValue,
|
|
||||||
schema: result.schema,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
|
|
||||||
const result = parseOptionToken(token.slice(1), true);
|
|
||||||
if (result.isFlag) {
|
|
||||||
schema.flags.push({ name: result.name, short: result.short || result.name });
|
|
||||||
} else {
|
|
||||||
schema.options.push({
|
|
||||||
name: result.name,
|
|
||||||
short: result.short || result.name,
|
|
||||||
required: true,
|
|
||||||
defaultValue: result.defaultValue,
|
|
||||||
schema: result.schema,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
} else if (token.startsWith('<') && token.endsWith('>')) {
|
|
||||||
const isVariadic = token.endsWith('...>');
|
|
||||||
let paramContent = token.replace(/^<+|>+$/g, '');
|
|
||||||
if (isVariadic) {
|
|
||||||
paramContent = paramContent.replace(/\.\.\.$/, '');
|
|
||||||
}
|
|
||||||
let parsedSchema: ParsedSchema | undefined;
|
|
||||||
|
|
||||||
if (paramContent.includes(':')) {
|
|
||||||
const colonIdx = paramContent.indexOf(':');
|
|
||||||
const name = paramContent.slice(0, colonIdx).trim();
|
|
||||||
const typeStr = paramContent.slice(colonIdx + 1).trim();
|
|
||||||
try {
|
|
||||||
parsedSchema = defineSchema(typeStr);
|
|
||||||
} catch (e) {
|
|
||||||
// 不是有效的 schema
|
|
||||||
}
|
|
||||||
paramContent = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
schema.params.push({
|
|
||||||
name: paramContent,
|
|
||||||
required: true,
|
|
||||||
variadic: isVariadic,
|
|
||||||
schema: parsedSchema,
|
|
||||||
});
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionToken(token: string, required: boolean): ParsedOptionResult {
|
|
||||||
const parts = token.split(/\s+/);
|
|
||||||
const mainPart = parts[0];
|
|
||||||
|
|
||||||
let name: string;
|
|
||||||
let typeStr: string | undefined;
|
|
||||||
let isFlag = false;
|
|
||||||
|
|
||||||
if (mainPart.endsWith(':')) {
|
|
||||||
name = mainPart.slice(0, -1).trim();
|
|
||||||
typeStr = parts[1] || 'string';
|
|
||||||
} else if (mainPart.includes(':')) {
|
|
||||||
const [optName, optType] = mainPart.split(':').map(s => s.trim());
|
|
||||||
name = optName;
|
|
||||||
typeStr = optType;
|
|
||||||
} else {
|
|
||||||
name = mainPart;
|
|
||||||
isFlag = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeStr === 'boolean') {
|
|
||||||
isFlag = true;
|
|
||||||
typeStr = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let short: string | undefined;
|
|
||||||
let defaultValue: unknown;
|
|
||||||
let schema: ParsedSchema | undefined;
|
|
||||||
|
|
||||||
for (let i = 1; i < parts.length; i++) {
|
|
||||||
const part = parts[i];
|
|
||||||
|
|
||||||
if (part.startsWith('-') && part.length === 2) {
|
|
||||||
short = part.slice(1);
|
|
||||||
} else if (part === '=') {
|
|
||||||
const valuePart = parts[i + 1];
|
|
||||||
if (valuePart) {
|
|
||||||
try {
|
|
||||||
defaultValue = JSON.parse(valuePart);
|
|
||||||
} catch {
|
|
||||||
defaultValue = valuePart;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else if (part.startsWith('=')) {
|
|
||||||
const valuePart = part.slice(1);
|
|
||||||
try {
|
|
||||||
defaultValue = JSON.parse(valuePart);
|
|
||||||
} catch {
|
|
||||||
defaultValue = valuePart;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeStr && !isFlag) {
|
|
||||||
try {
|
|
||||||
schema = defineSchema(typeStr);
|
|
||||||
} catch {
|
|
||||||
// 不是有效的 schema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name, short, isFlag, schema, defaultValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeSchema(input: string): string[] {
|
|
||||||
const tokens: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inBracket = false;
|
|
||||||
let bracketContent = '';
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < input.length) {
|
|
||||||
const char = input[i];
|
|
||||||
|
|
||||||
if (inBracket) {
|
|
||||||
if (char === ']') {
|
|
||||||
tokens.push(`[${bracketContent}]`);
|
|
||||||
inBracket = false;
|
|
||||||
bracketContent = '';
|
|
||||||
current = '';
|
|
||||||
} else if (char === '[') {
|
|
||||||
bracketContent += char;
|
|
||||||
} else {
|
|
||||||
bracketContent += char;
|
|
||||||
}
|
|
||||||
} else if (/\s/.test(char)) {
|
|
||||||
if (current.length > 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
} else if (char === '[') {
|
|
||||||
if (current.length > 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
inBracket = true;
|
|
||||||
bracketContent = '';
|
|
||||||
} else if (char === '<') {
|
|
||||||
let angleContent = '<';
|
|
||||||
i++;
|
|
||||||
while (i < input.length && input[i] !== '>') {
|
|
||||||
angleContent += input[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
angleContent += '>';
|
|
||||||
tokens.push(angleContent);
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.length > 0) {
|
|
||||||
tokens.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bracketContent.length > 0) {
|
|
||||||
tokens.push(`[${bracketContent}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { type ParsedSchema } from 'inline-schema';
|
|
||||||
|
|
||||||
export type Command = {
|
|
||||||
name: string;
|
|
||||||
flags: Record<string, true>;
|
|
||||||
options: Record<string, unknown>;
|
|
||||||
params: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandParamSchema = {
|
|
||||||
name: string;
|
|
||||||
required: boolean;
|
|
||||||
variadic: boolean;
|
|
||||||
schema?: ParsedSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandOptionSchema = {
|
|
||||||
name: string;
|
|
||||||
short?: string;
|
|
||||||
required: boolean;
|
|
||||||
defaultValue?: unknown;
|
|
||||||
schema?: ParsedSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandFlagSchema = {
|
|
||||||
name: string;
|
|
||||||
short?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandSchema = {
|
|
||||||
name: string;
|
|
||||||
params: CommandParamSchema[];
|
|
||||||
options: CommandOptionSchema[];
|
|
||||||
flags: CommandFlagSchema[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedOptionResult {
|
|
||||||
name: string;
|
|
||||||
short?: string;
|
|
||||||
isFlag: boolean;
|
|
||||||
schema?: ParsedSchema;
|
|
||||||
defaultValue?: unknown;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface RNG {
|
export interface RNG {
|
||||||
/** 设置随机数种子 */
|
/** 设置随机数种子 */
|
||||||
setSeed(seed: number): void;
|
setSeed(seed: number): void;
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function createRNG(seed?: number): RNG {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mulberry32RNG 类实现(用于类型兼容) */
|
/** Mulberry32RNG 类实现(用于类型兼容) */
|
||||||
export class Mulberry32RNG implements RNG {
|
export class Mulberry32RNG {
|
||||||
private seed: number = 1;
|
private seed: number = 1;
|
||||||
|
|
||||||
constructor(seed?: number) {
|
constructor(seed?: number) {
|
||||||
|
|
@ -30,7 +30,7 @@ export class Mulberry32RNG implements RNG {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置随机数种子 */
|
/** 设置随机数种子 */
|
||||||
setSeed(seed: number): void {
|
call(seed: number): void {
|
||||||
this.seed = seed;
|
this.seed = seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +48,11 @@ export class Mulberry32RNG implements RNG {
|
||||||
return Math.floor(this.next(max));
|
return Math.floor(this.next(max));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重新设置种子 */
|
||||||
|
setSeed(seed: number): void {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取当前种子 */
|
/** 获取当前种子 */
|
||||||
getSeed(): number {
|
getSeed(): number {
|
||||||
return this.seed;
|
return this.seed;
|
||||||
|
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { createRule, dispatchCommand, type RuleContext, type RuleRegistry } from '../../src/core/rule';
|
|
||||||
import { createGameContext } from '../../src/core/context';
|
|
||||||
|
|
||||||
describe('Rule System', () => {
|
|
||||||
function createTestGame() {
|
|
||||||
const game = createGameContext();
|
|
||||||
return game;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createRule', () => {
|
|
||||||
it('should create a rule definition with parsed schema', () => {
|
|
||||||
const rule = createRule('<from> <to> [--force]', function*(cmd) {
|
|
||||||
return { from: cmd.params[0], to: cmd.params[1] };
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rule.schema.params).toHaveLength(2);
|
|
||||||
expect(rule.schema.params[0].name).toBe('from');
|
|
||||||
expect(rule.schema.params[0].required).toBe(true);
|
|
||||||
expect(rule.schema.params[1].name).toBe('to');
|
|
||||||
expect(rule.schema.params[1].required).toBe(true);
|
|
||||||
expect(rule.schema.flags).toHaveLength(1);
|
|
||||||
expect(rule.schema.flags[0].name).toBe('force');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a generator when called', () => {
|
|
||||||
const rule = createRule('<target>', function*(cmd) {
|
|
||||||
return cmd.params[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
const gen = rule.create({ name: 'test', params: ['card1'], flags: {}, options: {} });
|
|
||||||
const result = gen.next();
|
|
||||||
expect(result.done).toBe(true);
|
|
||||||
expect(result.value).toBe('card1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dispatchCommand - rule invocation', () => {
|
|
||||||
it('should invoke a registered rule and yield schema', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('move', createRule('<from> <to>', function*(cmd) {
|
|
||||||
yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return { moved: cmd.params[0] };
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ctx = game.dispatchCommand('move card1 hand');
|
|
||||||
|
|
||||||
expect(ctx).toBeDefined();
|
|
||||||
expect(ctx!.state).toBe('yielded');
|
|
||||||
expect(ctx!.schema).toBeDefined();
|
|
||||||
expect(ctx!.resolution).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should complete a rule when final command matches yielded schema', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('move', createRule('<from> <to>', function*(cmd) {
|
|
||||||
const confirm = yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return { moved: cmd.params[0], confirmed: confirm.name === 'confirm' };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('move card1 hand');
|
|
||||||
const ctx = game.dispatchCommand('confirm');
|
|
||||||
|
|
||||||
expect(ctx).toBeDefined();
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toEqual({ moved: 'card1', confirmed: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined when command matches no rule and no yielded context', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
const result = game.dispatchCommand('unknown command');
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass the initial command to the generator', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('attack', createRule('<target> [--power: number]', function*(cmd) {
|
|
||||||
return { target: cmd.params[0], power: cmd.options.power || '1' };
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ctx = game.dispatchCommand('attack goblin --power 5');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toEqual({ target: 'goblin', power: '5' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should complete immediately if generator does not yield', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('look', createRule('[--at]', function*() {
|
|
||||||
return 'looked';
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ctx = game.dispatchCommand('look');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toBe('looked');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dispatchCommand - rule priority', () => {
|
|
||||||
it('should prioritize new rule invocation over feeding yielded context', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('move', createRule('<from> <to>', function*(cmd) {
|
|
||||||
yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return { moved: cmd.params[0] };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.rules.value.set('confirm', createRule('', function*() {
|
|
||||||
return 'new confirm rule';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('move card1 hand');
|
|
||||||
|
|
||||||
const ctx = game.dispatchCommand('confirm');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toBe('new confirm rule');
|
|
||||||
expect(ctx!.type).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dispatchCommand - fallback to yielded context', () => {
|
|
||||||
it('should feed a yielded context when command does not match any rule', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('move', createRule('<from> <to>', function*(cmd) {
|
|
||||||
const response = yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return { moved: cmd.params[0], response: response.name };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('move card1 hand');
|
|
||||||
const ctx = game.dispatchCommand('yes');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toEqual({ moved: 'card1', response: 'yes' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip non-matching commands for yielded context', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('move', createRule('<from> <to>', function*(cmd) {
|
|
||||||
const response = yield '<item>';
|
|
||||||
return { response: response.params[0] };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('move card1 hand');
|
|
||||||
|
|
||||||
const ctx = game.dispatchCommand('goblin');
|
|
||||||
|
|
||||||
expect(ctx).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate command against yielded schema', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('trade', createRule('<from> <to>', function*(cmd) {
|
|
||||||
const response = yield '<item> [amount: number]';
|
|
||||||
return { traded: response.params[0] };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('trade player1 player2');
|
|
||||||
const ctx = game.dispatchCommand('offer gold 5');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toEqual({ traded: 'gold' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dispatchCommand - deepest context first', () => {
|
|
||||||
it('should feed the deepest yielded context', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('parent', createRule('<action>', function*() {
|
|
||||||
yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return 'parent done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.rules.value.set('child', createRule('<target>', function*() {
|
|
||||||
yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return 'child done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('parent start');
|
|
||||||
game.dispatchCommand('child target1');
|
|
||||||
|
|
||||||
const ctx = game.dispatchCommand('grandchild_cmd');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toBe('child done');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('nested rule invocations', () => {
|
|
||||||
it('should link child to parent', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('parent', createRule('<action>', function*() {
|
|
||||||
yield 'child_cmd';
|
|
||||||
return 'parent done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.rules.value.set('child_cmd', createRule('<target>', function*() {
|
|
||||||
return 'child done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('parent start');
|
|
||||||
const parentCtx = game.ruleContexts.value[0];
|
|
||||||
|
|
||||||
game.dispatchCommand('child_cmd target1');
|
|
||||||
|
|
||||||
expect(parentCtx.state).toBe('waiting');
|
|
||||||
|
|
||||||
const childCtx = game.ruleContexts.value[1];
|
|
||||||
expect(childCtx.parent).toBe(parentCtx);
|
|
||||||
expect(parentCtx.children).toContain(childCtx);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should discard previous children when a new child is invoked', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('parent', createRule('<action>', function*() {
|
|
||||||
yield 'child_a | child_b';
|
|
||||||
return 'parent done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.rules.value.set('child_a', createRule('<target>', function*() {
|
|
||||||
return 'child_a done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.rules.value.set('child_b', createRule('<target>', function*() {
|
|
||||||
return 'child_b done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('parent start');
|
|
||||||
game.dispatchCommand('child_a target1');
|
|
||||||
|
|
||||||
expect(game.ruleContexts.value.length).toBe(2);
|
|
||||||
|
|
||||||
const oldParent = game.ruleContexts.value[0];
|
|
||||||
expect(oldParent.children).toHaveLength(1);
|
|
||||||
|
|
||||||
game.dispatchCommand('parent start');
|
|
||||||
game.dispatchCommand('child_b target2');
|
|
||||||
|
|
||||||
const newParent = game.ruleContexts.value[2];
|
|
||||||
expect(newParent.children).toHaveLength(1);
|
|
||||||
expect(newParent.children[0].resolution).toBe('child_b done');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('context tracking', () => {
|
|
||||||
it('should track rule contexts in ruleContexts signal', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('test', createRule('<arg>', function*() {
|
|
||||||
yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return 'done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(game.ruleContexts.value.length).toBe(0);
|
|
||||||
|
|
||||||
game.dispatchCommand('test arg1');
|
|
||||||
|
|
||||||
expect(game.ruleContexts.value.length).toBe(1);
|
|
||||||
expect(game.ruleContexts.value[0].state).toBe('yielded');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add context to the context stack', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('test', createRule('<arg>', function*() {
|
|
||||||
yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
return 'done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
const initialStackLength = game.contexts.value.length;
|
|
||||||
|
|
||||||
game.dispatchCommand('test arg1');
|
|
||||||
|
|
||||||
expect(game.contexts.value.length).toBe(initialStackLength + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('error handling', () => {
|
|
||||||
it('should leave context in place when generator throws', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('failing', createRule('<arg>', function*() {
|
|
||||||
throw new Error('rule error');
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(() => game.dispatchCommand('failing arg1')).toThrow('rule error');
|
|
||||||
|
|
||||||
expect(game.ruleContexts.value.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should leave children in place when child generator throws', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('parent', createRule('<action>', function*() {
|
|
||||||
yield 'child';
|
|
||||||
return 'parent done';
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.rules.value.set('child', createRule('<target>', function*() {
|
|
||||||
throw new Error('child error');
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('parent start');
|
|
||||||
expect(() => game.dispatchCommand('child target1')).toThrow('child error');
|
|
||||||
|
|
||||||
expect(game.ruleContexts.value.length).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('schema yielding', () => {
|
|
||||||
it('should accept a CommandSchema object as yield value', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
const customSchema = {
|
|
||||||
name: 'custom',
|
|
||||||
params: [{ name: 'x', required: true, variadic: false }],
|
|
||||||
options: [],
|
|
||||||
flags: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
game.rules.value.set('test', createRule('<arg>', function*() {
|
|
||||||
const cmd = yield customSchema;
|
|
||||||
return { received: cmd.params[0] };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('test val1');
|
|
||||||
const ctx = game.dispatchCommand('custom hello');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toEqual({ received: 'hello' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse string schema on each yield', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('multi', createRule('<start>', function*() {
|
|
||||||
const a = yield '<value>';
|
|
||||||
const b = yield '<value>';
|
|
||||||
return { a: a.params[0], b: b.params[0] };
|
|
||||||
}));
|
|
||||||
|
|
||||||
game.dispatchCommand('multi init');
|
|
||||||
game.dispatchCommand('cmd first');
|
|
||||||
const ctx = game.dispatchCommand('cmd second');
|
|
||||||
|
|
||||||
expect(ctx!.state).toBe('done');
|
|
||||||
expect(ctx!.resolution).toEqual({ a: 'first', b: 'second' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('complex flow', () => {
|
|
||||||
it('should handle a multi-step game flow', () => {
|
|
||||||
const game = createTestGame();
|
|
||||||
|
|
||||||
game.rules.value.set('start', createRule('<player>', function*(cmd) {
|
|
||||||
const player = cmd.params[0];
|
|
||||||
const action = yield { name: '', params: [], options: [], flags: [] };
|
|
||||||
|
|
||||||
if (action.name === 'move') {
|
|
||||||
yield '<target>';
|
|
||||||
} else if (action.name === 'attack') {
|
|
||||||
yield '<target> [--power: number]';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { player, action: action.name };
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ctx1 = game.dispatchCommand('start alice');
|
|
||||||
expect(ctx1!.state).toBe('yielded');
|
|
||||||
|
|
||||||
const ctx2 = game.dispatchCommand('attack');
|
|
||||||
expect(ctx2!.state).toBe('yielded');
|
|
||||||
|
|
||||||
const ctx3 = game.dispatchCommand('attack goblin --power 3');
|
|
||||||
expect(ctx3!.state).toBe('done');
|
|
||||||
expect(ctx3!.resolution).toEqual({ player: 'alice', action: 'attack' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue