/** * The reusable bridge from typed runtime events to effects. A triggered rule is pure data — which * event it listens to, a declarative predicate over the event facts, how the affected target is * resolved, an effect *reference* (never a callback), and how the resulting effect is gated, lived, * and stacked. Affixes, talents, perks, quests, achievements, environment rules, and reactive AI all * subscribe through this one seam instead of owning parallel trigger runtimes. * * Core stays decoupled from any specific effect system: `dispatch` returns provenance-rich firings * and the game applies them to whatever effect/stat system it owns. Gate state (cooldowns, rate * windows, remaining charges) and active timed effects serialize cleanly so future triggers survive * save/load. */ import { type Predicate, type PredicateFacts, type PredicateValue } from "./predicate.js"; /** Role slots an event exposes; a target selector resolves one of these to a concrete id. */ export type TargetRole = "subject" | "object" | "source" | "owner"; /** * How a rule picks the id its effect lands on: a fixed event role, a dot path into the event facts, * or a literal id. Data-only so it saves with the rule. */ export type TargetSelector = { readonly role: TargetRole; } | { readonly path: string; } | { readonly literal: string; }; /** A reference to an effect the game knows how to apply — id plus JSON-safe params, no closures. */ export interface EffectRef { readonly id: string; readonly params?: Readonly>; } /** * How a repeated application of the same rule's effect on the same target combines with a live one: * `refresh` re-arms the timer at one stack, `stack` adds a stack up to `maxStacks` and re-arms, * `independent` keeps each application as its own instance with its own expiry, `ignore` drops the * new application while one is already active. */ export type StackPolicy = "refresh" | "stack" | "independent" | "ignore"; /** Bounded firing budget over a sliding time window. */ export interface RateLimit { readonly count: number; readonly windowMs: number; } /** * A declarative subscription from an event to an effect. Everything here is serializable content — * the runtime reads it, it never embeds behavior. `effect` names an effect the game resolves; core * only routes and gates. */ export interface TriggeredRule { /** Stable id, unique within an engine. */ readonly id: string; /** Event type this rule subscribes to. */ readonly event: string; /** Owner/source identity (item instance, talent node, quest id) for provenance and bulk cleanup. */ readonly owner?: string; /** Declarative gate over the event facts; omitted fires unconditionally. */ readonly when?: Predicate; /** How the affected target id is resolved; defaults to the `subject` role. */ readonly target?: TargetSelector; /** The effect to apply when the rule fires. */ readonly effect: EffectRef; /** Minimum ms between fires of this rule against the same resolved target. */ readonly cooldownMs?: number; /** Sliding-window firing cap across all targets. */ readonly rateLimit?: RateLimit; /** Total fires before the rule is spent; omitted is unlimited. */ readonly charges?: number; /** Lifetime of the applied effect in ms; omitted is instantaneous (no active instance kept). */ readonly durationMs?: number; /** Stacking policy for repeat applications while an instance is live; defaults to `refresh`. */ readonly stacking?: StackPolicy; /** Cap for the `stack` policy; defaults to 1 (i.e. behaves like `refresh` until raised). */ readonly maxStacks?: number; } /** A typed event handed to the engine; roles feed target resolution, facts feed the predicate. */ export interface RuleEvent { readonly type: string; readonly subject?: string; readonly object?: string; readonly source?: string; /** Fact bag the predicate and `{ path }` target selectors read. */ readonly facts?: PredicateFacts; } /** A live timed effect an engine is tracking until it expires or is cleaned up. */ export interface ActiveEffect { readonly instanceId: string; readonly ruleId: string; readonly effectId: string; readonly owner: string | undefined; readonly target: string; readonly params: Readonly> | undefined; readonly appliedAt: number; readonly expiresAt: number; readonly stacks: number; } /** Reason a firing did not produce an effect — surfaced for debug inspection, never thrown. */ export type FiringBlock = "predicate" | "no-target" | "cooldown" | "rate-limit" | "no-charges" | "stack-ignored"; /** * The outcome of matching one rule against one event. `applied` is the effect the caller should now * run through its own effect system; a blocked firing reports why. Provenance (rule, owner, event, * timestamp) rides along for auditability. */ export interface RuleFiring { readonly ruleId: string; readonly event: string; readonly owner: string | undefined; readonly target: string; readonly effect: EffectRef; readonly at: number; /** Set when the firing produced/updated a timed instance. */ readonly instanceId?: string; /** Stack count on the target after this firing (>= 1 for timed effects). */ readonly stacks?: number; /** Absent when the effect was applied; set when the firing was gated out. */ readonly blocked?: FiringBlock; } /** Serializable runtime state — everything that changes future triggers. Rules travel with it. */ export interface TriggeredRuleState { seq: number; rules: TriggeredRule[]; charges: Record; cooldowns: Record; windows: Record; active: ActiveEffect[]; } /** A running set of triggered rules with gating, timed lifetimes, stacking, cleanup, and save/load. */ export interface TriggeredRuleEngine { /** Register or replace a rule by id. */ add(rule: TriggeredRule): void; /** Drop a rule and any active effects it produced. */ remove(ruleId: string): void; /** Drop every rule and active effect belonging to an owner (unequip, respec, entity removal). */ removeByOwner(owner: string): void; /** Snapshot of registered rules in insertion order. */ rules(): readonly TriggeredRule[]; /** Match an event against all rules, gate, spawn/refresh timed effects, and return firings. */ dispatch(event: RuleEvent, now: number): readonly RuleFiring[]; /** Advance time and return the active effects that expired (and were removed) this tick. */ tick(now: number): readonly ActiveEffect[]; /** All live timed effects. */ active(): readonly ActiveEffect[]; /** Live timed effects landed on a given target. */ activeFor(target: string): readonly ActiveEffect[]; /** Drop all active timed effects but keep rules and gate state. */ clearActive(): void; /** Reset gate state and active effects (respec/encounter reset) while keeping rules. */ reset(): void; /** Serialize all future-affecting state. */ snapshot(): TriggeredRuleState; /** Replace all state from a snapshot. */ hydrate(state: TriggeredRuleState): void; } /** * Create an empty triggered-rule engine. Rules are added as data; dispatching an event returns the * firings the caller applies to its own effect system. Time is caller-supplied (`now` in ms), so the * engine is deterministic and drives equally from a fixed-step loop or a save-restored timeline. * * @capability triggered-rules event-conditioned effects with declarative predicates, lifetimes, and stacking */ export declare function createTriggeredRuleEngine(rules?: readonly TriggeredRule[]): TriggeredRuleEngine;