UrsaMU — AI Reference

Machine-optimized reference for code generation. Covers all public APIs,
types, patterns, and conventions. For human-readable guides see docs/guides/.
Current version: v2.6.0. Authoritative API surface lives at
src/@types/UrsamuSDK.ts and is mirrored in
.claude/skills/ursamu-dev/references/api-reference.md.


Overview

UrsaMU is a TypeScript/Deno MUSH-style multiplayer game server. Key characteristics:


Import Paths

// All public APIs (v2.6.0)
import {
  addCmd, cmds, registerScript, registerCmdMiddleware,
  registerPluginRoute, registerUIComponent, unregisterUIComponent,
  getRegisteredUIComponents,
  mu, createObj, DBO, dbojs, gameHooks, send, joinSocketToRoom,
  softcodeService, registerSoftcodeFunc, registerSoftcodeSub,
  evaluateLock, validateLock, registerLockFunc,
  registerFormatHandler, registerFormatTemplate, unregisterFormatHandler,
  resolveFormat, resolveFormatOr, resolveGlobalFormat, resolveGlobalFormatOr,
  header, divider, footer,
  registerStatSystem, getStatSystem, getDefaultStatSystem, getStatSystemNames,
  // Stdlib (v2.5.1+): Noise, PRNG, physics, spatial, interpolation, vector
  Noise, createNoise, seedNoise, perlin1, perlin2, perlin3, simplex2,
  worley2, fbm2, ridged2, noiseGrid, buildPerm,
  Rng, createRng,
  vreflect, pointInAabb, rayAabb,
  dist2d, dist3d, distSq2d, distSq3d, manhattan, chebyshev, angle2d, bearing,
  lerp, inverseLerp, remap, smoothstep, smootherstep, clamp,
  vsize, vsizeSq, vdistance, vdistanceSq, vlerp, vclamp,
} from "jsr:@ursamu/mush";

// Types (zero runtime cost)
import type {
  ICmd, IPlugin, IDBObj, IUrsamuSDK,
  SayEvent, PoseEvent, PageEvent, MoveEvent, SessionEvent,
  ChannelMessageEvent, SceneCreatedEvent, ScenePoseEvent,
  SceneSetEvent, SceneTitleEvent, SceneClearEvent,
} from "jsr:@ursamu/mush";

Internal plugin imports (use only within src/plugins/):

import { addCmd } from "../../services/commands/cmdParser.ts";
import type { IUrsamuSDK } from "../../@types/UrsamuSDK.ts";

IDBObj — Game Object

Every room, player, exit, and item in the database.

interface IDBObj {
  id: string;                        // Numeric string: "1", "42"
  name?: string;                     // Display name (shortcut to state.name)
  flags: Set<string>;                // e.g. Set { "player", "connected" }
  location?: string;                 // ID of containing room/object
  state: Record<string, unknown>;    // All stored data (desc, stats, attrs)
  contents: IDBObj[];                // Objects contained by this object
}

Common flags:


IUrsamuSDK — The u Object

Injected into every command’s exec() and into every sandbox script.

interface IUrsamuSDK {
  // Context (read-only, injected)
  state:    Record<string, unknown>;          // Per-execution scratch state
  socketId?: string;                          // Caller's WebSocket ID
  me:       IDBObj;                           // The actor
  here:     IDBObj & { broadcast(msg: string, opts?: Record<string,unknown>): void };
  target?:  IDBObj & { broadcast(msg: string, opts?: Record<string,unknown>): void };
  cmd:      {
    name:      string;      // matched command name
    original?: string;      // raw input string
    args:      string[];    // regex capture groups
    switches?: string[];    // ["edit"] from "@cmd/edit ..."
  };

  // Messaging
  send(message: string, target?: string, options?: Record<string,unknown>): void;
  broadcast(message: string, options?: Record<string,unknown>): void;

  // Navigation / execution
  teleport(target: string, destination: string): void;
  execute(command: string): void;         // as actor, full pipeline
  force(command: string): void;           // as actor, bypasses some checks
  forceAs(targetId: string, command: string): Promise<void>;  // as another object

  // Permissions
  canEdit(actor: IDBObj, target: IDBObj): Promise<boolean>;
  checkLock(target: string | IDBObj, lock: string): Promise<boolean>;
  setFlags(target: string | IDBObj, flags: string): Promise<void>;
  trigger(target: string, attr: string, args?: string[]): Promise<void>;
  eval(targetId: string, attr: string, args?: string[]): Promise<string>;

  // Namespaces (all methods async)
  db:     IUrsamuDB;
  util:   IUrsamuUtil;
  auth:   IUrsamuAuth;
  sys:    IUrsamuSys;
  chan:   IUrsamuChan;
  bb:     IUrsamuBB;
  text:   IUrsamuText;
  attr:   IUrsamuAttr;
  events: IUrsamuEvents;
  mail:   IUrsamuMail;   // sandbox scripts only (not in native addCmd)
  ui:     IUrsamuUI;     // web client only
}

u.db — Database

// Search — returns IDBObj[]
await u.db.search({ flags: /player/i })           // by flag regex
await u.db.search({ flags: /connected/i })         // connected players
await u.db.search({ location: u.here.id })         // objects in room
await u.db.search({ location: u.here.id, flags: /player/i })
await u.db.search({ "data.name": /alice/i })       // by name (case-insensitive)
await u.db.search({ $or: [{ "data.name": /a/i }, { "data.alias": /a/i }] })
await u.db.search("partial name")                  // string search

// Create — returns IDBObj
const obj = await u.db.create({
  flags: new Set(["thing"]),
  location: u.me.id,          // in actor's inventory
  state: { name: "Sword", damage: 5 },
  contents: [],
});

// Modify — op must be "$set", "$unset", or "$inc"
await u.db.modify(u.me.id, "$set", { "data.gold": 100 });           // one field
await u.db.modify(u.me.id, "$set", { data: { ...u.me.state, gold: 100 } }); // full state
await u.db.modify(u.me.id, "$inc", { "data.score": 10 });           // increment
await u.db.modify(u.me.id, "$unset", { "data.tempFlag": "" });      // remove field

// Destroy
await u.db.destroy(obj.id);

IMPORTANT: u.db.modify(id, op, data)op must be "$set", "$unset",
or "$inc". Passing "name", "state", or any other string does nothing.


u.util — Utilities

// Target resolution (searches inventory → room → global if true)
const obj = await u.util.target(u.me, "sword");       // by name
const obj = await u.util.target(u.me, "#42");          // by ID
const obj = await u.util.target(u.me, "alice", true);  // global search
if (!obj) { u.send("Not found."); return; }

// Display name (applies moniker if set)
u.util.displayName(u.me, u.me)        // actor's own display name
u.util.displayName(target, u.me)      // target's display name as seen by actor

// Strip MUSH codes / ANSI (use before storing or measuring)
u.util.stripSubs("%ch%crDanger!%cn")  // → "Danger!"

// Text alignment (all honor MUSH color codes as zero-width)
u.util.ljust("text", 20)              // "text                "
u.util.rjust("100",  20)              // "                 100"
u.util.center("TITLE", 40, "-")       // "-------------------TITLE------------------"
u.util.center("TITLE", 40)            // "                 TITLE                  "

// Block-style decorators (78-col default; %ch/%cn applied automatically)
// Override via config game.layout.header / .divider / .footer (mushcode)
u.util.header("Title")                // default or game.layout.header template
u.util.divider("Section")             // default or game.layout.divider template
u.util.footer("")                     // default or game.layout.footer template

// Printf formatting
u.util.sprintf("%-10s %5d", "Alice", 1200)   // "Alice       1200"
u.util.sprintf("%05d", 42)                   // "00042"
u.util.sprintf("%.2f%%", 98.6)              // "98.60%"

// Column template
u.util.template(
  "[NNN] [TTTTTTTTTTTTTTTTTTTT]",
  { N: { value: "42", align: "right" }, T: { value: "Title", align: "left" } }
)  // → "[ 42] [Title               ]"

// Map data (optional, may be undefined)
u.util.getMapData?.(targetId, radius)
u.util.parseDesc?.(desc, actor, target)  // async, optional

u.auth — Authentication

const ok = await u.auth.verify(name, password);     // boolean
await u.auth.login(playerId);                         // connect player
const hash = await u.auth.hash("plaintext");          // bcrypt hash
await u.auth.setPassword(playerId, "newpassword");    // change password

u.sys — Server Control

Admin/wizard-only. The SDK does NOT enforce privilege — scripts must check flags.

await u.sys.setConfig("game.masterRoom", "42");   // runtime config change
await u.sys.disconnect(socketId);                   // kick a socket
const ms = await u.sys.uptime();                    // uptime in milliseconds
await u.sys.reboot();                               // exit 75 → daemon restarts
await u.sys.shutdown();                             // exit 0 → clean stop
await u.sys.update();                               // git pull origin/main + reboot
await u.sys.update("develop");                      // pull specific branch

// Game time (in-game calendar)
const t: IGameTime = await u.sys.gameTime();        // { year, month, day, hour, minute }
await u.sys.setGameTime({ year: 1340, month: 3, day: 15, hour: 8, minute: 0 });

// IGameTime fields: year, month (1-12), day (1-28), hour (0-23), minute (0-59)

u.chan — Channels

await u.chan.join("public", "pub");                  // join by name, set alias
await u.chan.leave("pub");                           // leave by alias
const channels = await u.chan.list();               // all channel memberships

// Admin only:
await u.chan.create("events", {
  header: "%ch[EVENTS]%cn",
  lock: "player+",
  hidden: false
});
await u.chan.destroy("events");
await u.chan.set("public", {
  header: "%ch[PUB]%cn",
  masking: true,        // hide speaker names
  logHistory: true,
  historyLimit: 100,
});

// Get recent history (default 20 messages)
const history = await u.chan.history("public");          // last 20
const history = await u.chan.history("public", 50);      // last 50
// → [{ id, playerName, message, timestamp }, ...]

u.bb — Bulletin Boards

// Read
const boards = await u.bb.listBoards();
// → [{ id, name, description?, order, postCount, newCount }, ...]

const posts = await u.bb.listPosts(boardId);
// → [{ id, num, subject, authorName, date, edited? }, ...]

const post = await u.bb.readPost(boardId, postNum);
// → { id, subject, body, authorName, date, edited? } | null

// Unread counts
const n = await u.bb.newPostCount(boardId);
const total = await u.bb.totalNewCount();
await u.bb.markRead(boardId);

// Write (player)
await u.bb.post(boardId, "Subject", "Body text.");
await u.bb.editPost(boardId, postNum, "Updated body.");
await u.bb.deletePost(boardId, postNum);

// Admin
await u.bb.createBoard("General", { description: "General chat", order: 1 });
await u.bb.destroyBoard(boardId);

u.text — Named Text Blobs

Used for MOTD, help pages, templates.

const motd = await u.text.read("motd");           // → string (empty if not set)
await u.text.set("motd", "Welcome to the game!");
await u.text.set("motd", "");                     // clear

u.attr — Object Attributes

Reads soft-coded &ATTR values stored on objects.

const val: string | null = await u.attr.get(objectId, "SHORT-DESC");
const val = await u.attr.get(u.me.id, "FINGER-INFO");
// Attribute names are case-insensitive. Returns null if not set.

u.mail — Player Mail (sandbox scripts only)

Note: u.mail is only available in sandbox scripts (system/scripts/).
Native addCmd handlers should import mail from the database directly.

// Send
await u.mail.send({
  from: `#${u.me.id}`,          // dbref format: "#42"
  to: [`#${recipientId}`],       // array of dbrefs
  cc: [`#${ccId}`],              // optional
  subject: "Meeting tonight",
  message: "At 8pm in the hall.",
  read: false,
  date: Date.now(),
});

// Read inbox
const inbox = await u.mail.read({ to: { $in: [`#${u.me.id}`] } });
// → IMail[] — sorted manually by date if needed

// Delete
await u.mail.delete(messageId);

// Mark read / modify
await u.mail.modify({ id: messageId }, "$set", { read: true });

// IMail shape:
// { id?: string, from: string, to: string[], cc?: string[],
//   bcc?: string[], subject: string, message: string,
//   read: boolean, date: number }

u.events — Server-Wide Pub/Sub

// Emit
await u.events.emit("player.levelup", { id: u.me.id, level: 5 });

// Subscribe a stored &ATTR handler
const subId = await u.events.on("player.levelup", "LEVELUP_HANDLER");
// The attribute value is a script that receives event data

u.ui — Web Client UI (web client only)

// Send structured layout to the web client
u.ui.layout({
  components: [
    { type: "header", title: "Character Sheet" },
    { type: "table", content: [["Name", u.me.name]] },
  ],
  meta: { command: "score" }
});

// Render a template string
const html = u.ui.render("<b>{{name}}</b>", { name: "Alice" });

// Build a panel
u.ui.panel({ type: "list", title: "Who's Online", content: players });

u.trigger / u.eval

// Fire a stored &ATTR as a script (fire-and-forget)
await u.trigger(objectId, "ONENTER");
await u.trigger(objectId, "USE", ["open"]);
await u.trigger(u.me.id, "ACONNECT");

// Evaluate an attribute and return its output as a string
const result = await u.eval("#42", "FORMULA", ["arg1", "arg2"]);

u.forceAs

Execute a command as another object (requires wizard/admin).

await u.forceAs(npcId, "say Welcome, traveler!");
await u.forceAs(objectId, "look");

ICmd — Command Registration

interface ICmd {
  name:      string;                              // unique label
  pattern:   string | RegExp;                     // matched against raw input
  lock?:     string;                              // lock expression
  exec:      (u: IUrsamuSDK) => void | Promise<void>;
  help?:     string;                              // shown in `help <name>`
  hidden?:   boolean;                             // hide from listings
  category?: string;                              // group in help listings
}

addCmd({
  name: "+greet",
  pattern: /^\+greet\s+(.*)/i,
  lock: "connected",
  exec: async (u) => {
    const target = await u.util.target(u.me, u.cmd.args[0]);
    if (!target) { u.send("Who?"); return; }
    const myName = u.util.displayName(u.me, u.me);
    u.send(`You wave to ${u.util.displayName(target, u.me)}.`);
    u.send(`${myName} waves to you.`, target.id);
    u.here.broadcast(`${myName} waves to ${u.util.displayName(target, u.me)}.`);
  },
});

Pattern conventions

// No args:        /^inventory$/i
// One arg:        /^look\s+(.*)/i          → args[0]
// Switch + arg:   /^\+cmd(?:\/(\S+))?\s*(.*)/i  → args[0]=switch, args[1]=rest
// Two parts:      /^@name\s+(.+)=(.+)/i    → args[0], args[1]

Lock expressions

Expression Meaning
"" No restriction (login screen)
"connected" Must be logged in
"connected builder+" Logged in + builder or higher
"connected admin+" Logged in + admin or higher
"connected wizard" Logged in + wizard
"#42" Must be object #42
"player+" Has player flag or higher
"!dark" Must NOT have dark flag
"wizard|admin" wizard OR admin

IPlugin — Plugin Lifecycle

interface IPluginDependency {
  name:    string;   // must match another plugin's IPlugin.name exactly
  version: string;   // semver range, e.g. "^1.0.0", ">=2.1.0"
}

interface IPlugin {
  name:          string;
  version:       string;
  description?:  string;
  dependencies?: IPluginDependency[];  // see Plugin Coupling Patterns below
  init?:         () => boolean | Promise<boolean>;  // return false to abort
  remove?:       () => void | Promise<void>;
}

// index.ts
import "./commands.ts";     // register addCmd at module-load time
import type { IPlugin } from "jsr:@ursamu/mush";

export const plugin: IPlugin = {
  name: "my-plugin",
  version: "1.0.0",
  init: async () => {
    // seed data, connect external services, etc.
    return true;
  },
  remove: async () => {
    // cleanup
  },
};

Plugin is auto-discovered when placed in src/plugins/<name>/index.ts.

Exported Functions

addCmd(...cmds: ICmd[])

Registers commands. Call at module level in commands.ts, not inside init().

import { addCmd } from "jsr:@ursamu/mush";
addCmd({ name: "+foo", pattern: /^\+foo/i, exec: (u) => u.send("foo") });

registerPluginRoute(prefix, handler)

import { registerPluginRoute } from "jsr:@ursamu/mush";
registerPluginRoute("/api/v1/myplugin", async (req, userId) => {
  return Response.json({ ok: true });
});
// handler: (req: Request, userId: string | null) => Promise<Response>

DBO<T> — Plugin database collections

import { DBO } from "jsr:@ursamu/mush";

interface INote { id: string; playerId: string; text: string; date: number; }
const notes = new DBO<INote>("myplugin.notes");

await notes.create({ id: crypto.randomUUID(), playerId: u.me.id, text: "hello", date: Date.now() });
const all = await notes.find({ playerId: u.me.id });
const one = await notes.findOne({ id: noteId });
await notes.update({ id: noteId }, { text: "updated" });
await notes.delete({ id: noteId });
const all = await notes.all();

dbojs — Game objects

import { dbojs } from "jsr:@ursamu/mush";

const players = await dbojs.queryAll((o) => o.flags.has("player"));
const room    = await dbojs.queryOne((o) => o.id === "1");

createObj(template) — Outside command context

import { createObj } from "jsr:@ursamu/mush";
const room = await createObj({
  name: "The Void", flags: new Set(["room"]),
  state: { desc: "Empty." }, contents: [],
});

mu() — Start the server

import { mu } from "jsr:@ursamu/mush";
await mu();

GameHooks — Engine Event Bus

GameHookMap is an interface — plugins can extend it via TypeScript
declaration merging to add their own typed events without modifying the engine:

// In your plugin's augmentation file:
import type { IJob } from "./types.ts";
declare module "../../../services/Hooks/GameHooks.ts" {
  interface GameHookMap {
    "job:created": (job: IJob) => void | Promise<void>;
  }
}

Once imported, all gameHooks.on/off/emit calls become fully typed for the
new event across the entire codebase.

import { gameHooks } from "jsr:@ursamu/mush";

gameHooks.on("player:login", ({ actorId, actorName }) => { /* ... */ });
gameHooks.off("player:login", handler);
await gameHooks.emit("player:login", payload);

Built-in events

Event Payload fields
player:say actorId, actorName, roomId, message
player:pose actorId, actorName, roomId, content, isSemipose
player:page actorId, actorName, targetId, targetName, message
player:move actorId, actorName, fromRoomId, toRoomId, fromRoomName, toRoomName, exitName
player:login actorId, actorName
player:logout actorId, actorName
channel:message channelName, senderId, senderName, message
scene:created sceneId, sceneName, roomId, actorId, actorName, sceneType
scene:pose sceneId, sceneName, roomId, actorId, actorName, msg, type
scene:set sceneId, sceneName, roomId, actorId, actorName, description
scene:title sceneId, oldName, newName, actorId, actorName
scene:clear sceneId, sceneName, actorId, actorName, status

Script System

Scripts live in system/scripts/. File name = command name.

ESM style (recommended for complex scripts)

// system/scripts/mycommand.ts
export default async (u: IUrsamuSDK) => {
  // full access to u.*
};

export const aliases = ["myalias", "mc"];  // optional extra trigger names

Legacy block style (simple scripts, no export)

// system/scripts/gold.ts  (no export = block mode)
const gold = (u.me.state.gold as number) || 0;
u.send(`You have ${gold} gold.`);

ESM import restrictions in scripts

Workers support standard ESM imports BUT:


MUSH Color Codes

Code Effect
%ch Bold / bright
%cn Reset to normal
%cr Red
%cg Green
%cb Blue
%cy Yellow (actually cyan in some terminals)
%cw White
%cc Cyan
%cm Magenta
%cx Dark gray
%n Actor’s name
%r / %R Newline
%t Tab
%b Space
u.send("%ch%cyWelcome!%cn");
u.send(`%ch%cr[ALERT]%cn Server restarting.`);
u.send(u.util.center("%ch=== NEWS ===%cn", 78, "="));

Strip all codes: u.util.stripSubs(str) — use before storing or measuring length.

Project Layout

src/
  @types/          Type definitions (IDBObj, ICmd, IPlugin, etc.)
  commands/        Native addCmd registrations (admin, mail, help, etc.)
  plugins/         Plugin directory — each sub-folder is a plugin
  routes/          HTTP route handlers (scenes, mail, wiki, etc.)
  services/        Core services (Database, Sandbox, GameClock, Hooks, etc.)
  utils/           Shared utilities (flags, target, locks, etc.)
system/
  scripts/         Auto-loaded scripts (one per command: look.ts, say.ts, etc.)
  help/            In-game help text files
config/            Game configuration (config.json / config.toml)
docs/              Documentation site (Lume static site generator)
tests/             Test suite (Deno test)

Common Patterns

Permission guard (admin/wizard)

const isAdmin = u.me.flags.has("admin") ||
                u.me.flags.has("wizard") ||
                u.me.flags.has("superuser");
if (!isAdmin) { u.send("Permission denied."); return; }

Edit permission check

if (!(await u.canEdit(u.me, target))) {
  u.send("Permission denied.");
  return;
}

Standard switch-based command

addCmd({
  name: "+cmd",
  pattern: /^\+cmd(?:\/(\S+))?\s*(.*)/i,
  lock: "connected",
  exec: async (u) => {
    const sw  = (u.cmd.args[0] || "").toLowerCase().trim();
    const arg = (u.cmd.args[1] || "").trim();
    if (!sw || sw === "list") { /* default */ return; }
    if (sw === "view")  { /* ... */ return; }
    if (sw === "create") { /* ... */ return; }
    u.send(`Unknown switch "/${sw}".`);
  },
});

Read and write a state field

// READ
const gold = (u.me.state.gold as number) || 0;

// WRITE (spread to preserve existing fields)
await u.db.modify(u.me.id, "$set", { "data.gold": gold + 10 });

// Or write the entire state object (safe for small objects):
await u.db.modify(u.me.id, "$set", { data: { ...u.me.state, gold: gold + 10 } });

Increment a counter

await u.db.modify(u.me.id, "$inc", { "data.deaths": 1 });

Multi-column output table

const lines: string[] = [];
lines.push(u.util.center("%ch=== WHO ===%cn", 78, "="));
for (const p of online) {
  lines.push(
    u.util.ljust(u.util.displayName(p, u.me), 25) +
    u.util.rjust(`${idle}m`, 6)
  );
}
lines.push("=".repeat(78));
u.send(lines.join("\r\n"));

Register a plugin REST route

registerPluginRoute("/api/v1/myplugin", async (req, userId) => {
  if (!userId) return Response.json({ error: "Unauthorized" }, { status: 401 });
  if (req.method === "GET") {
    return Response.json({ data: await notes.find({ playerId: userId }) });
  }
  return Response.json({ error: "Not found" }, { status: 404 });
});

Listen for a game hook in a plugin

import { gameHooks } from "jsr:@ursamu/mush";
import type { SessionEvent } from "jsr:@ursamu/mush";

const onLogin = ({ actorId, actorName }: SessionEvent) => {
  console.log(`[myplugin] ${actorName} connected`);
};

export const plugin: IPlugin = {
  name: "myplugin", version: "1.0.0",
  init: () => { gameHooks.on("player:login", onLogin); return true; },
  remove: () => { gameHooks.off("player:login", onLogin); },
};

REST API — Core Endpoints

Authentication: pass Authorization: Bearer <jwt> header.

Method Path Auth Description
POST /api/v1/login No Login — returns JWT
GET /api/v1/who Yes Online players list
GET /api/v1/players/:id Yes Player info
GET /api/v1/scenes Yes Scene list
POST /api/v1/scenes Yes Open a new scene
GET /api/v1/scenes/:id Yes Scene detail
PATCH /api/v1/scenes/:id Yes Update scene
POST /api/v1/scenes/:id/pose Yes Post to scene
GET /api/v1/scenes/:id/export Yes Export scene (?format=markdown|json)
GET /api/v1/mail Yes Inbox list
POST /api/v1/mail Yes Send mail
GET /api/v1/mail/:id Yes Read message
DELETE /api/v1/mail/:id Yes Delete message
GET /api/v1/wiki Yes List/search wiki
GET /api/v1/wiki/:path Yes Read page
POST /api/v1/wiki Staff Create page
PATCH /api/v1/wiki/:path Staff Update page
DELETE /api/v1/wiki/:path Staff Delete page


Plugin Coupling Patterns

Rule: never use @ursamu/ursamu/* sub-paths inside src/plugins/

When running the engine directly, Deno resolves @ursamu/ursamu from the
local deno.json — not from JSR. A sub-path export that is absent from the
local deno.json will halt startup even if the published JSR version has it.

Context Correct import
Plugin importing another bundled plugin Relative: ../../jobs/mod.ts
External plugin (separate repo) importing engine types Sub-path: @ursamu/ursamu/jobs

Two strategies for plugin-to-plugin communication

Tight coupling — use dependencies

When plugin B genuinely cannot function without plugin A’s API (types, DB
access, configuration):

// discord/src/index.ts
export const plugin: IPlugin = {
  name: "discord",
  version: "1.1.0",
  dependencies: [{ name: "chargen", version: ">=1.0.0" }],
  // chargen is guaranteed to be init()'d before discord
};

Startup behavior:

Loose coupling — use gameHooks declaration merging

When plugin B only needs to react to plugin A’s lifecycle events (no types,
no direct API calls):

// In plugin A (jobs) — game-hooks-augment.ts:
declare module "../../../services/Hooks/GameHooks.ts" {
  interface GameHookMap {
    "job:created": (job: IJob) => void | Promise<void>;
  }
}

// In plugin A's hooks.ts — bridge emit to gameHooks:
await gameHooks.emit("job:created", job);   // fires AFTER jobHooks subscribers

// In plugin B (discord) — no jobs import needed:
import { gameHooks } from "@ursamu/ursamu";
gameHooks.on("job:created", async (job) => { /* post to webhook */ });

Loose coupling means:

Case study: discord → jobs import error

Symptom (deno task start):

Error loading plugin from .../discord/index.ts:
  TypeError: Unknown export './jobs' for '@ursamu/ursamu'.

Root cause: discord/src/job-hooks.ts imported jobHooks from
@ursamu/ursamu/jobs. When running the engine directly, @ursamu/ursamu
resolves to the local deno.json whose exports did not include ./jobs.

Wrong fix: Add "./jobs": "..." to deno.json exports and leave the
cross-plugin import as-is. This silences the error locally but doesn’t address
the architectural problem.

Right fix (applied):

  1. GameHookMap changed from type to interface — enables declaration merging.
  2. Jobs plugin added game-hooks-augment.ts extending GameHookMap with
    job:created, job:commented, etc.
  3. Jobs jobHooks.emit() now also bridges to gameHooks.emit().
  4. Discord job-hooks.ts uses gameHooks.on("job:created", ...)
    no import from the jobs plugin at all.

Result: discord no longer declares a jobs dependency. It reacts to job
events whether or not it knows jobs is installed.

Generated from src/@types/UrsamuSDK.ts, src/services/Sandbox/worker.ts, and source review. Last updated: 2026-05-15 (v2.6.0).