dev:scriptref:core:events

smudgy:core — Shared state & events

Generated from smudgy v0.4.0 (smudgy-core.d.ts @ 3c804cdb8fa3). Index: scriptref.

Scripts and packages share live data through three kinds of handles: states, events, and procedures. A state holds a value other scripts can read at any time, such as your hp or a roster of group members. Consumers can also watch a state for changes, or bind a widget property to one of its entries; a health bar bound to an hp entry repaints whenever the value changes, without any script running.

An event is a notification that something happened, delivered to every subscriber. Handling one with on() is much like handling a matching line with a trigger, with another package as the source instead of incoming text. Smudgy's own events are delivered the same way: smudgy:events/sys and smudgy:events/map announce connection and disconnection, sent commands, received lines, and map movement. A procedure is a function one package implements and other packages can call; a script might use one to ask a mapper package to start a speedwalk.

Packages declare their own handles with createState, createEvent, and createProcedure, and use another package's with an ordinary import.

A package declares its shared surface by exporting handles from its entry module. The export is the whole declaration; there is no registration step, and the exported name is the name consumers use to find the handle.

// In the entry module of kapusniak/arctic-prompt:
import { createState, createEvent, createProcedure } from "smudgy:core";
 
export interface PromptData { hp: number; maxhp: number }
 
export const promptState = createState<PromptData>();
export const prompt = createEvent<PromptData>();
export const refresh = createProcedure((args: { full: boolean }, sender) => {
  // runs when another script posts to refresh
});

The package then publishes through the handles it exported, from any of its modules: set() or assignment through value for a state, emit() for an event. Because the exported name is the handle's identity, renaming the export renames the handle for every consumer. To keep an established name through a refactor, or to create a handle inside a function where there is no export to take a name from, pass the name explicitly: createState('promptState').

User scripts and local modules can declare handles the same way; between your own modules they can be imported directly, like any other export.

Consuming a handle does not involve importing the package that declared it. Each kind of handle is served by a virtual module named after the kind and the producing package:

import { promptState } from "smudgy:state/kapusniak/arctic-prompt";
import { prompt } from "smudgy:events/kapusniak/arctic-prompt";
import { refresh } from "smudgy:procedures/kapusniak/arctic-prompt";
 
const hp = promptState.value?.hp;   // read-only live view
prompt.on(p => { /* runs on each emit */ });
refresh.post({ full: true });       // fire-and-forget

A scheme module exports every handle of its kind that the producer declared, under the producer's names. Importing one does not run the producer's code and does not require the package to be installed: an absent producer's state reads as undefined, its events never fire, and posts to its procedures do nothing. What the import provides is the consumer's half of each handle:

  • a state can be read (value), watched (watch/onWrite), and bound to widgets (bind)
  • an event can be subscribed to (on/once)
  • a procedure can be called (post)

Publishing (set, emit, a procedure's implementation) stays with the producer.

Bound state is how script-built widgets stay live. A widget property given bind() follows the published value on its own; the widget repaints as writes arrive, and no script runs in between. While the bound path has nothing published, the property shows its fallback value. A binding path cannot contain an expression, so a computed value is first published as a state of its own with createDerived, and bound like any other:

import { createDerived } from "smudgy:core";
import { createWidget, Column, Text, ProgressBar } from "smudgy:widgets";
import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
 
export const hpPct = createDerived(vitals, v => v.hp / v.maxhp);
 
createWidget("health", (
  <Column>
    <Text>HP: {vitals.bind('hp', { fallback: 0 })}</Text>
    <ProgressBar value={hpPct.bind()} max={1} />
  </Column>
));

Appending a handle's name to the module path imports that handle alone, as the default export: import promptState from "smudgy:state/kapusniak/arctic-prompt/promptState". Payload types come with the same imports: each scheme module also exports a type named after each handle, so after import { prompt } from ... a handler can be typed (p: prompt) => ...; the single-handle form exports the same type as Payload.

The built-in catalogs below work identically; smudgy:events/sys and smudgy:events/map are scheme modules whose producer is smudgy itself, and GMCP data arrives the same way (see gmcp). Importing a package's own code (smudgy://owner/package) does not provide its handles. Handle exports are only served inside the producing package; a script importing them from outside gets a console notice pointing at the scheme modules.

export function createState<T = unknown>(name?: string): StateHandle<T>;

Creates a shared state object. Like createEvent, the export names the state:

export interface PromptData { hp: number; maxhp: number }
 
export const promptState = createState<PromptData>();
 
promptState.set({ hp: 42, maxhp: 100 });

Consumers then get a fully typed read-only view:

import { promptState } from "smudgy:state/you/your-package";
const hp = promptState.value?.hp;

It is also possible to specify a name explicitly (export const thisStateIsCalled_promptState = createState('promptState')), which can be useful in some situations, such as if a script author wants to provide both a state and an event with the same name.

State objects are great when other scripts might need to know the current value of something, or if they might subscribe only to some changes deep in a complicated state structure.

If recipients should be notified of every occurrence, and aren't interested in comparing old vs new values, consider using an event instead.

export function createEvent<T = unknown>(name?: string): EventHandle<T>;

Creates an event emitter. Like createState, the export names the event: the system-wide name of the event is the name of the export, and it must be exported from the top level of a package or module.

export const prompt = createEvent<PromptData>();
 
// ...
 
prompt.emit({ hp: 42, maxhp: 100 });

If you only need light event-passing within a package or module, consider using an EventEmitter from node:events instead of a system-wide event.

export function createProcedure<A = unknown, R = void>(
  impl: (args: A, sender: string) => R | Promise<R>,
): ProcedureHandle<A, R>;
 
export function createProcedure<A = unknown, R = void>(
  name: string,
  impl: (args: A, sender: string) => R | Promise<R>,
): ProcedureHandle<A, R>;

Creates a procedure: a function other scripts and packages can call. Because it's impossible to call functions or share data across sandboxes, procedures are the only way to expose a callable function to other scripts.

Also, because all scripts run sequentially in the same thread, calling a procedure is an asynchronous operation. The event loop on the caller side completes a full cycle, the called sandbox then runs, receiving the procedure, and then, after it in turn completes a cycle, the caller sandbox receives the result of the procedure.

export const refresh = createProcedure(async (full: boolean, sender) => {
  // the first argument is the payload, and can be any type that can be serialized to JSON.
  // the second argument is the name of the sender, which will be "user" if the sender was
  // not in a sandbox, otherwise it will be the package owning the sandbox that called us, e.g., `smudgy://foo/bar`
});
export function createDerived<U = unknown, S = any>(
  source: StateConsumer<S>,
  compute: (snapshot: Readonly<S>) => U,
): DerivedHandle<U>;
 
export function createDerived<U = unknown, S = any>(
  name: string,
  source: StateConsumer<S>,
  compute: (snapshot: Readonly<S>) => U,
): DerivedHandle<U>;

Creates a state whose value is computed from another package's state. Especially useful for binding a computed value to a widget, since binding paths are plain lookups and can't contain expressions:

import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
export const hpPct = createDerived(vitals, v => v.hp / v.maxhp);
// <ProgressBar value={hpPct.bind()} />

Like createState, the export names the handle; pass a name first (createDerived('hpPct', vitals, …)) to set it explicitly.

The computation re-runs when the source changes (once per writing turn), and the result is published under the name as your own shared state, so other scripts can bind, watch, and consume it like any state you declare. Nothing is computed while the source has no published value.

export interface StateHandle<T = unknown> {
  value: T;
  readonly previousValue: Readonly<T> | undefined;
  set(value: T): void;
  set(path: string, value: unknown): void;
  bind(): Binding<T>;
  bind<P extends StatePath<T> & string>(
    path: P,
    options?: BindOptions<StateAt<T, P>>,
  ): Binding<StateAt<T, P>>;
  bind(path: string, options?: BindOptions): Binding<any>;
}

A shared state owned by the current script or package, created by createState. Publish with set or by assigning through value.

Other scripts and packages get a read-only view by importing from smudgy:state/<owner>/<package> (see StateConsumer).

  • value — A live view of the published value. Assigning into it publishes just that entry (vitals.value.hp = 42); assigning value itself replaces the whole published value. Assigning into an entry that doesn't exist yet throws, like any property chain onto undefined. Publishing the containing object first avoids this, as does set with a path, which creates the intermediate objects and is the more direct form for bulk updates. Objects read through the view are fresh proxies each time, not stable references (v.stats !== v.stats), so they are unsuitable as map or memoization keys. { ...v } copies one level (nested entries stay live views); JSON.parse(JSON.stringify(v)) copies the whole shape. Object.defineProperty and Object.freeze are not supported on the view and throw.
  • previousValue — The value as it looked before your latest writes, or undefined if nothing had been published before them. Useful for working out what changed. previousValue advances whenever you publish, not per state: all of your states publish together, so finishing any update advances previousValue for every state you own. Like value, this is a read-only live view that follows your newest writes; a spread or JSON copy is a value that stays put.
  • set — Publishes a value. With one argument, replaces the whole value; with two, replaces just the subtree at path (a dot/bracket lookup path such as "groupies[\"Mr. Foo\"].hp"). The two-argument form throws on an empty path. Values are serialized as JSON: properties whose value is undefined are dropped, and NaN becomes null.
  • bind — Connects this state to a widget property (see Binding). With no path the whole value is bound; with a path, just that entry: vitals.bind('hp'), roster.bind('groupies["Mr. Foo"].hp'). Paths are lookups, not expressions; a computed value becomes bindable once published as a state of its own, for example with createDerived.
export interface EventHandle<T = unknown> {
  emit(payload: T): void;
}

An event owned by the current script or package, created by createEvent. Other scripts and packages subscribe by importing from smudgy:events/<owner>/<package> (see EventConsumer).

  • emit — Broadcasts a payload to every subscriber. Payloads are serialized as JSON: properties whose value is undefined are dropped, and NaN becomes null. There is no reply channel; a request/response exchange is built from two events, one in each direction.
export interface ProcedureHandle<A = unknown, R = void> {
  readonly __smudgyProcedure?: (args: A) => R;
}

A procedure implemented by the current script or package, created by createProcedure. Other scripts and packages call it by importing from smudgy:procedures/<owner>/<package> (see ProcedureConsumer); every call runs this implementation.

The handle has no members of its own. The implementation is passed to createProcedure, so all there is to do with the handle is export it, which names the procedure and types your callers.

  • __smudgyProcedure — Type carrier only; no runtime member exists.
export interface DerivedHandle<U = unknown> {
  readonly value: Readonly<U> | undefined;
  bind(): Binding<U>;
  bind<P extends StatePath<U> & string>(
    path: P,
    options?: BindOptions<StateAt<U, P>>,
  ): Binding<StateAt<U, P>>;
  bind(path: string, options?: BindOptions): Binding<any>;
  off(): void;
}

Returned by createDerived: read the computed value, bind it to widgets, and off() to stop computing.

  • value — The most recently computed value, as a read-only live view. undefined before the first computation.
  • bind — Connects the computed value to a widget property (see Binding).
  • off — Stops recomputing. The last published value remains readable.
export interface StateConsumer<T = unknown> {
  readonly value: Readonly<T> | undefined;
  readonly previousValue: Readonly<T> | undefined;
  watch(handler: (snapshot: Readonly<T> | undefined) => void): EventSubscription;
  watch<P extends StatePath<T> & string>(
    path: P,
    handler: (snapshot: Readonly<StateAt<T, P>> | undefined) => void,
  ): EventSubscription;
  watch(path: string, handler: (snapshot: unknown) => void): EventSubscription;
  onWrite(handler: (path: string, snapshot: unknown) => void): EventSubscription;
  onWrite<P extends StatePath<T> & string>(
    path: P,
    handler: (path: string, snapshot: unknown) => void,
  ): EventSubscription;
  onWrite(path: string, handler: (path: string, snapshot: unknown) => void): EventSubscription;
  bind(): Binding<T>;
  bind<P extends StatePath<T> & string>(
    path: P,
    options?: BindOptions<StateAt<T, P>>,
  ): Binding<StateAt<T, P>>;
  bind(path: string, options?: BindOptions): Binding<any>;
}

A read-only view of another package's StateHandle. What import { … } from "smudgy:state/<owner>/<pkg>" gives you.

The two subscription verbs differ in cadence. watch coalesces each update into one delivery; onWrite replays every write:

import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
// vitals is currently { hp: 20, maxhp: 100 }; its producer now
// writes, within a single update:
//   vitals.value.hp = 15;
//   vitals.value.hp = 12;
//   vitals.value.maxhp = 100;
 
vitals.watch(v => { ... });
// one delivery, after the update: v is { hp: 12, maxhp: 100 }
 
vitals.onWrite((path, value) => { ... });
// three deliveries, in write order:
//   ("hp", 15), ("hp", 12), ("maxhp", 100)
 
// In either handler, previousValue holds the value from before the
// update began:
vitals.previousValue;  // { hp: 20, maxhp: 100 }
  • value — A live, read-only view of the producer's current value, or undefined if the producer hasn't published anything. A producer that isn't installed reads the same way, as undefined, not as an error. A published value that isn't an object (a number, a string, an array) reads whole, as a frozen value. Assigning or deleting through the view throws, and so do Object.defineProperty and Object.freeze. Objects read through the view are fresh proxies each time, not stable references (v.stats !== v.stats), so they are unsuitable as map or memoization keys. { ...v } copies one level (nested entries stay live views); JSON.parse(JSON.stringify(v)) copies the whole shape.
  • previousValue — The producer's value as it looked before its latest writes, or undefined if nothing had been published before them. Useful for working out what changed in a watch handler. It advances when the producer publishes, not per state: a producer's states publish together, so any update it finishes advances previousValue for every state it owns.
  • watch — Runs a handler once per writing turn in which the value was written, carrying that turn's final value. Delivery is write-triggered, not change-detected: a turn that rewrites the same value still fires. Pass a path first to watch a single entry: vitals.watch('hp', hp => …). A scoped watcher runs for writes at, under, or enclosing its path, so a whole-value set() fires an 'hp' watcher, while a write to a sibling entry such as maxhp does not.
  • onWrite — Runs a handler for every write, in write order, including writes that didn't change the value (which watch would coalesce into one delivery). The handler receives the written path (relative to this state; "" for the whole value) and the value that was written. Pass a path first to hear only writes at, above, or below that entry. onWrite suits occurrences, where each write is meaningful in itself; watch is the simpler, cheaper verb when only the current value matters.

bind

Connects the producer's published state to a widget property. The widget follows the published value on its own, repainting as writes arrive, with no handler in between (see Binding).

import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
 
<ProgressBar value={vitals.bind('hp', { fallback: 0 })}
             max={vitals.bind('maxhp', { fallback: 100 })} />

While the producer has published nothing (including when it is not installed), the bound path is unpublished and the widget shows the fallback value, or nothing when no fallback was given.

export interface EventConsumer<T = unknown> {
  on(handler: (payload: Readonly<T>) => void): EventSubscription;
  once(): Promise<Readonly<T>>;
  once(handler: (payload: Readonly<T>) => void): EventSubscription;
}

A subscription surface for another package's EventHandle. What import { … } from "smudgy:events/<owner>/<pkg>" (or smudgy:events/sys / smudgy:events/map) gives you.

  • on — Runs a handler on every occurrence. Payloads arrive read-only.
  • once — Returns a promise that resolves with the next occurrence: const first = await prompt.once(). An await on it suspends only the awaiting script; incoming lines and triggers are processed normally in the meantime.
  • once — Like EventConsumer.on, but the handler fires at most once.
export interface ProcedureConsumer<A = unknown, R = void> {
  post(args: A): void;
  readonly __smudgyProcedure?: (args: A) => R;
}

The caller's side of another package's ProcedureHandle. What import { … } from "smudgy:procedures/<owner>/<pkg>" gives you.

  • post — Sends arguments to the implementer, fire-and-forget: there is no reply or receipt, and posting to a producer that isn't installed does nothing. Arguments are serialized as JSON, like event payloads. Answers, when a procedure has any, come back as state the producer publishes or an event it emits.
  • __smudgyProcedure — Type carrier only; no runtime member exists.
export type ConsumerOf<H> = H extends StateHandle<infer T>
? StateConsumer<T>
: H extends EventHandle<infer T>
  ? EventConsumer<T>
  : H extends DerivedHandle<infer U>
    ? StateConsumer<U>
    : // Last: every member of ProcedureHandle is an optional phantom, so any object
      // type matches it structurally — the earlier arms must claim theirs first.
      H extends ProcedureHandle<infer A, infer R>
      ? ProcedureConsumer<A, R>
      : never;

Maps a producer handle type to the corresponding consumer type. The generated smudgy:state/... / smudgy:events/... / smudgy:procedures/... typings use it to derive what consumers see from a package's exports; you'll rarely need to name it yourself.

export type Payload<H> = H extends StateHandle<infer T>
? Readonly<T>
: H extends StateConsumer<infer T>
  ? Readonly<T>
  : H extends EventHandle<infer T>
    ? Readonly<T>
    : H extends EventConsumer<infer T>
      ? Readonly<T>
      : H extends DerivedHandle<infer U>
        ? Readonly<U>
        : // Last for the same structural reason as in ConsumerOf.
          H extends ProcedureHandle<infer A, any>
          ? A
          : H extends ProcedureConsumer<infer A, any>
            ? A
            : never;

The payload type a handle carries, from either side: what a handler receives (state snapshots and event payloads arrive read-only), or what a procedure call sends.

import { prompt } from "smudgy:events/kapusniak/arctic-prompt";
function onPrompt(p: Payload<typeof prompt>) { ... }

Usually you won't need it: every generated module also exports each handle's payload as a type with the handle's own name, so function onPrompt(p: prompt) works directly, and single-handle subpath modules export it as Payload. This helper is for generic code.

export interface EventSubscription {
  off(): void;
}

A subscription returned by an event handle's on/once or a state handle's watch. Call EventSubscription.off to stop listening.

import { connect } from "smudgy:events/sys";
const sub = connect.on(() => { ... });
// later, when you no longer care:
sub.off();
  • off — Cancels this subscription; the handler stops receiving deliveries. Calling it again has no effect. Subscriptions are also dropped automatically when the script reloads.
export type StatePath<T, Depth extends number = 4> = [Depth] extends [never]
? never
: T extends readonly unknown[]
  ? never
  : T extends object
    ? {
        [K in keyof T & string]:
          | K
          | `${K}.${StatePath<T[K], [never, 0, 1, 2, 3][Depth]> & string}`;
      }[keyof T & string]
    : never;

The dotted lookup paths into T, for bind autocompletion ('hp' | 'maxhp' | 'stats.str' | …). Paths are lookups, not expressions; nesting is suggested four levels deep, and any plain string is accepted where the shape is unknown.

export type StateAt<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
  ? StateAt<T[K], Rest>
  : unknown
: P extends keyof T
  ? T[P]
  : unknown;

The value type at a StatePath into T.

export interface Binding<T = unknown> {
  readonly __smudgyStoreBinding: number;
  readonly fallback?: string;
  readonly format?: string;
  readonly __smudgyBindingValue?: T;
}

A live connection from a shared-state path to a widget property, created by a state handle's bind. It is accepted wherever a widget prop takes a value:

<ProgressBar value={vitals.bind('hp')} max={vitals.bind('maxhp')} />
<Text>HP: {vitals.bind('hp')}</Text>

The widget then tracks the published value on its own. No handler runs and no re-mount happens on updates; the mounted widget simply repaints.

export interface BindOptions<T = unknown> {
  fallback?: T;
  format?: string;
}

Options for a state handle's bind.

  • fallback — The value the widget shows while the bound path is unpublished or null (for example, before the producer's first write).
  • format — A display template for text positions: {} is replaced by the bound value, so format: "{}%" renders 42 as 42%. Ignored where the binding feeds a non-text prop (a ProgressBar value, a size).
export const events: {
  lookup(producer: string, name: string): EventConsumer<unknown>;
};

Looks up an event by name at runtime, for generic tooling that doesn't know the event ahead of time. producer is "smudgy://owner/name", "user", or a platform name ("sys", "map"); the payload is untyped. The smudgy:events/... modules serve the same handles fully typed.

export const connect: EventConsumer<Record<string, never>>;

Fires when the session connects to the MUD. Empty payload.

export const disconnect: EventConsumer<Record<string, never>>;

Fires when the session disconnects from the MUD. Empty payload.

export const send: EventConsumer<{ command: string }>;

Fires just before a command goes to the MUD. command is the final outgoing line, after alias expansion and command splitting.

export const receive: EventConsumer<{ text: string }>;

Fires for each complete line received from the MUD, after triggers have run but before the line is displayed. text is the line as originally received; any trigger edits are applied afterward.

Inside the handler, the ambient line from smudgy:core refers to this same incoming line, so line.gag(), line.redirect(), and line.replace() work just as they do in a trigger.

export const room: EventConsumer<{ areaId: string; roomNumber: number | null }>;

Fires when the current map location changes, whether or not a mapper package is installed. areaId is the area's UUID as a string; roomNumber is the room number, or null when the location has no specific room.

Note that the string areaId is a different representation from the AreaId pair the mapper API uses; the two are not interchangeable.

Unstable: This event is new and may change in future releases. The event itself is guaranteed to remain, but the payload, particularly the areaId, may change.


Script API reference · smudgy:core — Sessions & output · smudgy:core — Lines & buffer · GMCP · smudgy:core — Automations · smudgy:core — Saved automations · smudgy:core — Panes · smudgy:widgets · Mapper · smudgy:params