refactor: entity -> MutableSignal

This commit is contained in:
hypercross 2026-04-03 14:17:36 +08:00
parent b1b059de8c
commit 86714e7837
8 changed files with 51 additions and 62 deletions

View File

@ -1,4 +1,4 @@
import {entity, Entity} from "@/utils/entity"; import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
import { import {
Command, Command,
CommandRegistry, CommandRegistry,
@ -12,16 +12,16 @@ import {
} from "@/utils/command"; } from "@/utils/command";
export interface IGameContext<TState extends Record<string, unknown> = {} > { export interface IGameContext<TState extends Record<string, unknown> = {} > {
state: Entity<TState>; state: MutableSignal<TState>;
commands: CommandRunnerContextExport<Entity<TState>>; commands: CommandRunnerContextExport<MutableSignal<TState>>;
} }
export function createGameContext<TState extends Record<string, unknown> = {} >( export function createGameContext<TState extends Record<string, unknown> = {} >(
commandRegistry: CommandRegistry<Entity<TState>>, commandRegistry: CommandRegistry<MutableSignal<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 = entity('state', stateValue); const state = mutableSignal(stateValue);
const commands = createCommandRunnerContext(commandRegistry, state); const commands = createCommandRunnerContext(commandRegistry, state);
return { return {
@ -36,7 +36,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<Entity<TState>>, registry: CommandRegistry<MutableSignal<TState>>,
createInitialState: () => TState createInitialState: () => TState
}, },
): IGameContext<TState> { ): IGameContext<TState> {
@ -44,12 +44,12 @@ 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<Entity<TState>>(); const registry = createCommandRegistry<MutableSignal<TState>>();
return { return {
registry, registry,
add<TResult = unknown>( add<TResult = unknown>(
schema: CommandSchema | string, schema: CommandSchema | string,
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult> run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
){ ){
createGameCommand(registry, schema, run); createGameCommand(registry, schema, run);
return this; return this;
@ -58,9 +58,9 @@ export function createGameCommandRegistry<TState extends Record<string, unknown>
} }
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>( export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
registry: CommandRegistry<Entity<TState>>, registry: CommandRegistry<MutableSignal<TState>>,
schema: CommandSchema | string, schema: CommandSchema | string,
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult> run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
) { ) {
registerCommand(registry, { registerCommand(registry, {
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,

View File

@ -20,8 +20,8 @@ export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSche
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './utils/command'; export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './utils/command';
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command'; export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command';
export type { Entity } from './utils/entity'; export type { MutableSignal } from './utils/mutable-signal';
export { createEntityCollection, entity } from './utils/entity'; export { mutableSignal, createEntityCollection } from './utils/mutable-signal';
export type { RNG } from './utils/rng'; export type { RNG } from './utils/rng';
export { createRNG, Mulberry32RNG } from './utils/rng'; export { createRNG, Mulberry32RNG } from './utils/rng';

View File

@ -1,4 +1,4 @@
import {createGameCommandRegistry, Part, Entity, createRegion} from '@/index'; import {createGameCommandRegistry, Part, MutableSignal, createRegion} from '@/index';
const BOARD_SIZE = 6; const BOARD_SIZE = 6;
const MAX_PIECES_PER_PLAYER = 8; const MAX_PIECES_PER_PLAYER = 8;
@ -48,7 +48,7 @@ export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>(); const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry; export const registry = registration.registry;
export function getPlayer(host: Entity<BoopState>, player: PlayerType): Player { export function getPlayer(host: MutableSignal<BoopState>, player: PlayerType): Player {
return host.value.players[player]; return host.value.players[player];
} }
@ -152,23 +152,23 @@ 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 getBoardRegion(host: Entity<BoopState>) { export function getBoardRegion(host: MutableSignal<BoopState>) {
return host.value.board; return host.value.board;
} }
export function isCellOccupied(host: Entity<BoopState>, row: number, col: number): boolean { export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
const board = getBoardRegion(host); const board = getBoardRegion(host);
return board.partMap[`${row},${col}`] !== undefined; return board.partMap[`${row},${col}`] !== undefined;
} }
export function getPartAt(host: Entity<BoopState>, row: number, col: number): BoopPart | null { export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const partId = board.partMap[`${row},${col}`]; const partId = board.partMap[`${row},${col}`];
if (!partId) return null; if (!partId) return null;
return host.value.pieces.find(p => p.id === partId) || null; return host.value.pieces.find(p => p.id === partId) || null;
} }
export function placePiece(host: Entity<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) { export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const playerData = getPlayer(host, player); const playerData = getPlayer(host, player);
const count = playerData[pieceType].placed + 1; const count = playerData[pieceType].placed + 1;
@ -188,7 +188,7 @@ export function placePiece(host: Entity<BoopState>, row: number, col: number, pl
decrementSupply(playerData, pieceType); decrementSupply(playerData, pieceType);
} }
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) { export function applyBoops(host: MutableSignal<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const pieces = host.value.pieces; const pieces = host.value.pieces;
@ -237,7 +237,7 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
} }
} }
export function removePieceFromBoard(host: Entity<BoopState>, part: BoopPart) { export function removePieceFromBoard(host: MutableSignal<BoopState>, part: BoopPart) {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const playerData = getPlayer(host, part.player); const playerData = getPlayer(host, part.player);
board.childIds = board.childIds.filter(id => id !== part.id); board.childIds = board.childIds.filter(id => id !== part.id);
@ -297,7 +297,7 @@ export function hasWinningLine(positions: number[][]): boolean {
return false; return false;
} }
export function checkGraduation(host: Entity<BoopState>, player: PlayerType): number[][][] { export function checkGraduation(host: MutableSignal<BoopState>, player: PlayerType): number[][][] {
const pieces = host.value.pieces; const pieces = host.value.pieces;
const posSet = new Set<string>(); const posSet = new Set<string>();
@ -316,7 +316,7 @@ export function checkGraduation(host: Entity<BoopState>, player: PlayerType): nu
return winningLines; return winningLines;
} }
export function processGraduation(host: Entity<BoopState>, player: PlayerType, lines: number[][][]) { export function processGraduation(host: MutableSignal<BoopState>, player: PlayerType, lines: number[][][]) {
const allPositions = new Set<string>(); const allPositions = new Set<string>();
for (const line of lines) { for (const line of lines) {
for (const [r, c] of line) { for (const [r, c] of line) {
@ -338,12 +338,12 @@ export function processGraduation(host: Entity<BoopState>, player: PlayerType, l
incrementSupply(playerData, 'cat', count); incrementSupply(playerData, 'cat', count);
} }
export function countPiecesOnBoard(host: Entity<BoopState>, player: PlayerType): number { export function countPiecesOnBoard(host: MutableSignal<BoopState>, player: PlayerType): number {
const pieces = host.value.pieces; const pieces = host.value.pieces;
return pieces.filter(p => p.player === player).length; return pieces.filter(p => p.player === player).length;
} }
export function checkWinner(host: Entity<BoopState>): WinnerType { export function checkWinner(host: MutableSignal<BoopState>): WinnerType {
const pieces = host.value.pieces; const pieces = host.value.pieces;
for (const player of ['white', 'black'] as PlayerType[]) { for (const player of ['white', 'black'] as PlayerType[]) {

View File

@ -1,4 +1,4 @@
import {createGameCommandRegistry, Part, Entity, createRegion, moveToRegion} from '@/index'; import {createGameCommandRegistry, Part, MutableSignal, createRegion, moveToRegion} from '@/index';
const BOARD_SIZE = 3; const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
@ -90,7 +90,7 @@ 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: Entity<TicTacToeState>, row: number, col: number): boolean { export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
const board = host.value.board; const board = host.value.board;
return board.partMap[`${row},${col}`] !== undefined; return board.partMap[`${row},${col}`] !== undefined;
} }
@ -103,7 +103,7 @@ export function hasWinningLine(positions: number[][]): boolean {
); );
} }
export function checkWinner(host: Entity<TicTacToeState>): WinnerType { export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
const parts = Object.values(host.value.parts); const parts = Object.values(host.value.parts);
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position); const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
@ -116,7 +116,7 @@ export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
return null; return null;
} }
export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) { export function placePiece(host: MutableSignal<TicTacToeState>, 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: TicTacToePart = { const piece: TicTacToePart = {

View File

@ -1,8 +1,8 @@
import {Signal, signal, SignalOptions} from "@preact/signals-core"; import {Signal, signal, SignalOptions} from "@preact/signals-core";
import {create} from 'mutative'; import {create} from 'mutative';
export class Entity<T> extends Signal<T> { export class MutableSignal<T> extends Signal<T> {
public constructor(public readonly id: string, t?: T, options?: SignalOptions<T>) { public constructor(t?: T, options?: SignalOptions<T>) {
super(t, options); super(t, options);
} }
produce(fn: (draft: T) => void) { produce(fn: (draft: T) => void) {
@ -10,19 +10,19 @@ export class Entity<T> extends Signal<T> {
} }
} }
export function entity<T = undefined>(id: string, t?: T, options?: SignalOptions<T>) { export function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T> {
return new Entity<T>(id, t, options); return new MutableSignal<T>(initial, options);
} }
export type EntityCollection<T> = { export type EntityCollection<T> = {
collection: Signal<Record<string, Entity<T>>>; collection: Signal<Record<string, MutableSignal<T>>>;
remove(...ids: string[]): void; remove(...ids: string[]): void;
add(...entities: (T & {id: string})[]): void; add(...entities: (T & {id: string})[]): void;
get(id: string): Entity<T>; get(id: string): MutableSignal<T>;
} }
export function createEntityCollection<T>(): EntityCollection<T> { export function createEntityCollection<T>(): EntityCollection<T> {
const collection = signal({} as Record<string, Entity<T>>); const collection = signal({} as Record<string, MutableSignal<T>>);
const remove = (...ids: string[]) => { const remove = (...ids: string[]) => {
collection.value = Object.fromEntries( collection.value = Object.fromEntries(
Object.entries(collection.value).filter(([id]) => !ids.includes(id)), Object.entries(collection.value).filter(([id]) => !ids.includes(id)),
@ -32,7 +32,7 @@ export function createEntityCollection<T>(): EntityCollection<T> {
const add = (...entities: (T & {id: string})[]) => { const add = (...entities: (T & {id: string})[]) => {
collection.value = { collection.value = {
...collection.value, ...collection.value,
...Object.fromEntries(entities.map((e) => [e.id, entity(e.id, e)])), ...Object.fromEntries(entities.map((e) => [e.id, mutableSignal(e)])),
}; };
}; };

View File

@ -16,7 +16,7 @@ import {
PlayerType, PlayerType,
getBoardRegion, getBoardRegion,
} from '@/samples/boop'; } from '@/samples/boop';
import {Entity} from "@/utils/entity"; import {MutableSignal} from "@/utils/entity";
import {createGameContext} from "@/"; import {createGameContext} from "@/";
import type { PromptEvent } from '@/utils/command'; import type { PromptEvent } from '@/utils/command';
@ -25,7 +25,7 @@ function createTestContext() {
return { registry, ctx }; return { registry, ctx };
} }
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): Entity<BoopState> { function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<BoopState> {
return ctx.state; return ctx.state;
} }
@ -35,7 +35,7 @@ function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promis
}); });
} }
function getParts(state: Entity<BoopState>) { function getParts(state: MutableSignal<BoopState>) {
return state.value.pieces; return state.value.pieces;
} }

View File

@ -8,7 +8,7 @@ import {
TicTacToeState, TicTacToeState,
WinnerType, PlayerType WinnerType, PlayerType
} from '@/samples/tic-tac-toe'; } from '@/samples/tic-tac-toe';
import {Entity} from "@/utils/entity"; import {MutableSignal} from "@/utils/mutable-signal";
import {createGameContext} from "@/"; import {createGameContext} from "@/";
import type { PromptEvent } from '@/utils/command'; import type { PromptEvent } from '@/utils/command';
@ -17,7 +17,7 @@ function createTestContext() {
return { registry, ctx }; return { registry, ctx };
} }
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): Entity<TicTacToeState> { function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<TicTacToeState> {
return ctx.state; return ctx.state;
} }

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createEntityCollection, Entity, entity } from '@/utils/entity'; import { createEntityCollection, MutableSignal, mutableSignal } from '@/utils/mutable-signal';
type TestEntity = { type TestEntity = {
id: string; id: string;
@ -82,16 +82,6 @@ describe('createEntityCollection', () => {
expect(collection.get('nonexistent')).toBeUndefined(); expect(collection.get('nonexistent')).toBeUndefined();
}); });
it('should have correct accessor id', () => {
const collection = createEntityCollection<TestEntity>();
const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
collection.add(testEntity);
const accessor = collection.get('e1');
expect(accessor.id).toBe('e1');
});
it('should handle removing non-existent entity', () => { it('should handle removing non-existent entity', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
@ -116,20 +106,19 @@ describe('createEntityCollection', () => {
}); });
}); });
describe('Entity', () => { describe('MutableSignal', () => {
it('should create entity with id and value', () => { it('should create signal with initial value', () => {
const e = entity('test', { count: 1 }); const s = mutableSignal({ count: 1 });
expect(e.id).toBe('test'); expect(s.value.count).toBe(1);
expect(e.value.count).toBe(1);
}); });
it('should produce immutable updates', () => { it('should produce immutable updates', () => {
const e = entity('test', { count: 1, items: [1, 2, 3] }); const s = mutableSignal({ count: 1, items: [1, 2, 3] });
e.produce(draft => { s.produce(draft => {
draft.count = 2; draft.count = 2;
draft.items.push(4); draft.items.push(4);
}); });
expect(e.value.count).toBe(2); expect(s.value.count).toBe(2);
expect(e.value.items).toEqual([1, 2, 3, 4]); expect(s.value.items).toEqual([1, 2, 3, 4]);
}); });
}); });