feat(engine): add message queue, triggers, and orchestrators

New @tts/engine package: the pure message layer that drives bgm games.
MessageQueue snapshots and drains on tick; TriggerRegistry matches by
type/id with runtime enable/disable; runOrchestrator suspends on wait
until a matching message is processed. Engine ties them together behind
the CommandHost contract that @tts/tabletop will implement.
This commit is contained in:
2026-08-10 18:26:23 +08:00
parent 91cd3a16d7
commit 3167d26bd6
11 changed files with 517 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
/**
* Triggers — declarative reactive glue.
*
* A trigger matches a message by `type` and named params, and emits messages
* in response. It is declared as data, keyed by `role+type+id` like other
* defs, and collision-checked the same way. `match` binds named params from
* the payload (like a route's candidates); `emit` uses the command-id-as-key
* convention. Multiple triggers can match the same message — both fire.
*/
import type { Message } from './message.js';
export interface Trigger {
/** The message type this trigger matches. */
type: string;
/** Optional identity, for runtime enable/disable and collision checks. */
id?: string;
/** Named params that must equal the corresponding fields in `msg.data`. */
match?: Record<string, unknown>;
/** Messages to emit when the trigger matches. */
emit: Message[];
}
/** A trigger matches when its `type` and every `match` param line up. */
export function triggerMatches(t: Trigger, msg: Message): boolean {
if (t.type !== msg.type) return false;
if (!t.match) return true;
const data = msg.data as Record<string, unknown> | undefined;
if (!data) return false;
return Object.entries(t.match).every(([k, v]) => data[k] === v);
}
/**
* A registry of triggers, keyed by `type#id`. `register` collision-checks the
* key; `enable`/`disable` toggle a trigger at runtime (an orchestrator's
* "no more placements this turn" control). `match` returns every enabled
* trigger that matches a message.
*/
export class TriggerRegistry {
private triggers = new Map<string, Trigger>();
private enabled = new Set<string>();
register(t: Trigger): void {
const key = triggerKey(t);
if (this.triggers.has(key)) {
throw new Error(`Duplicate trigger: ${key}`);
}
this.triggers.set(key, t);
this.enabled.add(key);
}
unregister(t: Trigger): void {
const key = triggerKey(t);
this.triggers.delete(key);
this.enabled.delete(key);
}
enable(type: string, id?: string): void {
this.enabled.add(triggerKey({ type, id }));
}
disable(type: string, id?: string): void {
this.enabled.delete(triggerKey({ type, id }));
}
/** Every enabled trigger matching `msg`. */
match(msg: Message): Trigger[] {
const out: Trigger[] = [];
for (const [key, t] of this.triggers) {
if (this.enabled.has(key) && triggerMatches(t, msg)) out.push(t);
}
return out;
}
}
function triggerKey(t: Pick<Trigger, 'type' | 'id'>): string {
return t.id ? `${t.type}#${t.id}` : t.type;
}