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 {
Command,
CommandRegistry,
@ -12,16 +12,16 @@ import {
} from "@/utils/command";
export interface IGameContext<TState extends Record<string, unknown> = {} > {
state: Entity<TState>;
commands: CommandRunnerContextExport<Entity<TState>>;
state: MutableSignal<TState>;
commands: CommandRunnerContextExport<MutableSignal<TState>>;
}
export function createGameContext<TState extends Record<string, unknown> = {} >(
commandRegistry: CommandRegistry<Entity<TState>>,
commandRegistry: CommandRegistry<MutableSignal<TState>>,
initialState?: TState | (() => TState)
): IGameContext<TState> {
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
const state = entity('state', stateValue);
const state = mutableSignal(stateValue);
const commands = createCommandRunnerContext(commandRegistry, state);
return {
@ -36,7 +36,7 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
*/
export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
module: {
registry: CommandRegistry<Entity<TState>>,
registry: CommandRegistry<MutableSignal<TState>>,
createInitialState: () => TState
},
): IGameContext<TState> {
@ -44,12 +44,12 @@ export function createGameContextFromModule<TState extends Record<string, unknow
}
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
const registry = createCommandRegistry<Entity<TState>>();
const registry = createCommandRegistry<MutableSignal<TState>>();
return {
registry,
add<TResult = unknown>(
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);
return this;
@ -58,9 +58,9 @@ export function createGameCommandRegistry<TState extends Record<string, unknown>
}
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
registry: CommandRegistry<Entity<TState>>,
registry: CommandRegistry<MutableSignal<TState>>,
schema: CommandSchema | string,
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
) {
registerCommand(registry, {
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 { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command';
export type { Entity } from './utils/entity';
export { createEntityCollection, entity } from './utils/entity';
export type { MutableSignal } from './utils/mutable-signal';
export { mutableSignal, createEntityCollection } from './utils/mutable-signal';
export type { RNG } 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 MAX_PIECES_PER_PLAYER = 8;
@ -48,7 +48,7 @@ export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
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];
}
@ -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;
}
export function getBoardRegion(host: Entity<BoopState>) {
export function getBoardRegion(host: MutableSignal<BoopState>) {
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);
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 partId = board.partMap[`${row},${col}`];
if (!partId) return 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 playerData = getPlayer(host, player);
const count = playerData[pieceType].placed + 1;
@ -188,7 +188,7 @@ export function placePiece(host: Entity<BoopState>, row: number, col: number, pl
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 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 playerData = getPlayer(host, part.player);
board.childIds = board.childIds.filter(id => id !== part.id);
@ -297,7 +297,7 @@ export function hasWinningLine(positions: number[][]): boolean {
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 posSet = new Set<string>();
@ -316,7 +316,7 @@ export function checkGraduation(host: Entity<BoopState>, player: PlayerType): nu
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>();
for (const line of lines) {
for (const [r, c] of line) {
@ -338,12 +338,12 @@ export function processGraduation(host: Entity<BoopState>, player: PlayerType, l
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;
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;
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 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;
}
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;
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 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;
}
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 moveNumber = Object.keys(host.value.parts).length + 1;
const piece: TicTacToePart = {

View File

@ -1,8 +1,8 @@
import {Signal, signal, SignalOptions} from "@preact/signals-core";
import {create} from 'mutative';
export class Entity<T> extends Signal<T> {
public constructor(public readonly id: string, t?: T, options?: SignalOptions<T>) {
export class MutableSignal<T> extends Signal<T> {
public constructor(t?: T, options?: SignalOptions<T>) {
super(t, options);
}
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>) {
return new Entity<T>(id, t, options);
export function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T> {
return new MutableSignal<T>(initial, options);
}
export type EntityCollection<T> = {
collection: Signal<Record<string, Entity<T>>>;
collection: Signal<Record<string, MutableSignal<T>>>;
remove(...ids: string[]): void;
add(...entities: (T & {id: string})[]): void;
get(id: string): Entity<T>;
get(id: string): MutableSignal<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[]) => {
collection.value = Object.fromEntries(
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})[]) => {
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,
getBoardRegion,
} from '@/samples/boop';
import {Entity} from "@/utils/entity";
import {MutableSignal} from "@/utils/entity";
import {createGameContext} from "@/";
import type { PromptEvent } from '@/utils/command';
@ -25,7 +25,7 @@ function createTestContext() {
return { registry, ctx };
}
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): Entity<BoopState> {
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<BoopState> {
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;
}

View File

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

View File

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