Compare commits
11 Commits
775bb00bed
...
28782aaf9b
| Author | SHA1 | Date |
|---|---|---|
|
|
28782aaf9b | |
|
|
b90a4bba52 | |
|
|
8f86b88c88 | |
|
|
9e2947a8d6 | |
|
|
af9254603a | |
|
|
6e1c42015f | |
|
|
467a56bd84 | |
|
|
de7006ef19 | |
|
|
97ef1df4fb | |
|
|
d4562b8534 | |
|
|
80f0796f3c |
|
|
@ -1,23 +1,23 @@
|
||||||
import { ReadonlySignal, Signal } from '@preact/signals-core';
|
import { ReadonlySignal, Signal } from '@preact/signals-core';
|
||||||
import type { CommandSchema, CommandRegistry, PromptEvent } from '@/utils/command';
|
import type {CommandSchema, CommandRegistry, PromptEvent, CommandRunnerContextExport} from '@/utils/command';
|
||||||
import type { MutableSignal } from '@/utils/mutable-signal';
|
import type { MutableSignal } from '@/utils/mutable-signal';
|
||||||
import { createGameContext } from './game';
|
import {createGameContext, IGameContext} from './game';
|
||||||
|
|
||||||
export type GameHostStatus = 'created' | 'running' | 'disposed';
|
export type GameHostStatus = 'created' | 'running' | 'disposed';
|
||||||
|
|
||||||
export interface GameModule<TState extends Record<string, unknown>> {
|
export interface GameModule<TState extends Record<string, unknown>> {
|
||||||
registry: CommandRegistry<MutableSignal<TState>>;
|
registry: CommandRegistry<IGameContext<TState>>;
|
||||||
createInitialState: () => TState;
|
createInitialState: () => TState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameHost<TState extends Record<string, unknown>> {
|
export class GameHost<TState extends Record<string, unknown>> {
|
||||||
readonly state: ReadonlySignal<TState>;
|
readonly context: IGameContext<TState>;
|
||||||
readonly commands: ReturnType<typeof createGameContext<TState>>['commands'];
|
|
||||||
readonly status: ReadonlySignal<GameHostStatus>;
|
readonly status: ReadonlySignal<GameHostStatus>;
|
||||||
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
||||||
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
||||||
|
|
||||||
private _state: MutableSignal<TState>;
|
private _state: MutableSignal<TState>;
|
||||||
|
private _commands: CommandRunnerContextExport<IGameContext<TState>>;
|
||||||
private _status: Signal<GameHostStatus>;
|
private _status: Signal<GameHostStatus>;
|
||||||
private _activePromptSchema: Signal<CommandSchema | null>;
|
private _activePromptSchema: Signal<CommandSchema | null>;
|
||||||
private _activePromptPlayer: Signal<string | null>;
|
private _activePromptPlayer: Signal<string | null>;
|
||||||
|
|
@ -26,17 +26,14 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
registry: CommandRegistry<MutableSignal<TState>>,
|
registry: CommandRegistry<IGameContext<TState>>,
|
||||||
createInitialState: () => TState,
|
createInitialState: () => TState,
|
||||||
) {
|
) {
|
||||||
this._createInitialState = createInitialState;
|
this._createInitialState = createInitialState;
|
||||||
this._eventListeners = new Map();
|
this._eventListeners = new Map();
|
||||||
|
|
||||||
const initialState = createInitialState();
|
const initialState = createInitialState();
|
||||||
const context = createGameContext(registry, initialState);
|
this.context = createGameContext(registry, initialState);
|
||||||
|
|
||||||
this._state = context.state;
|
|
||||||
this.commands = context.commands;
|
|
||||||
|
|
||||||
this._status = new Signal<GameHostStatus>('created');
|
this._status = new Signal<GameHostStatus>('created');
|
||||||
this.status = this._status;
|
this.status = this._status;
|
||||||
|
|
@ -47,7 +44,8 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
this._activePromptPlayer = new Signal<string | null>(null);
|
this._activePromptPlayer = new Signal<string | null>(null);
|
||||||
this.activePromptPlayer = this._activePromptPlayer;
|
this.activePromptPlayer = this._activePromptPlayer;
|
||||||
|
|
||||||
this.state = this._state;
|
this._state = this.context._state;
|
||||||
|
this._commands = this.context._commands;
|
||||||
|
|
||||||
this._setupPromptTracking();
|
this._setupPromptTracking();
|
||||||
}
|
}
|
||||||
|
|
@ -55,13 +53,13 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
private _setupPromptTracking() {
|
private _setupPromptTracking() {
|
||||||
let currentPromptEvent: PromptEvent | null = null;
|
let currentPromptEvent: PromptEvent | null = null;
|
||||||
|
|
||||||
this.commands.on('prompt', (e) => {
|
this._commands.on('prompt', (e) => {
|
||||||
currentPromptEvent = e as PromptEvent;
|
currentPromptEvent = e as PromptEvent;
|
||||||
this._activePromptSchema.value = currentPromptEvent.schema;
|
this._activePromptSchema.value = currentPromptEvent.schema;
|
||||||
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
|
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.commands.on('promptEnd', () => {
|
this._commands.on('promptEnd', () => {
|
||||||
currentPromptEvent = null;
|
currentPromptEvent = null;
|
||||||
this._activePromptSchema.value = null;
|
this._activePromptSchema.value = null;
|
||||||
this._activePromptPlayer.value = null;
|
this._activePromptPlayer.value = null;
|
||||||
|
|
@ -76,7 +74,7 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
if (this._isDisposed) {
|
if (this._isDisposed) {
|
||||||
return 'GameHost is disposed';
|
return 'GameHost is disposed';
|
||||||
}
|
}
|
||||||
return this.commands._tryCommit(input);
|
return this._commands._tryCommit(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -100,14 +98,14 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
throw new Error('GameHost is disposed');
|
throw new Error('GameHost is disposed');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.commands._cancel();
|
this._commands._cancel();
|
||||||
|
|
||||||
const initialState = this._createInitialState();
|
const initialState = this._createInitialState();
|
||||||
this._state.value = initialState as any;
|
this._state.value = initialState as any;
|
||||||
|
|
||||||
// Start the setup command but don't wait for it to complete
|
// Start the setup command but don't wait for it to complete
|
||||||
// The command will run in the background and prompt for input
|
// The command will run in the background and prompt for input
|
||||||
this.commands.run(setupCommand).catch(() => {
|
this._commands.run(setupCommand).catch(() => {
|
||||||
// Command may be cancelled or fail, which is expected
|
// Command may be cancelled or fail, which is expected
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -121,7 +119,7 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._isDisposed = true;
|
this._isDisposed = true;
|
||||||
this.commands._cancel();
|
this._commands._cancel();
|
||||||
this._status.value = 'disposed';
|
this._status.value = 'disposed';
|
||||||
|
|
||||||
// Emit dispose event BEFORE clearing listeners
|
// Emit dispose event BEFORE clearing listeners
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
|
import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandRegistry,
|
CommandRegistry, CommandResult,
|
||||||
CommandRunnerContext,
|
CommandRunnerContext, CommandRunnerContextExport,
|
||||||
CommandRunnerContextExport,
|
|
||||||
CommandSchema,
|
CommandSchema,
|
||||||
createCommandRegistry,
|
createCommandRegistry,
|
||||||
createCommandRunnerContext,
|
createCommandRunnerContext,
|
||||||
|
|
@ -11,24 +10,60 @@ import {
|
||||||
registerCommand
|
registerCommand
|
||||||
} from "@/utils/command";
|
} from "@/utils/command";
|
||||||
import type { GameModule } from './game-host';
|
import type { GameModule } from './game-host';
|
||||||
|
import {PromptValidator} from "@/utils/command/command-runner";
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
||||||
state: MutableSignal<TState>;
|
get value(): TState;
|
||||||
commands: CommandRunnerContextExport<MutableSignal<TState>>;
|
produce(fn: (draft: TState) => void): void;
|
||||||
|
produceAsync(fn: (draft: TState) => void): Promise<void>;
|
||||||
|
run<T>(input: string): Promise<CommandResult<T>>;
|
||||||
|
runParsed<T>(command: Command): Promise<CommandResult<T>>;
|
||||||
|
prompt<T>(schema: CommandSchema | string, validator: PromptValidator<T>, currentPlayer?: string | null): Promise<T>;
|
||||||
|
addInterruption(promise: Promise<void>): void;
|
||||||
|
|
||||||
|
// test only
|
||||||
|
_state: MutableSignal<TState>;
|
||||||
|
_commands: CommandRunnerContextExport<IGameContext<TState>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
commandRegistry: CommandRegistry<MutableSignal<TState>>,
|
commandRegistry: CommandRegistry<IGameContext<TState>>,
|
||||||
initialState?: TState | (() => TState)
|
initialState?: TState | (() => TState)
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
||||||
const state = mutableSignal(stateValue);
|
const state = mutableSignal(stateValue);
|
||||||
const commands = createCommandRunnerContext(commandRegistry, state);
|
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any;
|
||||||
|
|
||||||
return {
|
const context: IGameContext<TState> = {
|
||||||
state,
|
get value(): TState {
|
||||||
commands
|
return state.value;
|
||||||
|
},
|
||||||
|
produce(fn) {
|
||||||
|
return state.produce(fn);
|
||||||
|
},
|
||||||
|
produceAsync(fn) {
|
||||||
|
return state.produceAsync(fn);
|
||||||
|
},
|
||||||
|
run<T>(input: string) {
|
||||||
|
return commands.run<T>(input);
|
||||||
|
},
|
||||||
|
runParsed<T>(command: Command) {
|
||||||
|
return commands.runParsed<T>(command);
|
||||||
|
},
|
||||||
|
prompt(schema, validator, currentPlayer) {
|
||||||
|
return commands.prompt(schema, validator, currentPlayer);
|
||||||
|
},
|
||||||
|
addInterruption(promise) {
|
||||||
|
state.addInterruption(promise);
|
||||||
|
},
|
||||||
|
|
||||||
|
_state: state,
|
||||||
|
_commands: commands,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
context._commands = commands = createCommandRunnerContext(commandRegistry, context);
|
||||||
|
|
||||||
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,7 +72,7 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
*/
|
*/
|
||||||
export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
|
export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
|
||||||
module: {
|
module: {
|
||||||
registry: CommandRegistry<MutableSignal<TState>>,
|
registry: CommandRegistry<IGameContext<TState>>,
|
||||||
createInitialState: () => TState
|
createInitialState: () => TState
|
||||||
},
|
},
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
|
|
@ -45,28 +80,7 @@ export function createGameContextFromModule<TState extends Record<string, unknow
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
|
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
|
||||||
const registry = createCommandRegistry<MutableSignal<TState>>();
|
return createCommandRegistry<IGameContext<TState>>();
|
||||||
return {
|
|
||||||
registry,
|
|
||||||
add<TResult = unknown>(
|
|
||||||
schema: CommandSchema | string,
|
|
||||||
run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
|
|
||||||
){
|
|
||||||
createGameCommand(registry, schema, run);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
|
||||||
registry: CommandRegistry<MutableSignal<TState>>,
|
|
||||||
schema: CommandSchema | string,
|
|
||||||
run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
|
|
||||||
) {
|
|
||||||
registerCommand(registry, {
|
|
||||||
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
|
|
||||||
run,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { GameHost, createGameHost } from './game-host';
|
export { GameHost, createGameHost } from './game-host';
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,19 @@ function createEmptyPartPool<TMeta>(): PartPool<TMeta> {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export function createPartsFromTable<T>(items: T[], getId: (item: T, index: number) => string, getCount?: ((item: T) => number) | number){
|
||||||
|
const pool: Record<string, Part<T>> = {};
|
||||||
|
for (const entry of items) {
|
||||||
|
const count = getCount ? (typeof getCount === 'function' ? getCount(entry) : getCount) : 1;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const id = getId(entry, i);
|
||||||
|
pool[id] = {
|
||||||
|
id,
|
||||||
|
regionId: '',
|
||||||
|
position: [],
|
||||||
|
...entry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,41 +117,20 @@ export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>
|
||||||
region.partMap = buildPartMap(region, parts);
|
region.partMap = buildPartMap(region, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveToRegion<TMeta>(part: Part<TMeta>, sourceRegion: Region | null, targetRegion: Region, position?: number[]) {
|
export function moveToRegion<TMeta>(part: Part<TMeta>, sourceRegion: Region | null, targetRegion: Region | null, position?: number[]) {
|
||||||
if (sourceRegion && part.regionId === sourceRegion.id) {
|
if (sourceRegion && part.regionId === sourceRegion.id) {
|
||||||
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
||||||
delete sourceRegion.partMap[part.position.join(',')];
|
delete sourceRegion.partMap[part.position.join(',')];
|
||||||
}
|
}
|
||||||
|
|
||||||
targetRegion.childIds.push(part.id);
|
|
||||||
if (position) {
|
if (position) {
|
||||||
part.position = position;
|
part.position = position;
|
||||||
}
|
}
|
||||||
targetRegion.partMap[part.position.join(',')] = part.id;
|
|
||||||
|
|
||||||
part.regionId = targetRegion.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveToRegionAll<TMeta>(parts: Record<string, Part<TMeta>>, sourceRegion: Region | null, targetRegion: Region, positions?: number[][]) {
|
|
||||||
const partIds = Object.keys(parts);
|
|
||||||
for (let i = 0; i < partIds.length; i++) {
|
|
||||||
const part = parts[partIds[i]];
|
|
||||||
if (sourceRegion && part.regionId === sourceRegion.id) {
|
|
||||||
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
|
||||||
delete sourceRegion.partMap[part.position.join(',')];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if(targetRegion){
|
||||||
targetRegion.childIds.push(part.id);
|
targetRegion.childIds.push(part.id);
|
||||||
if (positions && positions[i]) {
|
|
||||||
part.position = positions[i];
|
|
||||||
}
|
|
||||||
targetRegion.partMap[part.position.join(',')] = part.id;
|
targetRegion.partMap[part.position.join(',')] = part.id;
|
||||||
|
|
||||||
part.regionId = targetRegion.id;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function removeFromRegion<TMeta>(part: Part<TMeta>, region: Region) {
|
part.regionId = targetRegion?.id || '';
|
||||||
region.childIds = region.childIds.filter(id => id !== part.id);
|
|
||||||
delete region.partMap[part.position.join(',')];
|
|
||||||
}
|
}
|
||||||
|
|
@ -14,10 +14,10 @@ export type { Part } from './core/part';
|
||||||
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';
|
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';
|
||||||
|
|
||||||
export type { PartTemplate, PartPool } from './core/part-factory';
|
export type { PartTemplate, PartPool } from './core/part-factory';
|
||||||
export { createPart, createParts, createPartPool, mergePartPools } from './core/part-factory';
|
export { createPart, createParts, createPartPool, mergePartPools, createPartsFromTable } from './core/part-factory';
|
||||||
|
|
||||||
export type { Region, RegionAxis } from './core/region';
|
export type { Region, RegionAxis } from './core/region';
|
||||||
export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
|
export { createRegion, applyAlign, shuffle, moveToRegion } from './core/region';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export type { Command, CommandResult, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
export type { Command, CommandResult, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import {
|
||||||
|
BOARD_SIZE,
|
||||||
|
BoopState,
|
||||||
|
PieceType,
|
||||||
|
PlayerType,
|
||||||
|
WinnerType,
|
||||||
|
WIN_LENGTH,
|
||||||
|
MAX_PIECES_PER_PLAYER, BoopGame
|
||||||
|
} from "./data";
|
||||||
|
import {createGameCommandRegistry} from "@/core/game";
|
||||||
|
import {moveToRegion} from "@/core/region";
|
||||||
|
import {
|
||||||
|
findPartAtPosition,
|
||||||
|
findPartInRegion,
|
||||||
|
getLineCandidates,
|
||||||
|
getNeighborPositions,
|
||||||
|
isCellOccupied,
|
||||||
|
isInBounds
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
export const registry = createGameCommandRegistry<BoopState>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 放置棋子到棋盘
|
||||||
|
*/
|
||||||
|
async function place(game: BoopGame, row: number, col: number, player: PlayerType, type: PieceType) {
|
||||||
|
const value = game.value;
|
||||||
|
// 从玩家supply中找到对应类型的棋子
|
||||||
|
const part = findPartInRegion(game, player, type);
|
||||||
|
|
||||||
|
if (!part) {
|
||||||
|
throw new Error(`No ${type} available in ${player}'s supply`);
|
||||||
|
}
|
||||||
|
|
||||||
|
game.produce(state => {
|
||||||
|
// 将棋子从supply移动到棋盘
|
||||||
|
moveToRegion(part, state.regions[player], state.regions.board, [row, col]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { row, col, player, type, partId: part.id };
|
||||||
|
}
|
||||||
|
const placeCommand = registry.register( 'place <row:number> <col:number> <player> <type>', place);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行boop - 推动周围棋子
|
||||||
|
*/
|
||||||
|
async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
|
||||||
|
const booped: string[] = [];
|
||||||
|
|
||||||
|
game.produce(state => {
|
||||||
|
// 按照远离放置位置的方向推动
|
||||||
|
for (const [dr, dc] of getNeighborPositions()) {
|
||||||
|
const nr = row + dr;
|
||||||
|
const nc = col + dc;
|
||||||
|
|
||||||
|
if (!isInBounds(nr, nc)) continue;
|
||||||
|
|
||||||
|
const part = findPartAtPosition(game, nr, nc);
|
||||||
|
if (!part) continue;
|
||||||
|
|
||||||
|
// 小猫不能推动猫
|
||||||
|
if (type === 'kitten' && part.type === 'cat') continue;
|
||||||
|
|
||||||
|
// 计算推动后的位置
|
||||||
|
const newRow = nr + dr;
|
||||||
|
const newCol = nc + dc;
|
||||||
|
|
||||||
|
// 检查新位置是否为空或在棋盘外
|
||||||
|
if (!isInBounds(newRow, newCol)) {
|
||||||
|
// 棋子被推出棋盘,返回玩家supply
|
||||||
|
booped.push(part.id);
|
||||||
|
moveToRegion(part, state.regions.board, state.regions[part.player]);
|
||||||
|
} else if (!isCellOccupied(game, newRow, newCol)) {
|
||||||
|
// 新位置为空,移动过去
|
||||||
|
booped.push(part.id);
|
||||||
|
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
|
||||||
|
}
|
||||||
|
// 如果新位置被占用,则不移动(两个棋子都保持原位)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { booped };
|
||||||
|
}
|
||||||
|
const boopCommand = registry.register('boop <row:number> <col:number> <type>', boop);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有玩家获胜(三个猫连线)
|
||||||
|
*/
|
||||||
|
async function checkWin(game: BoopGame) {
|
||||||
|
for(const line of getLineCandidates()){
|
||||||
|
let whites = 0;
|
||||||
|
let blacks = 0;
|
||||||
|
for(const [row, col] of line){
|
||||||
|
const part = findPartAtPosition(game, row, col);
|
||||||
|
if(part?.type !== 'cat') continue;
|
||||||
|
if (part.player === 'white') whites++;
|
||||||
|
else blacks++;
|
||||||
|
}
|
||||||
|
if(whites >= WIN_LENGTH) {
|
||||||
|
return 'white';
|
||||||
|
}
|
||||||
|
if(blacks >= WIN_LENGTH) {
|
||||||
|
return 'black';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const checkWinCommand = registry.register('check-win', checkWin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并执行小猫升级(三个小猫连线变成猫)
|
||||||
|
*/
|
||||||
|
async function checkGraduates(game: BoopGame){
|
||||||
|
const toUpgrade = new Set<string>();
|
||||||
|
for(const line of getLineCandidates()){
|
||||||
|
let whites = 0;
|
||||||
|
let blacks = 0;
|
||||||
|
for(const [row, col] of line){
|
||||||
|
const part = findPartAtPosition(game, row, col);
|
||||||
|
if (part?.player === 'white') whites++;
|
||||||
|
else if(part?.player === 'black') blacks++;
|
||||||
|
}
|
||||||
|
const player = whites >= WIN_LENGTH ? 'white' : blacks >= WIN_LENGTH ? 'black' : null;
|
||||||
|
if(!player) continue;
|
||||||
|
|
||||||
|
for(const [row, col] of line){
|
||||||
|
const part = findPartAtPosition(game, row, col);
|
||||||
|
part && toUpgrade.add(part.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
game.produce(state => {
|
||||||
|
for(const partId of toUpgrade){
|
||||||
|
const part = state.pieces[partId];
|
||||||
|
const [row, col] = part.position;
|
||||||
|
const player = part.player;
|
||||||
|
moveToRegion(part, state.regions.board, null);
|
||||||
|
|
||||||
|
const newPart = findPartInRegion(game, '', 'cat');
|
||||||
|
moveToRegion(newPart || part, null, state.regions[player], [row, col]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const checkGraduatesCommand = registry.register('check-graduates', checkGraduates);
|
||||||
|
|
||||||
|
async function setup(game: BoopGame) {
|
||||||
|
while (true) {
|
||||||
|
const currentPlayer = game.value.currentPlayer;
|
||||||
|
const turnOutput = await turnCommand(game, currentPlayer);
|
||||||
|
if (!turnOutput.success) throw new Error(turnOutput.error);
|
||||||
|
|
||||||
|
game.produce(state => {
|
||||||
|
state.winner = turnOutput.result.winner;
|
||||||
|
if (!state.winner) {
|
||||||
|
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (game.value.winner) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return game.value;
|
||||||
|
}
|
||||||
|
registry.register('setup', setup);
|
||||||
|
|
||||||
|
async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){
|
||||||
|
// 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫
|
||||||
|
const playerPieces = Object.values(game.value.pieces).filter(
|
||||||
|
p => p.player === turnPlayer && p.regionId === 'board'
|
||||||
|
);
|
||||||
|
if(playerPieces.length < MAX_PIECES_PER_PLAYER || game.value.winner !== null){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partId = await game.prompt(
|
||||||
|
'choose <player> <row:number> <col:number>',
|
||||||
|
(command) => {
|
||||||
|
const [player, row, col] = command.params as [PlayerType, number, number];
|
||||||
|
if (player !== turnPlayer) {
|
||||||
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
|
}
|
||||||
|
if (!isInBounds(row, col)) {
|
||||||
|
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const part = findPartAtPosition(game, row, col);
|
||||||
|
if (!part || part.player !== turnPlayer) {
|
||||||
|
throw `No ${player} piece at (${row}, ${col}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.id;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
game.produce(state => {
|
||||||
|
const part = state.pieces[partId];
|
||||||
|
moveToRegion(part, state.regions.board, null);
|
||||||
|
const cat = findPartInRegion(state, '', 'cat');
|
||||||
|
moveToRegion(cat || part, null, state.regions[turnPlayer]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function turn(game: BoopGame, turnPlayer: PlayerType) {
|
||||||
|
const {row, col, type} = await game.prompt(
|
||||||
|
'play <player> <row:number> <col:number> [type:string]',
|
||||||
|
(command) => {
|
||||||
|
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
|
||||||
|
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||||
|
|
||||||
|
if (player !== turnPlayer) {
|
||||||
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
|
}
|
||||||
|
if (!isInBounds(row, col)) {
|
||||||
|
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
||||||
|
}
|
||||||
|
if (isCellOccupied(game, row, col)) {
|
||||||
|
throw `Cell (${row}, ${col}) is already occupied.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = findPartInRegion(game, player, pieceType);
|
||||||
|
if (!found) {
|
||||||
|
throw `No ${pieceType}s left in ${player}'s supply.`;
|
||||||
|
}
|
||||||
|
return {player, row,col,type};
|
||||||
|
},
|
||||||
|
game.value.currentPlayer
|
||||||
|
);
|
||||||
|
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||||
|
|
||||||
|
await placeCommand(game, row, col, turnPlayer, pieceType);
|
||||||
|
await boopCommand(game, row, col, pieceType);
|
||||||
|
const winner = await checkWinCommand(game);
|
||||||
|
if(winner.success) return { winner: winner.result as WinnerType };
|
||||||
|
|
||||||
|
await checkGraduatesCommand(game);
|
||||||
|
return { winner: null };
|
||||||
|
}
|
||||||
|
const turnCommand = registry.register('turn <player>', turn);
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import parts from './parts.csv';
|
||||||
|
import {createRegion, moveToRegion, Region} from "@/core/region";
|
||||||
|
import {createPartsFromTable} from "@/core/part-factory";
|
||||||
|
import {Part} from "@/core/part";
|
||||||
|
import {IGameContext} from "@/core/game";
|
||||||
|
|
||||||
|
export const BOARD_SIZE = 6;
|
||||||
|
export const MAX_PIECES_PER_PLAYER = 8;
|
||||||
|
export const WIN_LENGTH = 3;
|
||||||
|
|
||||||
|
export type PlayerType = 'white' | 'black';
|
||||||
|
export type PieceType = 'kitten' | 'cat';
|
||||||
|
export type WinnerType = PlayerType | 'draw' | null;
|
||||||
|
export type RegionType = 'white' | 'black' | 'board' | '';
|
||||||
|
export type BoopPartMeta = { player: PlayerType; type: PieceType };
|
||||||
|
export type BoopPart = Part<BoopPartMeta>;
|
||||||
|
|
||||||
|
export function createInitialState() {
|
||||||
|
const pieces = createPartsFromTable(
|
||||||
|
parts,
|
||||||
|
(item, index) => `${item.player}-${item.type}-${index + 1}`,
|
||||||
|
(item) => item.count
|
||||||
|
) as Record<string, BoopPart>;
|
||||||
|
|
||||||
|
// Initialize region childIds
|
||||||
|
const whiteRegion = createRegion('white', []);
|
||||||
|
const blackRegion = createRegion('black', []);
|
||||||
|
const boardRegion = createRegion('board', [
|
||||||
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Populate region childIds based on piece regionId
|
||||||
|
for (const part of Object.values(pieces)) {
|
||||||
|
if(part.type !== 'kitten') continue;
|
||||||
|
if (part.player === 'white' ) {
|
||||||
|
moveToRegion(part, null, whiteRegion);
|
||||||
|
} else if (part.player === 'black') {
|
||||||
|
moveToRegion(part, null, blackRegion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
regions: {
|
||||||
|
white: whiteRegion,
|
||||||
|
black: blackRegion,
|
||||||
|
board: boardRegion,
|
||||||
|
} as Record<RegionType, Region>,
|
||||||
|
pieces,
|
||||||
|
currentPlayer: 'white' as PlayerType,
|
||||||
|
winner: null as WinnerType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type BoopState = ReturnType<typeof createInitialState>;
|
||||||
|
export type BoopGame = IGameContext<BoopState>;
|
||||||
|
|
@ -1,365 +1,2 @@
|
||||||
import {
|
export * from './data';
|
||||||
createGameCommandRegistry,
|
export * from './commands';
|
||||||
Part,
|
|
||||||
MutableSignal,
|
|
||||||
createRegion,
|
|
||||||
createPart,
|
|
||||||
isCellOccupied as isCellOccupiedUtil,
|
|
||||||
getPartAtPosition,
|
|
||||||
} from '@/index';
|
|
||||||
|
|
||||||
const BOARD_SIZE = 6;
|
|
||||||
const MAX_PIECES_PER_PLAYER = 8;
|
|
||||||
const WIN_LENGTH = 3;
|
|
||||||
|
|
||||||
export type PlayerType = 'white' | 'black';
|
|
||||||
export type PieceType = 'kitten' | 'cat';
|
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
|
||||||
|
|
||||||
type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
|
|
||||||
|
|
||||||
type PieceSupply = { supply: number; placed: number };
|
|
||||||
|
|
||||||
type Player = {
|
|
||||||
id: PlayerType;
|
|
||||||
kitten: PieceSupply;
|
|
||||||
cat: PieceSupply;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlayerData = Record<PlayerType, Player>;
|
|
||||||
|
|
||||||
export function createInitialState() {
|
|
||||||
return {
|
|
||||||
board: createRegion('board', [
|
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
]),
|
|
||||||
pieces: {} as Record<string, BoopPart>,
|
|
||||||
currentPlayer: 'white' as PlayerType,
|
|
||||||
winner: null as WinnerType,
|
|
||||||
players: {
|
|
||||||
white: createPlayer('white'),
|
|
||||||
black: createPlayer('black'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPlayer(id: PlayerType): Player {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
|
|
||||||
cat: { supply: 0, placed: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BoopState = ReturnType<typeof createInitialState>;
|
|
||||||
const registration = createGameCommandRegistry<BoopState>();
|
|
||||||
export const registry = registration.registry;
|
|
||||||
|
|
||||||
export function getPlayer(host: MutableSignal<BoopState>, player: PlayerType): Player {
|
|
||||||
return host.value.players[player];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decrementSupply(player: Player, pieceType: PieceType) {
|
|
||||||
player[pieceType].supply--;
|
|
||||||
player[pieceType].placed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function incrementSupply(player: Player, pieceType: PieceType, count?: number) {
|
|
||||||
player[pieceType].supply += count ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
registration.add('setup', async function() {
|
|
||||||
const {context} = this;
|
|
||||||
while (true) {
|
|
||||||
const currentPlayer = context.value.currentPlayer;
|
|
||||||
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer}`);
|
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
|
||||||
|
|
||||||
context.produce(state => {
|
|
||||||
state.winner = turnOutput.result.winner;
|
|
||||||
if (!state.winner) {
|
|
||||||
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (context.value.winner) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
registration.add('turn <player>', async function(cmd) {
|
|
||||||
const [turnPlayer] = cmd.params as [PlayerType];
|
|
||||||
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <player> <row:number> <col:number> [type:string]',
|
|
||||||
(command) => {
|
|
||||||
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
|
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
|
||||||
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
|
||||||
}
|
|
||||||
if (!isValidMove(row, col)) {
|
|
||||||
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
|
||||||
}
|
|
||||||
if (isCellOccupied(this.context, row, col)) {
|
|
||||||
return `Cell (${row}, ${col}) is already occupied.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerData = getPlayer(this.context, player);
|
|
||||||
const supply = playerData[pieceType].supply;
|
|
||||||
if (supply <= 0) {
|
|
||||||
return `No ${pieceType}s left in ${player}'s supply.`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
this.context.value.currentPlayer
|
|
||||||
);
|
|
||||||
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
|
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
|
||||||
|
|
||||||
placePiece(this.context, row, col, turnPlayer, pieceType);
|
|
||||||
applyBoops(this.context, row, col, pieceType);
|
|
||||||
|
|
||||||
const graduatedLines = checkGraduation(this.context, turnPlayer);
|
|
||||||
if (graduatedLines.length > 0) {
|
|
||||||
processGraduation(this.context, turnPlayer, graduatedLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
|
|
||||||
const pieces = this.context.value.pieces;
|
|
||||||
const availableKittens = Object.values(pieces).filter(
|
|
||||||
p => p.player === turnPlayer && p.pieceType === 'kitten'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (availableKittens.length > 0) {
|
|
||||||
const graduateCmd = await this.prompt(
|
|
||||||
'graduate <row:number> <col:number>',
|
|
||||||
(command) => {
|
|
||||||
const [row, col] = command.params as [number, number];
|
|
||||||
const posKey = `${row},${col}`;
|
|
||||||
const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
|
|
||||||
if (!part) return `No kitten at (${row}, ${col}).`;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
this.context.value.currentPlayer
|
|
||||||
);
|
|
||||||
const [row, col] = graduateCmd.params as [number, number];
|
|
||||||
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
|
|
||||||
removePieceFromBoard(this.context, part);
|
|
||||||
const playerData = getPlayer(this.context, turnPlayer);
|
|
||||||
incrementSupply(playerData, 'cat', 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const winner = checkWinner(this.context);
|
|
||||||
if (winner) return { winner };
|
|
||||||
|
|
||||||
return { winner: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
function isValidMove(row: number, col: number): boolean {
|
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBoardRegion(host: MutableSignal<BoopState>) {
|
|
||||||
return host.value.board;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
|
|
||||||
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
|
|
||||||
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const playerData = getPlayer(host, player);
|
|
||||||
const count = playerData[pieceType].placed + 1;
|
|
||||||
|
|
||||||
const piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
|
|
||||||
{ regionId: 'board', position: [row, col], player, pieceType },
|
|
||||||
`${player}-${pieceType}-${count}`
|
|
||||||
);
|
|
||||||
host.produce(s => {
|
|
||||||
s.pieces[piece.id] = piece;
|
|
||||||
board.childIds.push(piece.id);
|
|
||||||
board.partMap[`${row},${col}`] = piece.id;
|
|
||||||
});
|
|
||||||
decrementSupply(playerData, pieceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyBoops(host: MutableSignal<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const piecesArray = Object.values(pieces);
|
|
||||||
|
|
||||||
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
|
|
||||||
|
|
||||||
for (const part of piecesArray) {
|
|
||||||
const [r, c] = part.position;
|
|
||||||
if (r === placedRow && c === placedCol) continue;
|
|
||||||
|
|
||||||
const dr = Math.sign(r - placedRow);
|
|
||||||
const dc = Math.sign(c - placedCol);
|
|
||||||
|
|
||||||
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
|
|
||||||
const booperIsKitten = placedType === 'kitten';
|
|
||||||
const targetIsCat = part.pieceType === 'cat';
|
|
||||||
|
|
||||||
if (booperIsKitten && targetIsCat) continue;
|
|
||||||
|
|
||||||
piecesToBoop.push({ part, dr, dc });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { part, dr, dc } of piecesToBoop) {
|
|
||||||
const [r, c] = part.position;
|
|
||||||
const newRow = r + dr;
|
|
||||||
const newCol = c + dc;
|
|
||||||
|
|
||||||
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
|
|
||||||
const pt = part.pieceType;
|
|
||||||
const pl = part.player;
|
|
||||||
const playerData = getPlayer(host, pl);
|
|
||||||
removePieceFromBoard(host, part);
|
|
||||||
incrementSupply(playerData, pt);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCellOccupied(host, newRow, newCol)) continue;
|
|
||||||
|
|
||||||
part.position = [newRow, newCol];
|
|
||||||
board.partMap = Object.fromEntries(
|
|
||||||
board.childIds.map(id => {
|
|
||||||
const p = pieces[id];
|
|
||||||
return [p.position.join(','), id];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removePieceFromBoard(host: MutableSignal<BoopState>, part: BoopPart) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const playerData = getPlayer(host, part.player);
|
|
||||||
board.childIds = board.childIds.filter(id => id !== part.id);
|
|
||||||
delete board.partMap[part.position.join(',')];
|
|
||||||
delete host.value.pieces[part.id];
|
|
||||||
playerData[part.pieceType].placed--;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIRECTIONS: [number, number][] = [
|
|
||||||
[0, 1],
|
|
||||||
[1, 0],
|
|
||||||
[1, 1],
|
|
||||||
[1, -1],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function* linesThrough(r: number, c: number): Generator<number[][]> {
|
|
||||||
for (const [dr, dc] of DIRECTIONS) {
|
|
||||||
const minStart = -(WIN_LENGTH - 1);
|
|
||||||
for (let offset = minStart; offset <= 0; offset++) {
|
|
||||||
const startR = r + offset * dr;
|
|
||||||
const startC = c + offset * dc;
|
|
||||||
const endR = startR + (WIN_LENGTH - 1) * dr;
|
|
||||||
const endC = startC + (WIN_LENGTH - 1) * dc;
|
|
||||||
|
|
||||||
if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue;
|
|
||||||
if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue;
|
|
||||||
|
|
||||||
const line: number[][] = [];
|
|
||||||
for (let i = 0; i < WIN_LENGTH; i++) {
|
|
||||||
line.push([startR + i * dr, startC + i * dc]);
|
|
||||||
}
|
|
||||||
yield line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function* allLines(): Generator<number[][]> {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
|
||||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
|
||||||
for (const line of linesThrough(r, c)) {
|
|
||||||
const key = line.map(p => p.join(',')).join(';');
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
yield line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
|
||||||
const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`));
|
|
||||||
for (const line of allLines()) {
|
|
||||||
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkGraduation(host: MutableSignal<BoopState>, player: PlayerType): number[][][] {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const piecesArray = Object.values(pieces);
|
|
||||||
const posSet = new Set<string>();
|
|
||||||
|
|
||||||
for (const part of piecesArray) {
|
|
||||||
if (part.player === player && part.pieceType === 'kitten') {
|
|
||||||
posSet.add(`${part.position[0]},${part.position[1]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const winningLines: number[][][] = [];
|
|
||||||
for (const line of allLines()) {
|
|
||||||
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) {
|
|
||||||
winningLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return winningLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function processGraduation(host: MutableSignal<BoopState>, player: PlayerType, lines: number[][][]) {
|
|
||||||
const allPositions = new Set<string>();
|
|
||||||
for (const line of lines) {
|
|
||||||
for (const [r, c] of line) {
|
|
||||||
allPositions.add(`${r},${c}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const partsToRemove = Object.values(pieces).filter(
|
|
||||||
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const part of partsToRemove) {
|
|
||||||
removePieceFromBoard(host, part);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = partsToRemove.length;
|
|
||||||
const playerData = getPlayer(host, player);
|
|
||||||
incrementSupply(playerData, 'cat', count);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countPiecesOnBoard(host: MutableSignal<BoopState>, player: PlayerType): number {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
return Object.values(pieces).filter(p => p.player === player).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkWinner(host: MutableSignal<BoopState>): WinnerType {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const piecesArray = Object.values(pieces);
|
|
||||||
|
|
||||||
for (const player of ['white', 'black'] as PlayerType[]) {
|
|
||||||
const positions = piecesArray
|
|
||||||
.filter(p => p.player === player && p.pieceType === 'cat')
|
|
||||||
.map(p => p.position);
|
|
||||||
if (hasWinningLine(positions)) return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
type,player,count
|
||||||
|
string,string,int
|
||||||
|
kitten,white,8
|
||||||
|
kitten,black,8
|
||||||
|
cat,white,8
|
||||||
|
cat,black,8
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {BOARD_SIZE, BoopGame, BoopPart, BoopState, PieceType, WIN_LENGTH} from "@/samples/boop/data";
|
||||||
|
|
||||||
|
const DIRS = [
|
||||||
|
[0, 1],
|
||||||
|
[1, 0],
|
||||||
|
[1, 1],
|
||||||
|
[-1, 1]
|
||||||
|
]
|
||||||
|
type PT = [number, number];
|
||||||
|
type Line = PT[];
|
||||||
|
export function* getLineCandidates(){
|
||||||
|
for(const [dx, dy] of DIRS){
|
||||||
|
for(let x = 0; x < BOARD_SIZE; x ++)
|
||||||
|
for(let y = 0; y < BOARD_SIZE; y ++){
|
||||||
|
if(!isInBounds(x + dx * (WIN_LENGTH-1), y + dy * (WIN_LENGTH-1))) continue;
|
||||||
|
const line = [];
|
||||||
|
for(let i = 0; i < WIN_LENGTH; i ++){
|
||||||
|
line.push([x + i * dx, y + i * dy]);
|
||||||
|
}
|
||||||
|
yield line as Line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查位置是否在棋盘范围内
|
||||||
|
*/
|
||||||
|
export function isInBounds(x: number, y: number): boolean {
|
||||||
|
return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellOccupied(game: BoopGame | BoopState, x: number, y: number): boolean {
|
||||||
|
const id = `${x},${y}`;
|
||||||
|
return getState(game).regions.board.partMap[id] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getNeighborPositions(x: number = 0, y: number = 0){
|
||||||
|
for(let dx = -1; dx <= 1; dx ++)
|
||||||
|
for(let dy = -1; dy <= 1; dy ++)
|
||||||
|
if(dx !== 0 || dy !== 0)
|
||||||
|
yield [x + dx, y + dy] as PT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPartInRegion(ctx: BoopGame | BoopState, regionId: keyof BoopGame['value']['regions'], type: PieceType): BoopPart | null {
|
||||||
|
const state = getState(ctx);
|
||||||
|
if(!regionId){
|
||||||
|
return Object.values(state.pieces).find(part => part.type === type && !part.regionId) || null;
|
||||||
|
}
|
||||||
|
const id = state.regions[regionId].childIds.find(id => state.pieces[id].type === type);
|
||||||
|
return id ? state.pieces[id] || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPartAtPosition(ctx: BoopGame | BoopState, row: number, col: number): BoopPart | null {
|
||||||
|
const state = getState(ctx);
|
||||||
|
const id = state.regions.board.partMap[`${row},${col}`];
|
||||||
|
return id ? state.pieces[id] || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState(ctx: BoopGame | BoopState): BoopState {
|
||||||
|
if('value' in ctx){
|
||||||
|
return ctx.value;
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import {createGameCommandRegistry, Part, MutableSignal, createRegion, createPart, isCellOccupied as isCellOccupiedUtil} from '@/index';
|
import {
|
||||||
|
createGameCommandRegistry, Part, createRegion, createPart, isCellOccupied as isCellOccupiedUtil,
|
||||||
|
IGameContext
|
||||||
|
} from '@/index';
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
|
|
@ -31,67 +34,64 @@ export function createInitialState() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
const registration = createGameCommandRegistry<TicTacToeState>();
|
export type TicTacToeGame = IGameContext<TicTacToeState>;
|
||||||
export const registry = registration.registry;
|
export const registry = createGameCommandRegistry<TicTacToeState>();
|
||||||
|
|
||||||
registration.add('setup', async function() {
|
async function setup(game: TicTacToeGame) {
|
||||||
const {context} = this;
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentPlayer = context.value.currentPlayer;
|
const currentPlayer = game.value.currentPlayer;
|
||||||
const turnNumber = context.value.turn + 1;
|
const turnNumber = game.value.turn + 1;
|
||||||
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer} ${turnNumber}`);
|
const turnOutput = await turnCommand(game, currentPlayer, turnNumber);
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
if (!turnOutput.success) throw new Error(turnOutput.error);
|
||||||
|
|
||||||
context.produce(state => {
|
game.produce(state => {
|
||||||
state.winner = turnOutput.result.winner;
|
state.winner = turnOutput.result.winner;
|
||||||
if (!state.winner) {
|
if (!state.winner) {
|
||||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
||||||
state.turn = turnNumber;
|
state.turn = turnNumber;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (context.value.winner) break;
|
if (game.value.winner) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.value;
|
return game.value;
|
||||||
});
|
}
|
||||||
|
registry.register('setup', setup);
|
||||||
|
|
||||||
registration.add('turn <player> <turn:number>', async function(cmd) {
|
async function turn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) {
|
||||||
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
const {player, row, col} = await game.prompt(
|
||||||
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <player> <row:number> <col:number>',
|
'play <player> <row:number> <col:number>',
|
||||||
(command) => {
|
(command) => {
|
||||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
const [player, row, col] = command.params as [PlayerType, number, number];
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
if (player !== turnPlayer) {
|
||||||
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
|
} else if (!isValidMove(row, col)) {
|
||||||
|
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
||||||
|
} else if (isCellOccupied(game, row, col)) {
|
||||||
|
throw `Cell (${row}, ${col}) is already occupied.`;
|
||||||
|
} else {
|
||||||
|
return { player, row, col };
|
||||||
}
|
}
|
||||||
if (!isValidMove(row, col)) {
|
|
||||||
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
|
||||||
}
|
|
||||||
if (isCellOccupied(this.context, row, col)) {
|
|
||||||
return `Cell (${row}, ${col}) is already occupied.`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
this.context.value.currentPlayer
|
game.value.currentPlayer
|
||||||
);
|
);
|
||||||
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
placePiece(this.context, row, col, turnPlayer);
|
placePiece(game, row, col, turnPlayer);
|
||||||
|
|
||||||
const winner = checkWinner(this.context);
|
const winner = checkWinner(game);
|
||||||
if (winner) return { winner };
|
if (winner) return { winner };
|
||||||
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
||||||
|
|
||||||
return { winner: null };
|
return { winner: null };
|
||||||
});
|
}
|
||||||
|
const turnCommand = registry.register('turn <player:string> <turnNumber:int>', turn);
|
||||||
|
|
||||||
function isValidMove(row: number, col: number): boolean {
|
function isValidMove(row: number, col: number): boolean {
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean {
|
||||||
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
|
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +103,7 @@ export function hasWinningLine(positions: number[][]): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
export function checkWinner(host: TicTacToeGame): WinnerType {
|
||||||
const parts = host.value.parts;
|
const parts = host.value.parts;
|
||||||
const partsArray = Object.values(parts);
|
const partsArray = Object.values(parts);
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) {
|
||||||
const board = host.value.board;
|
const board = host.value.board;
|
||||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||||
const piece = createPart<{ player: PlayerType }>(
|
const piece = createPart<{ player: PlayerType }>(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,48 @@
|
||||||
import type { Command, CommandSchema } from './types';
|
import type { Command, CommandSchema } from './types';
|
||||||
import type {CommandResult, CommandRunner, CommandRunnerContext, CommandRunnerEvents, PromptEvent} from './command-runner';
|
import type {
|
||||||
|
CommandResult,
|
||||||
|
CommandRunner,
|
||||||
|
CommandRunnerContext,
|
||||||
|
CommandRunnerEvents,
|
||||||
|
PromptEvent,
|
||||||
|
PromptValidator
|
||||||
|
} from './command-runner';
|
||||||
import { parseCommand } from './command-parse';
|
import { parseCommand } from './command-parse';
|
||||||
import { applyCommandSchema } from './command-validate';
|
import { applyCommandSchema } from './command-validate';
|
||||||
import { parseCommandSchema } from './schema-parse';
|
import { parseCommandSchema } from './schema-parse';
|
||||||
import {AsyncQueue} from "@/utils/async-queue";
|
import {AsyncQueue} from "@/utils/async-queue";
|
||||||
|
|
||||||
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
type CanRunParsed = {
|
||||||
|
runParsed<T=unknown>(command: Command): Promise<CommandResult<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type CmdFunc<TContext> = (ctx: TContext, ...args: any[]) => Promise<unknown>;
|
||||||
|
export class CommandRegistry<TContext> extends Map<string, CommandRunner<TContext>>{
|
||||||
|
register<TFunc extends CmdFunc<TContext> = CmdFunc<TContext>>(schema: CommandSchema | string, run: TFunc) {
|
||||||
|
const parsedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
||||||
|
registerCommand(this, {
|
||||||
|
schema: parsedSchema,
|
||||||
|
async run(this: CommandRunnerContext<TContext>, command: Command){
|
||||||
|
const params = command.params;
|
||||||
|
return await run.call(this.context, this.context, ...params);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type TParams = TFunc extends (ctx: TContext, ...args: infer X) => Promise<unknown> ? X : null;
|
||||||
|
type TResult = TFunc extends (ctx: TContext, ...args: any[]) => Promise<infer X> ? X : null;
|
||||||
|
return function(ctx: TContext & CanRunParsed, ...args: TParams){
|
||||||
|
return ctx.runParsed({
|
||||||
|
options: {},
|
||||||
|
params: args,
|
||||||
|
flags: {},
|
||||||
|
name: parsedSchema.name,
|
||||||
|
}) as Promise<CommandResult<TResult>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createCommandRegistry<TContext>(): CommandRegistry<TContext> {
|
export function createCommandRegistry<TContext>(): CommandRegistry<TContext> {
|
||||||
return new Map();
|
return new CommandRegistry();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerCommand<TContext, TResult>(
|
export function registerCommand<TContext, TResult>(
|
||||||
|
|
@ -102,11 +136,11 @@ export function createCommandRunnerContext<TContext>(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = (
|
const prompt = <T>(
|
||||||
schema: CommandSchema | string,
|
schema: CommandSchema | string,
|
||||||
validator?: (command: Command) => string | null,
|
validator: PromptValidator<T>,
|
||||||
currentPlayer?: string | null
|
currentPlayer?: string | null
|
||||||
): Promise<Command> => {
|
): Promise<T> => {
|
||||||
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tryCommit = (commandOrInput: Command | string) => {
|
const tryCommit = (commandOrInput: Command | string) => {
|
||||||
|
|
@ -115,10 +149,16 @@ export function createCommandRunnerContext<TContext>(
|
||||||
if (!schemaResult.valid) {
|
if (!schemaResult.valid) {
|
||||||
return schemaResult.errors.join('; ');
|
return schemaResult.errors.join('; ');
|
||||||
}
|
}
|
||||||
const error = validator?.(schemaResult.command);
|
try{
|
||||||
if (error) return error;
|
const result = validator(schemaResult.command);
|
||||||
resolve(schemaResult.command);
|
resolve(result);
|
||||||
return null;
|
return null;
|
||||||
|
}catch(e){
|
||||||
|
if(typeof e === 'string')
|
||||||
|
return e;
|
||||||
|
else
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const cancel = (reason?: string) => {
|
const cancel = (reason?: string) => {
|
||||||
activePrompt = null;
|
activePrompt = null;
|
||||||
|
|
@ -137,7 +177,7 @@ export function createCommandRunnerContext<TContext>(
|
||||||
registry,
|
registry,
|
||||||
context,
|
context,
|
||||||
run: <T=unknown>(input: string) => runCommandWithContext(runnerCtx, input) as Promise<CommandResult<T>>,
|
run: <T=unknown>(input: string) => runCommandWithContext(runnerCtx, input) as Promise<CommandResult<T>>,
|
||||||
runParsed: (command: Command) => runCommandParsedWithContext(runnerCtx, command),
|
runParsed: <T=unknown>(command: Command) => runCommandParsedWithContext(runnerCtx, command) as Promise<CommandResult<T>>,
|
||||||
prompt,
|
prompt,
|
||||||
on,
|
on,
|
||||||
off,
|
off,
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,13 @@ export type CommandResult<T=unknown> = {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptValidator<T> = (command: Command) => T;
|
||||||
|
|
||||||
export type CommandRunnerContext<TContext> = {
|
export type CommandRunnerContext<TContext> = {
|
||||||
context: TContext;
|
context: TContext;
|
||||||
run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
|
run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
|
||||||
runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
runParsed: <T=unknown>(command: Command) => Promise<CommandResult<T>>;
|
||||||
prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null, currentPlayer?: string | null) => Promise<Command>;
|
prompt: <T>(schema: CommandSchema | string, validator: PromptValidator<T>, currentPlayer?: string | null) => Promise<T>;
|
||||||
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
||||||
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,644 +1,144 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import { registry, createInitialState, BoopState } from '@/samples/boop';
|
||||||
registry,
|
import { createGameContext } from '@/index';
|
||||||
checkWinner,
|
|
||||||
isCellOccupied,
|
|
||||||
getPartAt,
|
|
||||||
placePiece,
|
|
||||||
applyBoops,
|
|
||||||
checkGraduation,
|
|
||||||
processGraduation,
|
|
||||||
hasWinningLine,
|
|
||||||
removePieceFromBoard,
|
|
||||||
createInitialState,
|
|
||||||
BoopState,
|
|
||||||
WinnerType,
|
|
||||||
PlayerType,
|
|
||||||
getBoardRegion,
|
|
||||||
} from '@/samples/boop';
|
|
||||||
import {MutableSignal} from "@/utils/mutable-signal";
|
|
||||||
import {createGameContext} from "@/";
|
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import type { PromptEvent } from '@/utils/command';
|
||||||
|
|
||||||
function createTestContext() {
|
function createTestContext() {
|
||||||
const ctx = createGameContext(registry, createInitialState);
|
const ctx = createGameContext(registry, createInitialState());
|
||||||
return { registry, ctx };
|
return { registry, ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<BoopState> {
|
|
||||||
return ctx.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
|
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
ctx.commands.on('prompt', resolve);
|
ctx._commands.on('prompt', resolve);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParts(state: MutableSignal<BoopState>) {
|
describe('Boop Game', () => {
|
||||||
return Object.values(state.value.pieces);
|
describe('Setup', () => {
|
||||||
}
|
it('should create initial state correctly', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
|
||||||
describe('Boop - helper functions', () => {
|
expect(state.currentPlayer).toBe('white');
|
||||||
describe('isCellOccupied', () => {
|
expect(state.winner).toBeNull();
|
||||||
it('should return false for empty cell', () => {
|
expect(state.regions.board).toBeDefined();
|
||||||
const { ctx } = createTestContext();
|
expect(state.regions.white).toBeDefined();
|
||||||
const state = getState(ctx);
|
expect(state.regions.black).toBeDefined();
|
||||||
|
|
||||||
expect(isCellOccupied(state, 3, 3)).toBe(false);
|
// 8 kittens per player
|
||||||
});
|
const whiteKittens = Object.values(state.pieces).filter(p => p.player === 'white' && p.type === 'kitten');
|
||||||
|
const blackKittens = Object.values(state.pieces).filter(p => p.player === 'black' && p.type === 'kitten');
|
||||||
|
expect(whiteKittens.length).toBe(8);
|
||||||
|
expect(blackKittens.length).toBe(8);
|
||||||
|
|
||||||
it('should return true for occupied cell', () => {
|
// 8 cats per player (initially in box)
|
||||||
const { ctx } = createTestContext();
|
const whiteCats = Object.values(state.pieces).filter(p => p.player === 'white' && p.type === 'cat');
|
||||||
const state = getState(ctx);
|
const blackCats = Object.values(state.pieces).filter(p => p.player === 'black' && p.type === 'cat');
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
expect(whiteCats.length).toBe(8);
|
||||||
|
expect(blackCats.length).toBe(8);
|
||||||
|
|
||||||
expect(isCellOccupied(state, 3, 3)).toBe(true);
|
// All cats should be in box (regionId = '')
|
||||||
});
|
whiteCats.forEach(cat => expect(cat.regionId).toBe(''));
|
||||||
|
blackCats.forEach(cat => expect(cat.regionId).toBe(''));
|
||||||
|
|
||||||
it('should return false for different cell', () => {
|
// Kittens should be in player supplies
|
||||||
const { ctx } = createTestContext();
|
whiteKittens.forEach(k => expect(k.regionId).toBe('white'));
|
||||||
const state = getState(ctx);
|
blackKittens.forEach(k => expect(k.regionId).toBe('black'));
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
|
|
||||||
expect(isCellOccupied(state, 1, 1)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPartAt', () => {
|
describe('Place and Boop Commands', () => {
|
||||||
it('should return null for empty cell', () => {
|
it('should place a kitten via play command', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
expect(getPartAt(state, 2, 2)).toBeNull();
|
// Use turn command instead of setup which runs indefinitely
|
||||||
});
|
const promptPromise = waitForPrompt(ctx);
|
||||||
|
const runPromise = ctx.run('turn white');
|
||||||
|
|
||||||
it('should return the part at occupied cell', () => {
|
const promptEvent = await promptPromise;
|
||||||
const { ctx } = createTestContext();
|
expect(promptEvent).not.toBeNull();
|
||||||
const state = getState(ctx);
|
expect(promptEvent.schema.name).toBe('play');
|
||||||
placePiece(state, 2, 2, 'black', 'kitten');
|
|
||||||
|
|
||||||
const part = getPartAt(state, 2, 2);
|
// Place a kitten at position 2,2
|
||||||
expect(part).not.toBeNull();
|
const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
|
||||||
if (part) {
|
expect(error).toBeNull();
|
||||||
expect(part.player).toBe('black');
|
|
||||||
expect(part.pieceType).toBe('kitten');
|
const result = await runPromise;
|
||||||
}
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const state = ctx.value;
|
||||||
|
// Should have placed a piece on the board
|
||||||
|
const boardPieces = Object.keys(state.regions.board.partMap);
|
||||||
|
expect(boardPieces.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should have one less kitten in supply
|
||||||
|
const whiteSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'kitten');
|
||||||
|
expect(whiteSupply.length).toBe(7);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('placePiece', () => {
|
describe('Boop Mechanics', () => {
|
||||||
it('should add a kitten to the board', () => {
|
it('should boop adjacent pieces away from placement', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 2, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
const parts = getParts(state);
|
// White places at 2,2
|
||||||
expect(parts.length).toBe(1);
|
let promptPromise = waitForPrompt(ctx);
|
||||||
expect(parts[0].position).toEqual([2, 3]);
|
let runPromise = ctx.run('turn white');
|
||||||
expect(parts[0].player).toBe('white');
|
let promptEvent = await promptPromise;
|
||||||
expect(parts[0].pieceType).toBe('kitten');
|
let error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
|
||||||
|
expect(error).toBeNull();
|
||||||
|
let result = await runPromise;
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// Black places at 2,3, which will boop white's piece
|
||||||
|
promptPromise = waitForPrompt(ctx);
|
||||||
|
runPromise = ctx.run('turn black');
|
||||||
|
promptEvent = await promptPromise;
|
||||||
|
error = promptEvent.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} });
|
||||||
|
expect(error).toBeNull();
|
||||||
|
result = await runPromise;
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const state = ctx.value;
|
||||||
|
// Check that pieces were placed
|
||||||
|
const boardPieceCount = Object.keys(state.regions.board.partMap).length;
|
||||||
|
expect(boardPieceCount).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should name piece white-kitten-1', () => {
|
it('should handle pieces being booped off the board', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[0].id).toBe('white-kitten-1');
|
// White places at corner
|
||||||
});
|
const promptPromise = waitForPrompt(ctx);
|
||||||
|
const runPromise = ctx.run('turn white');
|
||||||
|
const promptEvent = await promptPromise;
|
||||||
|
const error = promptEvent.tryCommit({ name: 'play', params: ['white', 0, 0, 'kitten'], options: {}, flags: {} });
|
||||||
|
expect(error).toBeNull();
|
||||||
|
const result = await runPromise;
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
it('should name piece white-kitten-2 for second white kitten', () => {
|
const state = ctx.value;
|
||||||
const { ctx } = createTestContext();
|
// Verify placement
|
||||||
const state = getState(ctx);
|
expect(state.regions.board.partMap['0,0']).toBeDefined();
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[1].id).toBe('white-kitten-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should name piece white-cat-1', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
|
||||||
|
|
||||||
expect(getParts(state)[0].id).toBe('white-cat-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decrement the correct player kitten supply', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
expect(state.value.players.white.kitten.supply).toBe(7);
|
|
||||||
expect(state.value.players.black.kitten.supply).toBe(8);
|
|
||||||
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
|
||||||
expect(state.value.players.white.kitten.supply).toBe(7);
|
|
||||||
expect(state.value.players.black.kitten.supply).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decrement the correct player cat supply', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.cat.supply = 3;
|
|
||||||
});
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add piece to board region children', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
|
|
||||||
const board = getBoardRegion(state);
|
|
||||||
expect(board.childIds.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate unique IDs for pieces', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
|
||||||
|
|
||||||
const ids = getParts(state).map(p => p.id);
|
|
||||||
expect(new Set(ids).size).toBe(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyBoops', () => {
|
describe('Full Game Flow', () => {
|
||||||
it('should boop adjacent kitten away from placed kitten', () => {
|
it('should play a turn and switch players', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'black', 'kitten');
|
// White's turn - place at 2,2
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
let promptPromise = waitForPrompt(ctx);
|
||||||
|
let runPromise = ctx.run('turn white');
|
||||||
const whitePart = getParts(state)[1];
|
let prompt = await promptPromise;
|
||||||
expect(whitePart.position).toEqual([2, 2]);
|
const error1 = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
|
||||||
|
expect(error1).toBeNull();
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
let result = await runPromise;
|
||||||
|
expect(result.success).toBe(true);
|
||||||
expect(whitePart.position).toEqual([1, 1]);
|
|
||||||
});
|
const stateAfterWhite = ctx.value;
|
||||||
|
// Should have placed a piece
|
||||||
it('should not boop a cat when a kitten is placed', () => {
|
expect(stateAfterWhite.regions.board.partMap['2,2']).toBeDefined();
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'black', 'kitten');
|
|
||||||
const whitePart = getParts(state)[0];
|
|
||||||
whitePart.pieceType = 'cat';
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
|
||||||
|
|
||||||
expect(whitePart.position).toEqual([3, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove piece that is booped off the board', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'black', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 1, 1, 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
expect(getParts(state)[0].player).toBe('black');
|
|
||||||
expect(state.value.players.white.kitten.supply).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not boop piece if target cell is occupied', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 1, 'black', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 0, 1, 'kitten');
|
|
||||||
|
|
||||||
const whitePart = getParts(state).find(p => p.player === 'white');
|
|
||||||
expect(whitePart).toBeDefined();
|
|
||||||
if (whitePart) {
|
|
||||||
expect(whitePart.position).toEqual([1, 1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should boop multiple adjacent pieces', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'black', 'kitten');
|
|
||||||
placePiece(state, 2, 3, 'black', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[1].position).toEqual([1, 1]);
|
|
||||||
expect(getParts(state)[2].position).toEqual([1, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not boop the placed piece itself', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[0].position).toEqual([3, 3]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removePieceFromBoard', () => {
|
|
||||||
it('should remove piece from board children', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
const part = getParts(state)[0];
|
|
||||||
|
|
||||||
removePieceFromBoard(state, part);
|
|
||||||
|
|
||||||
const board = getBoardRegion(state);
|
|
||||||
expect(board.childIds.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkGraduation', () => {
|
|
||||||
it('should return empty array when no kittens in a row', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect horizontal line of 3 kittens', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 1, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
expect(lines[0]).toEqual([[1, 0], [1, 1], [1, 2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect vertical line of 3 kittens', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 2, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
expect(lines[0]).toEqual([[0, 2], [1, 2], [2, 2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect diagonal line of 3 kittens', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
expect(lines[0]).toEqual([[0, 0], [1, 1], [2, 2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect anti-diagonal line of 3 kittens', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 2, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
expect(lines[0]).toEqual([[0, 2], [1, 1], [2, 0]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not detect line with mixed piece types', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
getParts(state)[1].pieceType = 'cat';
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('processGraduation', () => {
|
|
||||||
it('should convert kittens to cats and update supply', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
|
|
||||||
processGraduation(state, 'white', lines);
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(0);
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only graduate pieces on the winning lines', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
processGraduation(state, 'white', lines);
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
expect(getParts(state)[0].position).toEqual([3, 3]);
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hasWinningLine', () => {
|
|
||||||
it('should return false for no line', () => {
|
|
||||||
expect(hasWinningLine([[0, 0], [1, 1], [3, 3]])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for horizontal line', () => {
|
|
||||||
expect(hasWinningLine([[0, 0], [0, 1], [0, 2]])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for vertical line', () => {
|
|
||||||
expect(hasWinningLine([[0, 0], [1, 0], [2, 0]])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for diagonal line', () => {
|
|
||||||
expect(hasWinningLine([[0, 0], [1, 1], [2, 2]])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for anti-diagonal line', () => {
|
|
||||||
expect(hasWinningLine([[2, 0], [1, 1], [0, 2]])).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkWinner', () => {
|
|
||||||
it('should return null for empty board', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
expect(checkWinner(state)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return winner when player has 3 cats in a row', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
|
||||||
placePiece(state, 0, 1, 'white', 'cat');
|
|
||||||
placePiece(state, 0, 2, 'white', 'cat');
|
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('white');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return draw when both players use all pieces', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
placePiece(state, i % 6, Math.floor(i / 6) + (i % 2), 'white', 'kitten');
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
placePiece(state, i % 6, Math.floor(i / 6) + 3 + (i % 2), 'black', 'kitten');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = checkWinner(state);
|
|
||||||
expect(result === 'draw' || result === null).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Boop - game flow', () => {
|
|
||||||
it('should have setup and turn commands registered', () => {
|
|
||||||
const { registry: reg } = createTestContext();
|
|
||||||
|
|
||||||
expect(reg.has('setup')).toBe(true);
|
|
||||||
expect(reg.has('turn')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should setup board when setup command runs', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run('setup');
|
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
|
||||||
expect(promptEvent).not.toBeNull();
|
|
||||||
expect(promptEvent.schema.name).toBe('play');
|
|
||||||
|
|
||||||
promptEvent.cancel('test end');
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept valid move via turn command', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
|
||||||
expect(promptEvent).not.toBeNull();
|
|
||||||
expect(promptEvent.schema.name).toBe('play');
|
|
||||||
|
|
||||||
const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
|
|
||||||
expect(error).toBeNull();
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
|
||||||
expect(getParts(ctx.state).length).toBe(1);
|
|
||||||
expect(getParts(ctx.state)[0].position).toEqual([2, 2]);
|
|
||||||
expect(getParts(ctx.state)[0].id).toBe('white-kitten-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject move for wrong player and re-prompt', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent1 = await promptPromise;
|
|
||||||
// 验证器会拒绝错误的玩家
|
|
||||||
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} });
|
|
||||||
expect(error1).toContain('Invalid player');
|
|
||||||
|
|
||||||
// 验证失败后,再次尝试有效输入
|
|
||||||
const error2 = promptEvent1.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
|
|
||||||
expect(error2).toBeNull();
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject move to occupied cell and re-prompt', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 2, 2, 'black', 'kitten');
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent1 = await promptPromise;
|
|
||||||
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
|
|
||||||
expect(error1).toContain('occupied');
|
|
||||||
|
|
||||||
// 验证失败后,再次尝试有效输入
|
|
||||||
const error2 = promptEvent1.tryCommit({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} });
|
|
||||||
expect(error2).toBeNull();
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject move when kitten supply is empty', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.kitten.supply = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent1 = await promptPromise;
|
|
||||||
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} });
|
|
||||||
expect(error1).toContain('No kittens');
|
|
||||||
|
|
||||||
// 验证失败后,取消
|
|
||||||
promptEvent1.cancel('test end');
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should boop adjacent pieces after placement', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
let promptPromise = waitForPrompt(ctx);
|
|
||||||
let runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
let prompt = await promptPromise;
|
|
||||||
const error1 = prompt.tryCommit({ name: 'play', params: ['white', 3, 3], options: {}, flags: {} });
|
|
||||||
expect(error1).toBeNull();
|
|
||||||
let result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
|
|
||||||
promptPromise = waitForPrompt(ctx);
|
|
||||||
runPromise = ctx.commands.run<{winner: WinnerType}>('turn black');
|
|
||||||
prompt = await promptPromise;
|
|
||||||
const error2 = prompt.tryCommit({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} });
|
|
||||||
expect(error2).toBeNull();
|
|
||||||
result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(getParts(state).length).toBe(2);
|
|
||||||
|
|
||||||
const whitePart = getParts(state).find(p => p.player === 'white');
|
|
||||||
expect(whitePart).toBeDefined();
|
|
||||||
if (whitePart) {
|
|
||||||
expect(whitePart.position).not.toEqual([3, 3]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should graduate kittens to cats and check for cat win', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 1, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
processGraduation(state, 'white', lines);
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(0);
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept placing a cat via play command', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.cat.supply = 3;
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
|
||||||
const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
|
|
||||||
expect(error).toBeNull();
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
expect(getParts(state)[0].id).toBe('white-cat-1');
|
|
||||||
expect(getParts(state)[0].pieceType).toBe('cat');
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject placing a cat when supply is empty', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.cat.supply = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent1 = await promptPromise;
|
|
||||||
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['white', 0, 0, 'cat'], options: {}, flags: {} });
|
|
||||||
expect(error1).toContain('No cats');
|
|
||||||
|
|
||||||
// 验证失败后,取消
|
|
||||||
promptEvent1.cancel('test end');
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import {
|
||||||
placePiece,
|
placePiece,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
TicTacToeState,
|
TicTacToeState,
|
||||||
WinnerType, PlayerType
|
TicTacToeGame,
|
||||||
|
WinnerType,
|
||||||
|
PlayerType
|
||||||
} from '@/samples/tic-tac-toe';
|
} from '@/samples/tic-tac-toe';
|
||||||
import {MutableSignal} from "@/utils/mutable-signal";
|
import { createGameContext } from '@/index';
|
||||||
import {createGameContext} from "@/";
|
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import type { PromptEvent } from '@/utils/command';
|
||||||
|
|
||||||
function createTestContext() {
|
function createTestContext() {
|
||||||
|
|
@ -17,13 +18,9 @@ function createTestContext() {
|
||||||
return { registry, ctx };
|
return { registry, ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<TicTacToeState> {
|
|
||||||
return ctx.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
|
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
ctx.commands.on('prompt', resolve);
|
ctx._commands.on('prompt', resolve);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,92 +28,84 @@ describe('TicTacToe - helper functions', () => {
|
||||||
describe('checkWinner', () => {
|
describe('checkWinner', () => {
|
||||||
it('should return null for empty board', () => {
|
it('should return null for empty board', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
expect(checkWinner(state)).toBeNull();
|
expect(checkWinner(ctx)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect horizontal win for X', () => {
|
it('should detect horizontal win for X', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 1, 0, 'O');
|
placePiece(ctx, 1, 0, 'O');
|
||||||
placePiece(state, 0, 1, 'X');
|
placePiece(ctx, 0, 1, 'X');
|
||||||
placePiece(state, 1, 1, 'O');
|
placePiece(ctx, 1, 1, 'O');
|
||||||
placePiece(state, 0, 2, 'X');
|
placePiece(ctx, 0, 2, 'X');
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('X');
|
expect(checkWinner(ctx)).toBe('X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect horizontal win for O', () => {
|
it('should detect horizontal win for O', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 2, 0, 'X');
|
placePiece(ctx, 2, 0, 'X');
|
||||||
placePiece(state, 1, 0, 'O');
|
placePiece(ctx, 1, 0, 'O');
|
||||||
placePiece(state, 2, 1, 'X');
|
placePiece(ctx, 2, 1, 'X');
|
||||||
placePiece(state, 1, 1, 'O');
|
placePiece(ctx, 1, 1, 'O');
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 1, 2, 'O');
|
placePiece(ctx, 1, 2, 'O');
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('O');
|
expect(checkWinner(ctx)).toBe('O');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect vertical win', () => {
|
it('should detect vertical win', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 0, 1, 'O');
|
placePiece(ctx, 0, 1, 'O');
|
||||||
placePiece(state, 1, 0, 'X');
|
placePiece(ctx, 1, 0, 'X');
|
||||||
placePiece(state, 1, 1, 'O');
|
placePiece(ctx, 1, 1, 'O');
|
||||||
placePiece(state, 2, 0, 'X');
|
placePiece(ctx, 2, 0, 'X');
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('X');
|
expect(checkWinner(ctx)).toBe('X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect diagonal win (top-left to bottom-right)', () => {
|
it('should detect diagonal win (top-left to bottom-right)', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 0, 1, 'O');
|
placePiece(ctx, 0, 1, 'O');
|
||||||
placePiece(state, 1, 1, 'X');
|
placePiece(ctx, 1, 1, 'X');
|
||||||
placePiece(state, 0, 2, 'O');
|
placePiece(ctx, 0, 2, 'O');
|
||||||
placePiece(state, 2, 2, 'X');
|
placePiece(ctx, 2, 2, 'X');
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('X');
|
expect(checkWinner(ctx)).toBe('X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect diagonal win (top-right to bottom-left)', () => {
|
it('should detect diagonal win (top-right to bottom-left)', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 0, 2, 'O');
|
placePiece(ctx, 0, 2, 'O');
|
||||||
placePiece(state, 1, 0, 'X');
|
placePiece(ctx, 1, 0, 'X');
|
||||||
placePiece(state, 1, 1, 'O');
|
placePiece(ctx, 1, 1, 'O');
|
||||||
placePiece(state, 1, 2, 'X');
|
placePiece(ctx, 1, 2, 'X');
|
||||||
placePiece(state, 2, 0, 'O');
|
placePiece(ctx, 2, 0, 'O');
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('O');
|
expect(checkWinner(ctx)).toBe('O');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null for no winner', () => {
|
it('should return null for no winner', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 0, 1, 'O');
|
placePiece(ctx, 0, 1, 'O');
|
||||||
placePiece(state, 1, 2, 'X');
|
placePiece(ctx, 1, 2, 'X');
|
||||||
|
|
||||||
expect(checkWinner(state)).toBeNull();
|
expect(checkWinner(ctx)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return draw when board is full with no winner', () => {
|
it('should return draw when board is full with no winner', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
const drawPositions = [
|
const drawPositions = [
|
||||||
[0, 0, 'X'], [0, 1, 'O'], [0, 2, 'X'],
|
[0, 0, 'X'], [0, 1, 'O'], [0, 2, 'X'],
|
||||||
|
|
@ -124,66 +113,60 @@ describe('TicTacToe - helper functions', () => {
|
||||||
[2, 0, 'O'], [2, 1, 'X'], [2, 2, 'X'],
|
[2, 0, 'O'], [2, 1, 'X'], [2, 2, 'X'],
|
||||||
] as [number, number, PlayerType][];
|
] as [number, number, PlayerType][];
|
||||||
|
|
||||||
drawPositions.forEach(([r, c, p], i) => {
|
drawPositions.forEach(([r, c, p]) => {
|
||||||
placePiece(state, r, c, p);
|
placePiece(ctx, r, c, p);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(checkWinner(state)).toBe('draw');
|
expect(checkWinner(ctx)).toBe('draw');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isCellOccupied', () => {
|
describe('isCellOccupied', () => {
|
||||||
it('should return false for empty cell', () => {
|
it('should return false for empty cell', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
expect(isCellOccupied(state, 1, 1)).toBe(false);
|
expect(isCellOccupied(ctx, 1, 1)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for occupied cell', () => {
|
it('should return true for occupied cell', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
placePiece(ctx, 1, 1, 'X');
|
||||||
placePiece(state, 1, 1, 'X');
|
|
||||||
|
|
||||||
expect(isCellOccupied(state, 1, 1)).toBe(true);
|
expect(isCellOccupied(ctx, 1, 1)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for different cell', () => {
|
it('should return false for different cell', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 0, 0, 'X');
|
|
||||||
|
|
||||||
expect(isCellOccupied(state, 1, 1)).toBe(false);
|
expect(isCellOccupied(ctx, 1, 1)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('placePiece', () => {
|
describe('placePiece', () => {
|
||||||
it('should add a piece to the board', () => {
|
it('should add a piece to the board', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
placePiece(ctx, 1, 1, 'X');
|
||||||
placePiece(state, 1, 1, 'X');
|
|
||||||
|
|
||||||
expect(Object.keys(state.value.parts).length).toBe(1);
|
expect(Object.keys(ctx.value.parts).length).toBe(1);
|
||||||
expect(state.value.parts['piece-X-1']!.position).toEqual([1, 1]);
|
expect(ctx.value.parts['piece-X-1']!.position).toEqual([1, 1]);
|
||||||
expect(state.value.parts['piece-X-1']!.player).toBe('X');
|
expect(ctx.value.parts['piece-X-1']!.player).toBe('X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add piece to board region children', () => {
|
it('should add piece to board region children', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
placePiece(ctx, 0, 0, 'O');
|
||||||
placePiece(state, 0, 0, 'O');
|
|
||||||
|
|
||||||
const board = state.value.board;
|
const board = ctx.value.board;
|
||||||
expect(board.childIds.length).toBe(1);
|
expect(board.childIds.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate unique IDs for pieces', () => {
|
it('should generate unique IDs for pieces', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
placePiece(ctx, 0, 0, 'X');
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(ctx, 0, 1, 'O');
|
||||||
placePiece(state, 0, 1, 'O');
|
|
||||||
|
|
||||||
const ids = Object.keys(state.value.parts);
|
const ids = Object.keys(ctx.value.parts);
|
||||||
expect(new Set(ids).size).toBe(2);
|
expect(new Set(ids).size).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -201,7 +184,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run('setup');
|
const runPromise = ctx.run('setup');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
expect(promptEvent).not.toBeNull();
|
expect(promptEvent).not.toBeNull();
|
||||||
|
|
@ -217,7 +200,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
|
const runPromise = ctx.run<{winner: WinnerType}>('turn X 1');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
expect(promptEvent).not.toBeNull();
|
expect(promptEvent).not.toBeNull();
|
||||||
|
|
@ -229,22 +212,22 @@ describe('TicTacToe - game flow', () => {
|
||||||
const result = await runPromise;
|
const result = await runPromise;
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
expect(Object.keys(ctx.state.value.parts).length).toBe(1);
|
expect(Object.keys(ctx.value.parts).length).toBe(1);
|
||||||
expect(ctx.state.value.parts['piece-X-1']!.position).toEqual([1, 1]);
|
expect(ctx.value.parts['piece-X-1']!.position).toEqual([1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject move for wrong player and re-prompt', async () => {
|
it('should reject move for wrong player and re-prompt', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
|
const runPromise = ctx.run<{winner: WinnerType}>('turn X 1');
|
||||||
|
|
||||||
const promptEvent1 = await promptPromise;
|
const promptEvent1 = await promptPromise;
|
||||||
// 验证器会拒绝错误的玩家
|
// 验证器会拒绝错误的玩家
|
||||||
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} });
|
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} });
|
||||||
expect(error1).toContain('Invalid player');
|
expect(error1).toContain('Invalid player');
|
||||||
|
|
||||||
// 验证失败后,再次尝试有效输入
|
// 验证失败后,再次尝试有效输入
|
||||||
const error2 = promptEvent1.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
const error2 = promptEvent1.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||||||
expect(error2).toBeNull();
|
expect(error2).toBeNull();
|
||||||
|
|
||||||
|
|
@ -255,18 +238,17 @@ describe('TicTacToe - game flow', () => {
|
||||||
|
|
||||||
it('should reject move to occupied cell and re-prompt', async () => {
|
it('should reject move to occupied cell and re-prompt', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 1, 1, 'O');
|
placePiece(ctx, 1, 1, 'O');
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
|
const runPromise = ctx.run<{winner: WinnerType}>('turn X 1');
|
||||||
|
|
||||||
const promptEvent1 = await promptPromise;
|
const promptEvent1 = await promptPromise;
|
||||||
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||||||
expect(error1).toContain('occupied');
|
expect(error1).toContain('occupied');
|
||||||
|
|
||||||
// 验证失败后,再次尝试有效输入
|
// 验证失败后,再次尝试有效输入
|
||||||
const error2 = promptEvent1.tryCommit({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
|
const error2 = promptEvent1.tryCommit({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
|
||||||
expect(error2).toBeNull();
|
expect(error2).toBeNull();
|
||||||
|
|
||||||
|
|
@ -279,7 +261,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
let promptPromise = waitForPrompt(ctx);
|
let promptPromise = waitForPrompt(ctx);
|
||||||
let runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
|
let runPromise = ctx.run<{winner: WinnerType}>('turn X 1');
|
||||||
let prompt = await promptPromise;
|
let prompt = await promptPromise;
|
||||||
const error1 = prompt.tryCommit({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
|
const error1 = prompt.tryCommit({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
|
||||||
expect(error1).toBeNull();
|
expect(error1).toBeNull();
|
||||||
|
|
@ -288,7 +270,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
|
|
||||||
promptPromise = waitForPrompt(ctx);
|
promptPromise = waitForPrompt(ctx);
|
||||||
runPromise = ctx.commands.run('turn O 2');
|
runPromise = ctx.run('turn O 2');
|
||||||
prompt = await promptPromise;
|
prompt = await promptPromise;
|
||||||
const error2 = prompt.tryCommit({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} });
|
const error2 = prompt.tryCommit({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} });
|
||||||
expect(error2).toBeNull();
|
expect(error2).toBeNull();
|
||||||
|
|
@ -297,7 +279,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
|
|
||||||
promptPromise = waitForPrompt(ctx);
|
promptPromise = waitForPrompt(ctx);
|
||||||
runPromise = ctx.commands.run('turn X 3');
|
runPromise = ctx.run('turn X 3');
|
||||||
prompt = await promptPromise;
|
prompt = await promptPromise;
|
||||||
const error3 = prompt.tryCommit({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} });
|
const error3 = prompt.tryCommit({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} });
|
||||||
expect(error3).toBeNull();
|
expect(error3).toBeNull();
|
||||||
|
|
@ -306,7 +288,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
|
|
||||||
promptPromise = waitForPrompt(ctx);
|
promptPromise = waitForPrompt(ctx);
|
||||||
runPromise = ctx.commands.run('turn O 4');
|
runPromise = ctx.run('turn O 4');
|
||||||
prompt = await promptPromise;
|
prompt = await promptPromise;
|
||||||
const error4 = prompt.tryCommit({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} });
|
const error4 = prompt.tryCommit({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} });
|
||||||
expect(error4).toBeNull();
|
expect(error4).toBeNull();
|
||||||
|
|
@ -315,7 +297,7 @@ describe('TicTacToe - game flow', () => {
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
|
|
||||||
promptPromise = waitForPrompt(ctx);
|
promptPromise = waitForPrompt(ctx);
|
||||||
runPromise = ctx.commands.run('turn X 5');
|
runPromise = ctx.run('turn X 5');
|
||||||
prompt = await promptPromise;
|
prompt = await promptPromise;
|
||||||
const error5 = prompt.tryCommit({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} });
|
const error5 = prompt.tryCommit({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} });
|
||||||
expect(error5).toBeNull();
|
expect(error5).toBeNull();
|
||||||
|
|
@ -326,7 +308,6 @@ describe('TicTacToe - game flow', () => {
|
||||||
|
|
||||||
it('should detect draw after 9 moves', async () => {
|
it('should detect draw after 9 moves', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
const pieces = [
|
const pieces = [
|
||||||
{ id: 'p1', pos: [0, 0], player: 'X' },
|
{ id: 'p1', pos: [0, 0], player: 'X' },
|
||||||
|
|
@ -339,14 +320,14 @@ describe('TicTacToe - game flow', () => {
|
||||||
{ id: 'p8', pos: [1, 2], player: 'O' },
|
{ id: 'p8', pos: [1, 2], player: 'O' },
|
||||||
] as { id: string, pos: [number, number], player: PlayerType}[];
|
] as { id: string, pos: [number, number], player: PlayerType}[];
|
||||||
|
|
||||||
for (const { id, pos, player } of pieces) {
|
for (const { pos, player } of pieces) {
|
||||||
placePiece(state, pos[0], pos[1], player);
|
placePiece(ctx, pos[0], pos[1], player);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(checkWinner(state)).toBeNull();
|
expect(checkWinner(ctx)).toBeNull();
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 9');
|
const runPromise = ctx.run<{winner: WinnerType}>('turn X 9');
|
||||||
const prompt = await promptPromise;
|
const prompt = await promptPromise;
|
||||||
const error = prompt.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
const error = prompt.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||||||
expect(error).toBeNull();
|
expect(error).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { csvLoader } from 'inline-schema/csv-loader/rollup';
|
||||||
|
|
||||||
const srcDir = fileURLToPath(new URL('./src', import.meta.url));
|
const srcDir = fileURLToPath(new URL('./src', import.meta.url));
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ export default defineConfig({
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
plugins: [csvLoader()],
|
||||||
esbuildOptions(options) {
|
esbuildOptions(options) {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
'@': srcDir,
|
'@': srcDir,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import {csvLoader} from 'inline-schema/csv-loader/rollup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
|
@ -6,6 +7,9 @@ export default defineConfig({
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
|
plugins: [csvLoader({
|
||||||
|
writeToDisk: true
|
||||||
|
})],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@/': new URL('./src/', import.meta.url).pathname,
|
'@/': new URL('./src/', import.meta.url).pathname,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue