@statedelta-axiom/tick-runner
v0.1.0
Published
PPP tick orchestration (Prepare → Process → Propagate) with drain loop
Readme
@statedelta-actions/tick-runner
Orquestrador de 1 ciclo PPP (Preparar → Processar → Propagar) sobre o ActionEngine.
O TickRunner integra views, rules e events numa execução sequencial por ciclo.
Recebe todos os recursos via DI — não instancia nada. Método run(), não tick():
quem conta ticks é o RealmSystem (futuro).
import { createTickRunner, createCtx, createEventBridge } from "@statedelta-actions/tick-runner";
// Montar engines externamente, passar pro TR via DI
const runner = createTickRunner(
{ ctx, ruleEngine, eventProcessor, collectEvents },
{ maxEventPasses: 5 },
);
const result = runner.run({ tick: 1 });
// result.halted, result.thrown, result.eventPasses, result.ruleResult...Filosofia
PPP é a mecânica universal
Todo ciclo segue a mesma sequência fixa:
- Preparar — ViewEvaluator computa sinais derivados (sweep linear + cache). Records locked.
- Processar — RuleEngine avalia rules contra estado + sinais. Rules emitem eventos.
- Propagar — EventProcessor drena eventos internos. Multi-pass até fila vazia ou limite.
Não existe outro modelo de orquestração. Variações de domínio (FPS, ETL, board game) são resolvidas por composição (rules, priorities, estado), não por pipelines diferentes.
Orquestrador puro
O TR conhece a mecânica de cada fase, mas não implementa nenhuma. Delega:
- Views →
IViewComputer.evaluate()/evaluateAsync() - Rules →
IRuleEngine.evaluate()/evaluateAsync() - Events →
IEventProcessor.processEvents()/processEventsAsync()
Se uma fase não tem componente (sem viewComputer, sem eventProcessor), é skippada silenciosamente. O mínimo funcional é um RuleEngine.
O TR oferece run() (sync) e runAsync() (async). Não detecta async — oferece
ambos. Quem decide é o caller (RealmSystem) baseado em physics.async.
Halt = commit, Throw = rollback
Semântica fixa, sem configuração:
| | Rules param? | Eventos drenam? | Semântica | |---|---|---|---| | halt | Sim | Sim — normalmente | "Terminei rules, honre os eventos emitidos" | | throw | Sim | Não — descartados | "Inválido, cancela tudo" |
Estado vive fora — ctx é ponteiro
O TR é o único package deste monorepo que conhece estado (apex-store).
Mas o ctx não carrega dados — delega pra Router → Store. O ctx é criado uma vez
e reutilizado entre runs. Dados efêmeros (tick, effects) entram via run() e
morrem quando o run acaba.
Opt-in progressivo
Sem viewComputer → Pass 0 não executa. Sem eventProcessor → Pass 2+ não executa.
Sem run data → ctx.run() retorna undefined. O mínimo é um RuleEngine — o resto é opt-in.
Componente não fornecido = zero overhead (nem instanciado).
Instalação
pnpm add @statedelta-actions/tick-runnerRequer: @statedelta-actions/actions, rules, events, view-state e apex-store.
Quick Start
O TR recebe tudo pronto via DI. O consumer (ou um composition root como o RS futuro) monta os engines e passa pro TR:
import { createTickRunner, createCtx, createEventBridge } from "@statedelta-actions/tick-runner";
import { createActionEngine } from "@statedelta-actions/actions";
import { createRuleEngine } from "@statedelta-actions/rules";
import { createEventProcessor } from "@statedelta-actions/events";
import { createStore } from "apex-store";
import { createRouter } from "apex-store/router";
// 1. Infra
const store = createStore();
const router = createRouter(store);
// 2. Event bridge — closure pair que conecta emit handler ↔ TR
const { emitHandler, collectEvents } = createEventBridge();
// 3. Engines
const actionEngine = createActionEngine({ handlers: { ...myHandlers, emit: emitHandler } });
const ruleEngine = createRuleEngine({ actionEngine });
const eventProcessor = createEventProcessor({ actionEngine });
// 4. Ctx — persistente, reutilizado entre runs
const ctx = createCtx(router);
// 5. TickRunner — orquestrador PPP
const runner = createTickRunner(
{ ctx, ruleEngine, eventProcessor, collectEvents },
{ maxEventPasses: 10 },
);
// Executar ciclos
const r1 = runner.run({ tick: 1 });
const r2 = runner.run({ tick: 2 });Com Views (Pass 0)
Para habilitar views reativas, forneça viewComputer e get:
import { createViewEvaluator } from "@statedelta-actions/view-state";
const viewEvaluator = createViewEvaluator({
actionEngine,
createRecord: (viewId) => createViewRecord(viewId, store),
});
const runner = createTickRunner(
{
ctx,
ruleEngine,
collectEvents,
viewComputer: viewEvaluator, // habilita Pass 0
eventProcessor,
get: (path) => router.get(path), // bound pro ViewEvaluator
},
{ maxEventPasses: 10 },
);Sem viewComputer, Pass 0 é skippado — zero overhead.
API
createTickRunner
function createTickRunner<TCtx>(deps: TickRunnerDeps<TCtx>, options?: TickRunnerOptions): ITickRunner;interface TickRunnerDeps<TCtx> {
/** Ctx persistente — get() → Router → Store, run() → dados efêmeros. */
readonly ctx: TCtx & InternalCtx;
/** Pass 1: RuleEngine (obrigatório — core do ciclo). */
readonly ruleEngine: IRuleEngine<TCtx>;
/** Função que coleta eventos enfileirados e limpa a fila. */
readonly collectEvents: () => QueuedEvent[];
/** Pass 0: ViewEvaluator (opcional — sem ele, skip Pass 0). */
readonly viewComputer?: IViewComputer<TCtx>;
/** Pass 2+: EventProcessor (opcional — sem ele, eventos coletados mas não drenados). */
readonly eventProcessor?: IEventProcessor<TCtx>;
/** Função get pro ViewEvaluator — router.get bound. Requerido se viewComputer presente. */
readonly get?: (path: string) => unknown;
}
interface TickRunnerOptions {
/** Limite de passadas de event drain por ciclo. Default: 10. */
readonly maxEventPasses?: number;
}ITickRunner
interface ITickRunner {
run(env?: RunEnv): RunResult;
runAsync(env?: RunEnv): Promise<RunResult>;
}
interface RunEnv {
readonly tick?: number;
readonly effects?: Record<string, unknown>;
readonly [key: string]: unknown;
}run() e runAsync() produzem o mesmo RunResult — a diferença é que
runAsync() usa await nos métodos async dos sub-engines
(evaluateAsync, processEventsAsync). Com handlers todos sync, ambos
produzem resultado idêntico.
createCtx
Cria o ctx persistente com duas camadas: estado (Store) e run (per-run).
function createCtx(router: Router): TickContext & InternalCtx;createEventBridge
Closure pair que conecta o handler emit do ActionEngine ao TR:
function createEventBridge<TCtx>(): EventBridge<TCtx>;
interface EventBridge<TCtx> {
readonly emitHandler: HandlerDefinition<TCtx>;
readonly collectEvents: () => QueuedEvent[];
}O emitHandler é registrado no ActionEngine como handler "emit". Diretivas
{ type: "emit", event: "...", data: ... } enfileiram na closure. O TR chama
collectEvents() após cada fase pra coletar e limpar a fila.
O handler usa createEmitAnalyzer() do events package — integra com o grafo do Analyzer
(capability "emit", dependency para event definitions).
Ctx — Duas Camadas
O ctx expõe dois canais de leitura:
| Canal | Método | Lifetime | Conteúdo |
|-------|--------|----------|----------|
| Estado | ctx.get(stateId, field?) | Persiste entre runs | Todo estado via Router → Store |
| Run | ctx.run(key) | Efêmero por run | tick, effects, dados injetados |
// Estado — persiste, acessível sempre
ctx.get("hp") // → Counter.value via Router → Store
ctx.get("combat", "$hpPercent") // → View Record via Router → Store
// Run — efêmero, morre no fim do run
ctx.run("tick") // → 42 (se passado no run)
ctx.run("effects") // → { playerInput: { key: "space" } }O ctx é criado uma vez e reutilizado. O run data é substituído a cada run(env).
RunResult
Cada run() retorna resultado estruturado com histórico completo:
const r = runner.run({ tick: 1 });
// Overview
r.halted // halt em rules? (eventos drenaram normalmente)
r.thrown // throw? (tudo abortado)
r.thrownAt // "throw:rule" | "throw:event:pass-1:damage" | undefined
r.totalPasses // quantos passes de eventos
r.maxPassesExceeded // ERRO: drain não convergiu?
r.pendingEvents // eventos não processados
// Rule phase
r.ruleResult // RuleEvaluationResult completo
// Event phase
r.eventPasses[0] // primeiro pass
r.eventPasses[0].events // eventos processados
r.eventPasses[0].result // EventProcessingResult
r.eventPasses[0].result.eventResults[0] // per-event: SingleEventResult
// Todos os eventos (convenience)
r.allEvents // flat de todos os eventos de todos os passesCiclo PPP — Mecânica
run(env) / runAsync(env)
│
├── ctx._setRun(env ?? {}) ← injeta run data efêmero no ctx persistente
│
├── FASE 0: Preparar (se viewComputer presente)
│ viewComputer.evaluate(ctx, get) // sync
│ await viewComputer.evaluateAsync(ctx, get) // async
│
├── FASE 1: Processar (sempre)
│ ruleResult = ruleEngine.evaluate(ctx) // sync
│ ruleResult = await ruleEngine.evaluateAsync(ctx) // async
│ throw? → return imediato, eventos descartados
│
├── FASE 2+: Propagar (se eventProcessor presente)
│ events = collectEvents()
│ while (events.length > 0 && pass < maxEventPasses):
│ eventProcessor.processEvents(events, ctx) // sync
│ await eventProcessor.processEventsAsync(events, ctx) // async
│ throw em evento? → break
│ events = collectEvents()
│
└── return RunResultHalt vs Throw — detalhado
Halt em rules:
Rule A (priority 900) → executa → emite "saved"
Rule B (priority 800) → halt
Rule C (priority 700) → NÃO executa
Eventos: ["saved"] → drenam normalmente na Fase 2+
halted: true, thrown: falseThrow em rules:
Rule A (priority 900) → executa → emite "saved"
Rule B (priority 800) → throw
Eventos: ["saved"] → DESCARTADOS
thrown: true, thrownAt: "throw:rule"Throw em events:
Pass 1: "saved" → listener executa → throw
Drain para. Eventos pendentes reportados.
thrown: true, thrownAt: "throw:event:pass-1:saved"Halt em events — scoped ao evento. Para listeners daquele evento, mas o próximo evento continua. Não para o drain.
Escopo e Limites
O que o TR faz:
- Orquestra a execução sequencial das 3 fases PPP
- Coleta eventos via closure bridge e drena via EventProcessor
- Monta resultado estruturado com histórico de cada fase
- Respeita halt/throw com semântica fixa
O que o TR não faz:
- Não cria engines — recebe prontos via DI
- Não conta ticks — método é
run(), nãotick() - Não gerencia effects — concern do RS
- Não faz persistência/replay — concern do RS futuro
- Não registra rules/listeners/views — feito externamente pelo consumer
Testes
77 testes em 6 suites.
| Suite | Testes | O que cobre |
|-------|:------:|-------------|
| runner.test.ts | 16 | Unit do ciclo PPP com mocks: run data injection, Pass 0/1/2+, halt/throw, multi-pass, reusabilidade |
| async.test.ts | 10 | runAsync: operação básica, run data injection, Pass 0/1/2+ async, throw em rules/events, maxPasses, equivalência sync/async |
| integration.test.ts | 17 | Ciclo completo via Factory: views→rules→events, recompute entre runs, cascata multi-pass, ping/pong infinite loop, halt/throw semântica, env, múltiplas views |
| factory.test.ts | 18 | Wiring da Factory: componentes criados, enableViews opt-in, ctx delegation, run data lifecycle, PPP básico, halt/throw, view Records locked |
| context.test.ts | 8 | Unit do ctx: get→Router delegation, run data injection/replacement, persistência |
| event-bridge.test.ts | 8 | Unit do bridge: enqueue, drain, ordem, cópia, analyze capability |
Integração — ciclos testados
O integration.test.ts valida o ciclo PPP completo com engines reais:
- Views → Rules → Events — view computa
$isLowHp, rule lê e emite, listener drena - Recompute entre runs — hp muda, view reflete, rule reage diferente
- State continuity — hp 100→50→0 ao longo de 3 runs, rule matcha só no 0
- Cascata 3 níveis — rule emite chain-1 → listener emite chain-2 → listener emite chain-3
- Loop infinito — ping/pong detectado por maxEventPasses
- Halt honra eventos — 2 rules emitem antes do halt, ambos drenam
- Throw descarta tudo — eventos pendentes reportados, zero passes
- Múltiplas views — combat + magic com priorities diferentes, ambas acessíveis
Factory (proto-RS) — testes apenas
Uma Factory (createTickSystem) existe em tests/helpers/factory.ts como
composition root temporário pra testes de integração. Instancia e conecta
todos os packages (Store, Router, engines, ViewEvaluator) e entrega pro TR.
Não é exportada pelo package. Não faz parte da API pública. O RS futuro
substituirá como composition root real. Ver tests/helpers/README.md.
Documentação
| Doc | Conteúdo |
|-----|----------|
| docs/ARCHITECTURE.md | Mecânica interna, módulos, wiring, dependências |
| docs/DECISIONS.md | ADRs: ctx, scope, views opt-in, factory, locked records |
| docs/IMPLEMENTATION-PROPOSAL.md | Proposta original de implementação |
| docs/CHANGELOG.md | Histórico de mudanças |
Exports
// Runner
export { createTickRunner } from "./runner";
// Context
export { createCtx } from "./context";
// Event Bridge
export { createEventBridge } from "./event-bridge";
export type { EventBridge } from "./event-bridge";
// Types
export type {
TickContext,
InternalCtx,
RunEnv,
TickRunnerDeps,
TickRunnerOptions,
EventPassResult,
RunResult,
ITickRunner,
} from "./types";Licença
MIT
