diff --git a/src/core/context.ts b/src/core/context.ts index dfbd9ba..4ea5eef 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -2,7 +2,7 @@ import {createModel, Signal, signal} from '@preact/signals-core'; import {createEntityCollection} from "../utils/entity"; import {Part} from "./part"; import {Region} from "./region"; -import {RuleRegistry, RuleContext, dispatchCommand as dispatchRuleCommand} from "./rule"; +import {RuleDef, RuleRegistry, RuleContext, dispatchCommand as dispatchRuleCommand} from "./rule"; export type Context = { type: string; @@ -37,11 +37,33 @@ export const GameContext = createModel((root: Context) => { return undefined; } + function registerRule(name: string, rule: RuleDef) { + const newRules = new Map(rules.value); + newRules.set(name, rule); + rules.value = newRules; + } + + function unregisterRule(name: string) { + const newRules = new Map(rules.value); + newRules.delete(name); + rules.value = newRules; + } + + function addRuleContext(ctx: RuleContext) { + ruleContexts.value = [...ruleContexts.value, ctx]; + } + + function removeRuleContext(ctx: RuleContext) { + ruleContexts.value = ruleContexts.value.filter(c => c !== ctx); + } + function dispatchCommand(input: string) { return dispatchRuleCommand({ rules: rules.value, ruleContexts: ruleContexts.value, contexts, + addRuleContext, + removeRuleContext, }, input); } @@ -54,6 +76,8 @@ export const GameContext = createModel((root: Context) => { pushContext, popContext, latestContext, + registerRule, + unregisterRule, dispatchCommand, } }) diff --git a/src/core/rule.ts b/src/core/rule.ts index 7910aa4..f093620 100644 --- a/src/core/rule.ts +++ b/src/core/rule.ts @@ -38,13 +38,12 @@ function parseYieldedSchema(value: string | CommandSchema): CommandSchema { function pushContextToGame(game: GameContextLike, ctx: RuleContext) { game.contexts.value = [...game.contexts.value, { value: ctx } as any]; - game.ruleContexts.push(ctx); + game.addRuleContext(ctx); } function discardChildren(game: GameContextLike, parent: RuleContext) { for (const child of parent.children) { - const idx = game.ruleContexts.indexOf(child); - if (idx !== -1) game.ruleContexts.splice(idx, 1); + game.removeRuleContext(child); const ctxIdx = game.contexts.value.findIndex((c: any) => c.value === child); if (ctxIdx !== -1) { @@ -161,4 +160,6 @@ type GameContextLike = { rules: RuleRegistry; ruleContexts: RuleContext[]; contexts: { value: any[] }; + addRuleContext: (ctx: RuleContext) => void; + removeRuleContext: (ctx: RuleContext) => void; }; diff --git a/src/utils/entity.ts b/src/utils/entity.ts index b4ab469..7cff6bd 100644 --- a/src/utils/entity.ts +++ b/src/utils/entity.ts @@ -9,6 +9,43 @@ export type EntityAccessor = { value: T; } +function createReactiveProxy(entitySignal: Signal): T { + return new Proxy({} as T, { + get(_target, prop) { + const current = entitySignal.value; + const value = current[prop as keyof T]; + if (typeof value === 'function') { + return value.bind(current); + } + return value; + }, + set(_target, prop, value) { + const current = entitySignal.value; + entitySignal.value = { ...current, [prop]: value }; + return true; + }, + ownKeys(_target) { + return Reflect.ownKeys(entitySignal.value); + }, + getOwnPropertyDescriptor(_target, prop) { + return Reflect.getOwnPropertyDescriptor(entitySignal.value, prop); + }, + }); +} + +function createReactiveAccessor(id: string, entitySignal: Signal): EntityAccessor { + const proxy = createReactiveProxy(entitySignal); + return { + id, + get value() { + return proxy; + }, + set value(value: T) { + entitySignal.value = value; + } + } as EntityAccessor; +} + export function createEntityCollection() { const collection = signal({} as Record>); const remove = (...ids: string[]) => { @@ -24,17 +61,18 @@ export function createEntityCollection() { }; }; - const get = (id: string) => { - return { - id, - get value(){ - return collection.value[id]?.value; - }, - set value(value: T){ - const signal = collection.value[id]; - if(signal)signal.value = value; - } + const get = (id: string): EntityAccessor => { + const entitySignal = collection.value[id]; + if (!entitySignal) { + return { + id, + get value() { + return undefined as unknown as T; + }, + set value(_value: T) {} + } as EntityAccessor; } + return createReactiveAccessor(id, entitySignal); } return { diff --git a/tests/core/rule.test.ts b/tests/core/rule.test.ts index d95ff25..8138f15 100644 --- a/tests/core/rule.test.ts +++ b/tests/core/rule.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createRule, dispatchCommand, type RuleContext, type RuleRegistry } from '../../src/core/rule'; +import { createRule, type RuleContext } from '../../src/core/rule'; import { createGameContext } from '../../src/core/context'; describe('Rule System', () => { @@ -39,7 +39,7 @@ describe('Rule System', () => { it('should invoke a registered rule and yield schema', () => { const game = createTestGame(); - game.rules.value.set('move', createRule(' ', function*(cmd) { + game.registerRule('move', createRule(' ', function*(cmd) { yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0] }; })); @@ -55,7 +55,7 @@ describe('Rule System', () => { it('should complete a rule when final command matches yielded schema', () => { const game = createTestGame(); - game.rules.value.set('move', createRule(' ', function*(cmd) { + game.registerRule('move', createRule(' ', function*(cmd) { const confirm = yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0], confirmed: confirm.name === 'confirm' }; })); @@ -79,7 +79,7 @@ describe('Rule System', () => { it('should pass the initial command to the generator', () => { const game = createTestGame(); - game.rules.value.set('attack', createRule(' [--power: number]', function*(cmd) { + game.registerRule('attack', createRule(' [--power: number]', function*(cmd) { return { target: cmd.params[0], power: cmd.options.power || '1' }; })); @@ -92,7 +92,7 @@ describe('Rule System', () => { it('should complete immediately if generator does not yield', () => { const game = createTestGame(); - game.rules.value.set('look', createRule('[--at]', function*() { + game.registerRule('look', createRule('[--at]', function*() { return 'looked'; })); @@ -107,12 +107,12 @@ describe('Rule System', () => { it('should prioritize new rule invocation over feeding yielded context', () => { const game = createTestGame(); - game.rules.value.set('move', createRule(' ', function*(cmd) { + game.registerRule('move', createRule(' ', function*(cmd) { yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0] }; })); - game.rules.value.set('confirm', createRule('', function*() { + game.registerRule('confirm', createRule('', function*() { return 'new confirm rule'; })); @@ -130,7 +130,7 @@ describe('Rule System', () => { it('should feed a yielded context when command does not match any rule', () => { const game = createTestGame(); - game.rules.value.set('move', createRule(' ', function*(cmd) { + game.registerRule('move', createRule(' ', function*(cmd) { const response = yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0], response: response.name }; })); @@ -145,7 +145,7 @@ describe('Rule System', () => { it('should skip non-matching commands for yielded context', () => { const game = createTestGame(); - game.rules.value.set('move', createRule(' ', function*(cmd) { + game.registerRule('move', createRule(' ', function*(cmd) { const response = yield ''; return { response: response.params[0] }; })); @@ -160,7 +160,7 @@ describe('Rule System', () => { it('should validate command against yielded schema', () => { const game = createTestGame(); - game.rules.value.set('trade', createRule(' ', function*(cmd) { + game.registerRule('trade', createRule(' ', function*(cmd) { const response = yield ' [amount: number]'; return { traded: response.params[0] }; })); @@ -177,12 +177,12 @@ describe('Rule System', () => { it('should feed the deepest yielded context', () => { const game = createTestGame(); - game.rules.value.set('parent', createRule('', function*() { + game.registerRule('parent', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'parent done'; })); - game.rules.value.set('child', createRule('', function*() { + game.registerRule('child', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'child done'; })); @@ -201,12 +201,12 @@ describe('Rule System', () => { it('should link child to parent', () => { const game = createTestGame(); - game.rules.value.set('parent', createRule('', function*() { + game.registerRule('parent', createRule('', function*() { yield 'child_cmd'; return 'parent done'; })); - game.rules.value.set('child_cmd', createRule('', function*() { + game.registerRule('child_cmd', createRule('', function*() { return 'child done'; })); @@ -225,16 +225,16 @@ describe('Rule System', () => { it('should discard previous children when a new child is invoked', () => { const game = createTestGame(); - game.rules.value.set('parent', createRule('', function*() { + game.registerRule('parent', createRule('', function*() { yield 'child_a | child_b'; return 'parent done'; })); - game.rules.value.set('child_a', createRule('', function*() { + game.registerRule('child_a', createRule('', function*() { return 'child_a done'; })); - game.rules.value.set('child_b', createRule('', function*() { + game.registerRule('child_b', createRule('', function*() { return 'child_b done'; })); @@ -259,7 +259,7 @@ describe('Rule System', () => { it('should track rule contexts in ruleContexts signal', () => { const game = createTestGame(); - game.rules.value.set('test', createRule('', function*() { + game.registerRule('test', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'done'; })); @@ -275,7 +275,7 @@ describe('Rule System', () => { it('should add context to the context stack', () => { const game = createTestGame(); - game.rules.value.set('test', createRule('', function*() { + game.registerRule('test', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'done'; })); @@ -292,7 +292,7 @@ describe('Rule System', () => { it('should leave context in place when generator throws', () => { const game = createTestGame(); - game.rules.value.set('failing', createRule('', function*() { + game.registerRule('failing', createRule('', function*() { throw new Error('rule error'); })); @@ -304,12 +304,12 @@ describe('Rule System', () => { it('should leave children in place when child generator throws', () => { const game = createTestGame(); - game.rules.value.set('parent', createRule('', function*() { + game.registerRule('parent', createRule('', function*() { yield 'child'; return 'parent done'; })); - game.rules.value.set('child', createRule('', function*() { + game.registerRule('child', createRule('', function*() { throw new Error('child error'); })); @@ -331,7 +331,7 @@ describe('Rule System', () => { flags: [], }; - game.rules.value.set('test', createRule('', function*() { + game.registerRule('test', createRule('', function*() { const cmd = yield customSchema; return { received: cmd.params[0] }; })); @@ -346,7 +346,7 @@ describe('Rule System', () => { it('should parse string schema on each yield', () => { const game = createTestGame(); - game.rules.value.set('multi', createRule('', function*() { + game.registerRule('multi', createRule('', function*() { const a = yield ''; const b = yield ''; return { a: a.params[0], b: b.params[0] }; @@ -365,7 +365,7 @@ describe('Rule System', () => { it('should handle a multi-step game flow', () => { const game = createTestGame(); - game.rules.value.set('start', createRule('', function*(cmd) { + game.registerRule('start', createRule('', function*(cmd) { const player = cmd.params[0]; const action = yield { name: '', params: [], options: [], flags: [] };