Skip to content

tm.hooks ​

Hook pipelines. A resource fires named events through a pipeline, and its own modules and other resources extend or veto behavior by registering hooks, so a new feature is a hook instead of an edit to the owner. Shared (both sides).

lua
local hooks = tm.hooks.pipeline('placeables', { indexKey = 'name' })  -- once per resource
hooks:expose()                                                         -- optional: other resources may register

local ok, reason = hooks:check('canPlace', { name = item, source = source })  -- a hook can veto
if not ok then return false, reason end
hooks:emit('placed', { name = item, id = id })                                -- observe only

hooks:on('canPlace', function(payload) return false, 'Not here.' end, { filter = { name = 'door' }, priority = -1 })
tm.exports.get('placeables'):registerHook('placed', fn, { filter = { name = { 'door', 'gate' } } })

Pipeline ​

tm.hooks.pipeline(name, options?) creates the pipeline for this resource, or returns the existing one with that name.

OptionMeaning
indexKeyThe payload field most filters target. Default name. Hooks filtered on it are bucketed, so an event for a payload nobody hooked costs one table lookup.
publicPayload(event, payload)Builds the small plain-data table hooks from other resources receive instead of the payload (no live objects, nothing large). Filters and in-resource hooks still see the full payload. If it errors, an empty table is passed and the error is logged.
MethodNotes
pipeline:on(event, fn, options?)Registers a hook and returns its id.
pipeline:off(id)Removes one hook. Returns whether it existed.
pipeline:removeResource(resource, id?)Removes every hook a resource registered (or only id). Done automatically when a resource stops.
pipeline:has(event)True when anything is registered. Lets a hot path skip building a payload.
pipeline:check(event, payload)Runs a vetoable event. Returns ok, reason.
pipeline:emit(event, payload)Runs an observe-only event. Return values are ignored.
pipeline:replay(event, source)Names where existing items are, so late hooks can catch up (below).
pipeline:expose(names?)Lets other resources register hooks through exports (below).

Hook behavior ​

  • Return false (or false, 'reason') from a check hook to veto and stop the chain. Any other return, or an error (logged, never a veto), lets the chain continue. emit ignores returns. Only a string reason is passed on.
  • priority: lower runs first, ties run in registration order. Default 0.
  • filter maps payload fields to accepted values (one value or an array). A table field such as tags matches when any entry is accepted. All fields must match.
  • filter only compares exact values. For anything else (Lua patterns, either of two fields, a live lookup table) pass when, a predicate that gets the payload and returns true to run the hook: { when = function(p) return tostring(p.inventoryId):find('^stash_') ~= nil end }. It runs after filter, so keep filter for cheap exact checks. A when that errors is logged and skips the hook, never a veto. A when from another resource is a function reference, so it costs a round trip per event: prefer filter there.
  • A hook that takes 50 ms or more logs a warning.
  • resource (option) is the owner used for cleanup. It defaults to the calling resource.

Other resources ​

pipeline:expose() registers two exports in the owning resource at runtime (no manifest entry needed): registerHook(event, fn, options) (returns the hook id, or false for a bad call) and removeHooks(id?) (removes the caller's hooks, or one by id). Pass { register = 'name', remove = 'name' } to rename them, for example when a resource has two pipelines. Consumers call them through a handle:

lua
tm.exports.onStart('placeables', function()
    tm.exports.get('placeables'):registerHook('placed', function(view) ... end, { filter = { name = 'door' } })
end)

Hooks registered this way receive a serialized copy of the payload (the publicPayload table when the pipeline defines one), so they cannot mutate it, and a veto may lose its reason string. They are removed when their resource stops. Register them from onStart, because the owner forgets them when it restarts.

What tm_lib itself must provide for this to work: nothing. expose uses plain exports(...) in the owning resource and the consumer uses tm.exports (so the import needs only tm.log).

Catching up ​

A hook registered after items already exist can catch up: name where they are, then register with replay = true.

lua
hooks:replay('spawned', function(emit)
    for _, item in pairs(items) do emit(payloadOf(item)) end   -- may Wait between items
end)
hooks:on('spawned', fn, { replay = true })

The replay runs in its own thread after the register call returns, runs only that hook, respects its filter and when, and stops if the hook is removed. A hook can see an item twice if it appears during the replay, so keep replaying hooks idempotent.