npm package: @statedelta-axiom/tick-runner
Description: PPP tick orchestration (Prepare → Process → Propagate) with drain loop
Author: Anderson D. Rosa
Latest version: 0.1.0

# @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).

```typescript
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:

1. **Preparar** — ViewEvaluator computa sinais derivados (sweep linear + cache). Records locked.
2. **Processar** — RuleEngine avalia rules contra estado + sinais. Rules emitem eventos.
3. **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

```bash
pnpm add @statedelta-actions/tick-runner
```

Requer: `@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:

```typescript
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`:

```typescript
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

```typescript
function createTickRunner<TCtx>(deps: TickRunnerDeps<TCtx>, options?: TickRunnerOptions): ITickRunner;
```

```typescript
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

```typescript
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).

```typescript
function createCtx(router: Router): TickContext & InternalCtx;
```

### createEventBridge

Closure pair que conecta o handler `emit` do ActionEngine ao TR:

```typescript
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 |

```typescript
// 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:

```typescript
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 passes
```

## Ciclo 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 RunResult
```

### Halt 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: false
```

**Throw 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ão `tick()`
- 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`](./tests/helpers/README.md).

## Documentação

| Doc | Conteúdo |
|-----|----------|
| [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) | Mecânica interna, módulos, wiring, dependências |
| [`docs/DECISIONS.md`](./docs/DECISIONS.md) | ADRs: ctx, scope, views opt-in, factory, locked records |
| [`docs/IMPLEMENTATION-PROPOSAL.md`](./docs/IMPLEMENTATION-PROPOSAL.md) | Proposta original de implementação |
| [`docs/CHANGELOG.md`](./docs/CHANGELOG.md) | Histórico de mudanças |

## Exports

```typescript
// 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