refactor: even tighter api
This commit is contained in:
parent
b2b35c3a99
commit
e945d28fc3
|
|
@ -1,45 +1,33 @@
|
||||||
import {createEntityCollection, entity, Entity, EntityCollection} from "../utils/entity";
|
import {entity, Entity} from "../utils/entity";
|
||||||
import {Part} from "./part";
|
|
||||||
import {Region} from "./region";
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
CommandRunnerContext,
|
CommandRunnerContext,
|
||||||
CommandRunnerContextExport, CommandSchema, createCommandRegistry,
|
CommandRunnerContextExport,
|
||||||
createCommandRunnerContext, parseCommandSchema,
|
CommandSchema,
|
||||||
PromptEvent, registerCommand
|
createCommandRegistry,
|
||||||
|
createCommandRunnerContext,
|
||||||
|
parseCommandSchema,
|
||||||
|
registerCommand
|
||||||
} from "../utils/command";
|
} from "../utils/command";
|
||||||
import {AsyncQueue} from "../utils/async-queue";
|
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
||||||
parts: EntityCollection<Part>;
|
|
||||||
regions: EntityCollection<Region>;
|
|
||||||
state: Entity<TState>;
|
state: Entity<TState>;
|
||||||
commands: CommandRunnerContextExport<IGameContext<TState>>;
|
commands: CommandRunnerContextExport<Entity<TState>>;
|
||||||
prompts: AsyncQueue<PromptEvent>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
commandRegistry: CommandRegistry<IGameContext<TState>>,
|
commandRegistry: CommandRegistry<Entity<TState>>,
|
||||||
initialState?: TState | (() => TState)
|
initialState?: TState | (() => TState)
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
const parts = createEntityCollection<Part>();
|
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
||||||
const regions = createEntityCollection<Region>();
|
const state = entity('state', stateValue);
|
||||||
const prompts = new AsyncQueue<PromptEvent>();
|
const commands = createCommandRunnerContext(commandRegistry, state);
|
||||||
const state = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
|
||||||
|
|
||||||
const ctx = {
|
return {
|
||||||
parts,
|
state,
|
||||||
regions,
|
commands
|
||||||
prompts,
|
};
|
||||||
commands: null!,
|
|
||||||
state: entity('gameState', state),
|
|
||||||
} as IGameContext<TState>
|
|
||||||
|
|
||||||
ctx.commands = createCommandRunnerContext(commandRegistry, ctx);
|
|
||||||
ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.prompts.push(prompt));
|
|
||||||
|
|
||||||
return ctx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -48,21 +36,31 @@ 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<IGameContext<TState>>,
|
registry: CommandRegistry<Entity<TState>>,
|
||||||
createInitialState: () => TState
|
createInitialState: () => TState
|
||||||
},
|
},
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
return createGameContext(module.registry, module.createInitialState);
|
return createGameContext(module.registry, module.createInitialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >(): CommandRegistry<IGameContext<TState>> {
|
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
|
||||||
return createCommandRegistry<IGameContext<TState>>();
|
const registry = createCommandRegistry<Entity<TState>>();
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
add<TResult = unknown>(
|
||||||
|
schema: CommandSchema | string,
|
||||||
|
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
|
||||||
|
){
|
||||||
|
createGameCommand(registry, schema, run);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
||||||
registry: CommandRegistry<IGameContext<TState>>,
|
registry: CommandRegistry<Entity<TState>>,
|
||||||
schema: CommandSchema | string,
|
schema: CommandSchema | string,
|
||||||
run: (this: CommandRunnerContext<IGameContext<TState>>, command: Command) => Promise<TResult>
|
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
|
||||||
) {
|
) {
|
||||||
registerCommand(registry, {
|
registerCommand(registry, {
|
||||||
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
|
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,143 @@
|
||||||
import {createGameCommand, createGameCommandRegistry, IGameContext} from '../core/game';
|
import {createGameCommandRegistry} from '../core/game';
|
||||||
import type { Part } from '../core/part';
|
import type { Part } from '../core/part';
|
||||||
|
import {Entity, entity} from "../utils/entity";
|
||||||
|
import {Region} from "../core/region";
|
||||||
|
|
||||||
|
const BOARD_SIZE = 3;
|
||||||
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
|
const WINNING_LINES: number[][][] = [
|
||||||
|
[[0, 0], [0, 1], [0, 2]],
|
||||||
|
[[1, 0], [1, 1], [1, 2]],
|
||||||
|
[[2, 0], [2, 1], [2, 2]],
|
||||||
|
[[0, 0], [1, 0], [2, 0]],
|
||||||
|
[[0, 1], [1, 1], [2, 1]],
|
||||||
|
[[0, 2], [1, 2], [2, 2]],
|
||||||
|
[[0, 0], [1, 1], [2, 2]],
|
||||||
|
[[0, 2], [1, 1], [2, 0]],
|
||||||
|
];
|
||||||
|
|
||||||
type PlayerType = 'X' | 'O';
|
type PlayerType = 'X' | 'O';
|
||||||
type WinnerType = 'X' | 'O' | 'draw' | null;
|
type WinnerType = PlayerType | 'draw' | null;
|
||||||
|
|
||||||
|
type TicTacToePart = Part & { player: PlayerType };
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
currentPlayer: 'O' as PlayerType,
|
board: entity<Region>('board', {
|
||||||
|
id: 'board',
|
||||||
|
axes: [
|
||||||
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
],
|
||||||
|
children: [],
|
||||||
|
}),
|
||||||
|
parts: [] as Entity<TicTacToePart>[],
|
||||||
|
currentPlayer: 'X' as PlayerType,
|
||||||
winner: null as WinnerType,
|
winner: null as WinnerType,
|
||||||
turn: 0,
|
turn: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
export const registry = createGameCommandRegistry<TicTacToeState>();
|
const registration = createGameCommandRegistry<TicTacToeState>();
|
||||||
|
export const registry = registration.registry;
|
||||||
createGameCommand(registry, 'setup', async function() {
|
|
||||||
const {regions, state} = this.context;
|
|
||||||
regions.add({
|
|
||||||
id: 'board',
|
|
||||||
axes: [
|
|
||||||
{ name: 'x', min: 0, max: 2 },
|
|
||||||
{ name: 'y', min: 0, max: 2 },
|
|
||||||
],
|
|
||||||
children: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
|
registration.add('setup', async function() {
|
||||||
|
const {context} = this;
|
||||||
while (true) {
|
while (true) {
|
||||||
let player = 'X' as PlayerType;
|
const currentPlayer = context.value.currentPlayer;
|
||||||
let turn = 0;
|
const turnNumber = context.value.turn + 1;
|
||||||
state.produce(state => {
|
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer} ${turnNumber}`);
|
||||||
player = state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
|
||||||
turn = ++state.turn;
|
|
||||||
});
|
|
||||||
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${player} ${turn}`);
|
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
if (!turnOutput.success) throw new Error(turnOutput.error);
|
||||||
|
|
||||||
state.produce(state => {
|
context.produce(state => {
|
||||||
state.winner = turnOutput.result.winner;
|
state.winner = turnOutput.result.winner;
|
||||||
|
if (!state.winner) {
|
||||||
|
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
||||||
|
state.turn = turnNumber;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (state.value.winner) break;
|
if (context.value.winner) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.value;
|
return context.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
createGameCommand(registry, 'turn <player> <turn:number>', async function(cmd) {
|
registration.add('turn <player> <turn:number>', async function(cmd) {
|
||||||
const [turnPlayer, turnNumber] = cmd.params as [string, number];
|
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
||||||
while (true) {
|
const maxRetries = MAX_TURNS * 2;
|
||||||
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
|
let retries = 0;
|
||||||
const [player, row, col] = playCmd.params as [string, number, number];
|
|
||||||
if(turnPlayer !== player) continue;
|
|
||||||
|
|
||||||
if (isNaN(row) || isNaN(col) || row < 0 || row > 2 || col < 0 || col > 2) continue;
|
while (retries < maxRetries) {
|
||||||
|
retries++;
|
||||||
|
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
|
||||||
|
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
||||||
|
|
||||||
|
if (player !== turnPlayer) continue;
|
||||||
|
if (!isValidMove(row, col)) continue;
|
||||||
if (isCellOccupied(this.context, row, col)) continue;
|
if (isCellOccupied(this.context, row, col)) continue;
|
||||||
|
|
||||||
placePiece(this.context, row, col, turnNumber);
|
placePiece(this.context, row, col, turnPlayer);
|
||||||
|
|
||||||
const winner = checkWinner(this.context);
|
const winner = checkWinner(this.context);
|
||||||
if (winner) return { winner : winner as WinnerType };
|
if (winner) return { winner };
|
||||||
|
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
||||||
|
|
||||||
if (turnNumber >= 9) return { winner: 'draw' as WinnerType};
|
return { winner: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error('Too many invalid attempts');
|
||||||
});
|
});
|
||||||
|
|
||||||
export function getBoardRegion(host: IGameContext<TicTacToeState>) {
|
function isValidMove(row: number, col: number): boolean {
|
||||||
return host.regions.get('board');
|
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: IGameContext<TicTacToeState>, row: number, col: number): boolean {
|
export function getBoardRegion(host: Entity<TicTacToeState>) {
|
||||||
|
return host.value.board;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
return board.value.children.some(
|
return board.value.children.some(
|
||||||
part => {
|
part => part.value.position[0] === row && part.value.position[1] === col
|
||||||
return part.value.position[0] === row && part.value.position[1] === col;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
const lines = [
|
return WINNING_LINES.some(line =>
|
||||||
[[0, 0], [0, 1], [0, 2]],
|
|
||||||
[[1, 0], [1, 1], [1, 2]],
|
|
||||||
[[2, 0], [2, 1], [2, 2]],
|
|
||||||
[[0, 0], [1, 0], [2, 0]],
|
|
||||||
[[0, 1], [1, 1], [2, 1]],
|
|
||||||
[[0, 2], [1, 2], [2, 2]],
|
|
||||||
[[0, 0], [1, 1], [2, 2]],
|
|
||||||
[[0, 2], [1, 1], [2, 0]],
|
|
||||||
];
|
|
||||||
|
|
||||||
return lines.some(line =>
|
|
||||||
line.every(([r, c]) =>
|
line.every(([r, c]) =>
|
||||||
positions.some(([pr, pc]) => pr === r && pc === c)
|
positions.some(([pr, pc]) => pr === r && pc === c)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkWinner(host: IGameContext<TicTacToeState>): 'X' | 'O' | 'draw' | null {
|
export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
|
||||||
const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value);
|
const parts = host.value.parts.map((e: Entity<TicTacToePart>) => e.value);
|
||||||
|
|
||||||
const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position);
|
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
||||||
const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position);
|
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
||||||
|
|
||||||
if (hasWinningLine(xPositions)) return 'X';
|
if (hasWinningLine(xPositions)) return 'X';
|
||||||
if (hasWinningLine(oPositions)) return 'O';
|
if (hasWinningLine(oPositions)) return 'O';
|
||||||
|
if (parts.length >= MAX_TURNS) return 'draw';
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: IGameContext<TicTacToeState>, row: number, col: number, moveCount: number) {
|
export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
const piece: Part = {
|
const moveNumber = host.value.parts.length + 1;
|
||||||
id: `piece-${moveCount}`,
|
const piece: TicTacToePart = {
|
||||||
|
id: `piece-${player}-${moveNumber}`,
|
||||||
region: board,
|
region: board,
|
||||||
position: [row, col],
|
position: [row, col],
|
||||||
|
player,
|
||||||
};
|
};
|
||||||
host.parts.add(piece);
|
host.produce(state => {
|
||||||
board.produce(draft => {
|
const e = entity(piece.id, piece)
|
||||||
draft.children.push(host.parts.get(piece.id));
|
state.parts.push(e);
|
||||||
|
board.produce(draft => {
|
||||||
|
draft.children.push(e);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} fr
|
||||||
import { parseCommand } from './command-parse.js';
|
import { parseCommand } from './command-parse.js';
|
||||||
import { applyCommandSchema } from './command-validate.js';
|
import { applyCommandSchema } from './command-validate.js';
|
||||||
import { parseCommandSchema } from './schema-parse.js';
|
import { parseCommandSchema } from './schema-parse.js';
|
||||||
|
import {AsyncQueue} from "../async-queue";
|
||||||
|
|
||||||
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ type Listener = (e: PromptEvent) => void;
|
||||||
|
|
||||||
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
|
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
|
||||||
registry: CommandRegistry<TContext>;
|
registry: CommandRegistry<TContext>;
|
||||||
|
promptQueue: AsyncQueue<PromptEvent>;
|
||||||
_activePrompt: PromptEvent | null;
|
_activePrompt: PromptEvent | null;
|
||||||
_resolvePrompt: (command: Command) => void;
|
_resolvePrompt: (command: Command) => void;
|
||||||
_rejectPrompt: (error: Error) => void;
|
_rejectPrompt: (error: Error) => void;
|
||||||
|
|
@ -101,11 +103,25 @@ export function createCommandRunnerContext<TContext>(
|
||||||
_resolvePrompt: resolvePrompt,
|
_resolvePrompt: resolvePrompt,
|
||||||
_rejectPrompt: rejectPrompt,
|
_rejectPrompt: rejectPrompt,
|
||||||
_pendingInput: null,
|
_pendingInput: null,
|
||||||
|
promptQueue: null!
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.defineProperty(runnerCtx, '_activePrompt', {
|
Object.defineProperty(runnerCtx, '_activePrompt', {
|
||||||
get: () => activePrompt,
|
get: () => activePrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let promptQueue: AsyncQueue<PromptEvent>;
|
||||||
|
Object.defineProperty(runnerCtx, 'promptQueue', {
|
||||||
|
get(){
|
||||||
|
if (!promptQueue) {
|
||||||
|
promptQueue = new AsyncQueue();
|
||||||
|
listeners.add(async (event) => {
|
||||||
|
promptQueue.push(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return promptQueue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return runnerCtx;
|
return runnerCtx;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue