The plugin API 目次

Plugins

The plugin API

What wt gives a plugin: the character, facts for the storyteller and every storyteller's tool, rolls, cards, sheet sections, boards and map marks, the clock, places, people, stores, dice, questions, buttons, words, and the rules a plugin brings.

A plugin's shape

A plugin is one JavaScript module (ES2020): no imports, no network, no timers. Its default export has an onEnable(wt) that runs once when the world loads the plugin. There it registers what it listens to, its buttons, its questions and the world's death rule; anything that acts on the world (a tell, a move, a stored value) is called inside a listener, never in onEnable itself.

export default {
  onEnable(wt) {
    wt.on('rest', async (e) => {
      if (!e.place) return;
      await wt.gm.tell(e.character.name + ' rested at ' + e.place.name + '.');
    });
  },
};

wt is everything the world gives a plugin. The sections below cover it part by part; the events page covers what each moment carries. In the Studio, a plugin's API reference shows the same reference, with every storyteller's tool.

The character

e.character is the character the event is about; wt.character(id) reads another character in the same world. What you can read:

FieldMeaning
id, name, race, class, level, homeRegionWho they are. class is null without one; homeRegion is the region they started in.
health { current, max }Their health now and at most.
money { amount, words }amount in the world's smallest coin; words as the world writes it (3 silver, 4 copper).
place { id, name, kind, region, tags }Where they are, with its kind, region and tags.
sheetTheir rule-system sheet, or null in a world without one.
itemsThe names of what they carry.
alive, downed, day, timeOfDayWhether they are alive and whether they are down, and the day and time of day on their clock.
storeThis plugin's own notes on this character. Change it in place; it is saved when the event ends (up to 16 KB).

What you can do with them:

CallMeaning
heal(amount | 'full')Heals them, up to their maximum.
addMoney(amount)Gives money in the world's smallest coin; a negative amount takes it, never below nothing.
addXp(amount)Growth, priced like the engine's own.
effects([...])Rule-card effects (a track, a condition, a pool, money, growth), applied as a card's effects are. On Worldtable's own engine, money only.
moveTo(placeId, { mode, reason })Moves them there at once: no travel time, and out of any fight. A town is entered at its square.
rollCheck({ check, stat, labels })Rolls one of the rule system's checks now, in code: its effects are applied as the rule system says and a roll chip shows in the story. Returns the result. Rule-system worlds only.
giveItem(item, quantity)Gives an item: a name, or a name with a description, category, rarity and whether it is magical. No narration of its own.
takeItem(name, quantity)Takes an item (or several), with no narration of its own.
addStatus(status)Puts a status on them: a name, or a name with a description, how many turns or until when, whether it is good or bad, and stat changes. No narration of its own.
removeStatus(name)Takes a status off them.
refresh()Reads them again (health, money, place, sheet, items, race, level, class) after something else changed them.

giveItem, takeItem, addStatus and removeStatus are quiet: nothing appears in the story for them. If the storyteller should know, tell it.

wt.on('defeat', async (e) => {
  const c = e.character;
  c.store.kills = (c.store.kills || 0) + 1;          // saved when the event ends
  if (c.store.kills % 10 !== 0) return;
  await c.addMoney(50);                               // the world's smallest coin
  await c.heal('full');
  await wt.gm.tell(c.name + ' has brought down ' + c.store.kills + ' foes; the guild pays them a bounty.');
});
const camp = await wt.places.find({ tag: 'camp', region: c.homeRegion });
if (camp) await c.moveTo(camp.id, { mode: 'respawn', reason: 'carried back to camp' });

// with a rule system: a track on the sheet, as a rule card would write it
await c.effects([{ kind: 'track', track: 'hollowing', op: '+', amount: '1' }]);

For every kind of effect, see rule card effects.

Rolls

In a world with a rule system, a plugin can roll the world's own checks two ways: at once in code, or by putting the roll card in front of the player.

c.rollCheck({ check, stat, labels })
Rolled now, in code. The result lands as the rule system says (its effects applied) and a roll chip shows in the story. It returns check and checkName, stat, band and bandName, tone, total, target, margin, and what was applied.
wt.roll(character, { check, stat, labels, key, data, prompt, seconds })
The world's roll card, with the dice, in front of the player. prompt is a short line shown on the card above the check ("Hold your nerve"). The player throws it; if they don't, the dice throw themselves when the card runs out (three minutes, unless seconds says otherwise). Either way the roll event fires for this plugin with your key and data and the result; thrown is true when the player threw it. It returns the card's pendingId and when it runs out.

A roll card can come from a button, a timer or a new day as well as from a turn: it shows in the player's story, and they throw it there.

Both need a rule system. In a world on Worldtable's own engine, they fail with an error.

// rolled now, in code
const r = await e.character.rollCheck({ check: 'resolve' });
if (r.band === 'fail') await e.character.addStatus({ name: 'Shaken', turns: 3, polarity: 'negative' });

// the player throws it (band ids are the rule system's own)
await wt.roll(e.character, { check: 'resolve', key: 'hold-the-line', prompt: wt.t('holdYourNerve') });
wt.on('roll', async (e) => {
  if (e.key !== 'hold-the-line') return;
  if (e.band === 'fail') await wt.gm.tell(e.character.name + ' breaks and runs.');
});

Telling the storyteller

Plugins tell the storyteller facts, and the storyteller narrates them. The facts arrive marked as stated by the world's rules, so the storyteller treats them as true.

wt.gm.tell(fact)
A fact for this turn's scene, or for the next turn's if this one is already written (told once). Up to 2,000 characters.
wt.gm.remember(key, line)
A standing line in every turn for that character until you set it to null: a stain left behind, a side taken, a secret. Up to 400 characters.
wt.gm.deathRule(text)
Called in onEnable: this world's death rule, in place of the usual rule that players can't die. Pair it with a death listener that cancels, so the storyteller may play a death your plugin handles.
// a fact for the scene
await wt.gm.tell(c.name + ' has lost seven years at the revel. Show what changed.');

// a standing line, kept until cleared
await wt.gm.remember('stain', 'Their stain: 40 silver lies at the old bridge.');
await wt.gm.remember('stain', null);

// the world's death rule, set once
export default {
  onEnable(wt) {
    wt.gm.deathRule(wt.t('deathRule'));
    wt.on('death', { priority: 'high' }, async (e) => { e.cancel(); /* … */ });
  },
};

The storyteller's tools

Every tool the storyteller has is a plugin's too: wt.gm.<tool>(args) with the storyteller's own arguments, run as if the storyteller had called it and with the same checks. It returns the tool's result. That covers giving and taking items, health, quests, people's memories and moods, fights, places, the clock, transformations and skills.

Tools that only read (fetch_character_details, fetch_npc_details, fetch_location_details, fetch_player_quests and the like) return what they found. The Studio lists every tool with its arguments: open a plugin, then API reference, then Every storyteller's tool too.

await wt.gm.grant_item({ item: 'Lamp oil', quantity: 1, narration: 'The keeper hands over a flask of lamp oil.' });
await wt.gm.update_npc_mood({ npc_name: 'Odile', mood: 'wary', reason: 'the stranger asked about the vault' });

Every storyteller's tool a plugin calls is recorded in What it did.

Cards and sheet sections

wt.ui.card(character, card)
A card in the story for that character: a title, a body, a tone (plain, grave, bright or warn), and up to four buttons, each running one of the plugin's commands with the data it carries. navigate: true takes the player to the room they are in now (after a move, to where they woke).
wt.ui.sheet(character, section)
A section on their character sheet: an id, a title, up to 12 lines, a track (label, value, max) or several bars (tracks, each with a label, value, max and its own tone: one per body part, one per rival), a tone, an https picture, and headline: true to sit at the top. The same id replaces it; remove: true takes it off.
await wt.ui.card(e.character, {
  title: wt.t('foundTitle'),
  body: wt.t('foundBody', { amount: '40 silver' }),
  tone: 'bright',
  buttons: [{ label: wt.t('keepIt'), command: 'keep-it', data: { amount: 40 } }],
});

await wt.ui.sheet(e.character, { id: 'hollowing', title: wt.t('hollowing'), track: { value: 2, max: 5 }, tone: 'grave', headline: true });
await wt.ui.sheet(e.character, { id: 'hollowing', remove: true });
// several bars in one section: one per rival
await wt.ui.sheet(e.character, {
  id: 'rivals', title: wt.t('rivals'),
  tracks: [
    { label: 'Corvin', value: 3, max: 5, tone: 'grave' },
    { label: 'Ysolde', value: 1, max: 5 },
  ],
});

Boards and map marks

wt.ui.board(id, { title, rows } | null)
A board the whole world shares, which players open from Boards over the box where they type: rankings, records, the world's firsts. Its rows show in the order you give them, each a label with an optional value and note. The same id replaces the board; null takes it away.
wt.ui.marker(id, { place, label, icon, tone, everyone, characterId } | null)
A mark on the world map at a place (by its id): a label shown on hover or a tap, an icon and a tone. It is one character's own (the event's character, or the one characterId names) or, with everyone: true, everyone's. The same id changes it; null takes it off.
// a board everyone can open
const top = (await wt.store.shared.list('kills:')).map((r) => r.value).sort((a, b) => b.n - a.n).slice(0, 10);
await wt.ui.board('tavern', { title: wt.t('boardTitle'), rows: top.map((r) => ({ label: r.name, value: r.n })) });

// the stain, on its owner's map only
await wt.ui.marker('stain-' + c.id, { place: c.place.id, label: wt.t('stainTitle'), icon: 'drop', tone: 'grave' });
await wt.ui.marker('stain-' + c.id, null);

// a carving everyone sees
await wt.ui.marker('msg-' + place.id, { place: place.id, label: text, icon: 'chat', everyone: true });

The clock

wt.clock.now()
The day and time of day on the character's clock.
wt.clock.advance({ hours, days, years })
Moves the clock on by hours, days or years, as the storyteller's advance_time does.
wt.clock.advanceTo(time)
Moves it on to the next dawn, morning, noon, afternoon, evening, nightfall, night, midnight or daybreak.
const now = await wt.clock.now();        // { day: 12, timeOfDay: 'evening' }
await wt.clock.advance({ days: 90 });     // a season
await wt.clock.advanceTo('nightfall');

Places and tags

wt.places.get(id)
A place by its id, with its name, kind, region and tags.
wt.places.here()
Where the character is.
wt.places.find({ name, tag, kind, region, all })
By name, the best match near the character. By tag, a place with that tag (all: true for every one, region to narrow it). By kind and region, a place of that kind. Kinds: region, settlement, landmark, site, room, camp, ship, realm.
wt.places.tag(id, tag, on)
Puts a tag on a place, or takes it off with false. Tags are short: lowercase letters, digits, dashes and underscores.

A place's tags are its map site's own and the ones plugins set. Tags set by a plugin are seen by every plugin in the world, so one plugin can mark places another reads.

const here = await wt.places.here();
if (here && here.tags.includes('lamp')) { /* … */ }
const lamps = await wt.places.find({ tag: 'lamp', all: true });
const port = await wt.places.find({ name: 'Chalcos' });
await wt.places.tag(here.id, 'haunted');          // on
await wt.places.tag(here.id, 'haunted', false);   // off

People

wt.npcs.get(nameOrId) · wt.npcs.here()

Someone in the world by name or id, or everyone where the character is. Each comes with id, name, role, place, alive and mood, their health, threat and level, their abilities, and how they feel about the character (trust, respect, fear, attraction, hostility). Other changes go through the storyteller's tools (update_npc, update_npc_memory, move_npc).

const here = await wt.npcs.here();
const smith = await wt.npcs.get('Odile');
if (smith && smith.alive) { /* … */ }
wt.npcs.change(nameOrId, { strength, magic, health, maxHealth, threat, combat })
Changes their numbers: a number sets one, "+N" or "-N" adds or takes, "x0.7" multiplies. It changes the NPC and every fight they are in; combat adds to a rule sheet's fight numbers. Returns their name and what changed.
wt.npcs.condition(nameOrId, { name, description, level, turns, remove })
Puts a condition on them, or takes it off with remove: true. It goes on their rule sheet, where it reads into their rolls, and into the words a fight on Worldtable's own engine reads.
wt.npcs.spawn({ creature, role, race, threat, count, place, about, fight, difficulty, ambush })
Brings someone in: a creature from the bestiary by its kind's name, or people of a role (and race, threat, how many). People are named by the world's own name generators, never invented. place puts them somewhere else than here; fight: true starts a fight with them (difficulty, ambush). Returns their names.
await wt.npcs.change('Odile', { health: '-10', strength: 'x1.5' });
await wt.npcs.condition('Odile', { name: 'Burning', turns: 2 });
await wt.npcs.condition('Odile', { name: 'Burning', remove: true });

const { names } = await wt.npcs.spawn({ creature: 'Wolf', count: 3, fight: true, difficulty: 'hard' });
await wt.gm.tell(names.join(', ') + ' come out of the trees.');

Stores and timers

WhereWho reads it
e.character.storeThis plugin, about this character. Change it in place.
wt.store.worldThis plugin, for the whole world: get, set, delete and list by a key prefix.
wt.store.sharedEvery plugin in the world: get and list can read another plugin's by its id; set writes this plugin's own.

Values are JSON: text, numbers, true or false, lists and objects. Setting a value to null deletes it. Each stored value is up to 32 KB, and a plugin keeps up to 2,000 keys in a world.

wt.schedule sets a timer: in so many days or hours on the character's clock (characterId: null for the world's), with a key and some data. When it comes due, at the start of a turn or as a day passes, the plugin's scheduled listeners run with e.key and e.data. A second timer with the same key replaces the first. Timers are stored, so they survive restarts.

e.character.store.visits = (e.character.store.visits || 0) + 1;
await wt.store.world.set('bell-rung', true);
const board = await wt.store.shared.list('kills:');

await wt.schedule({ days: 10, key: 'debt-due', data: { owed: 50 } });
wt.on('scheduled', async (e) => {
  if (e.key !== 'debt-due') return;
  await wt.gm.tell('The moneylender comes to collect ' + e.data.owed + ' silver from ' + e.character.name + '.');
});

Dice

wt.dice.roll takes NdM, NdM+K or NdM*K and gives the total and each die. wt.dice.pick picks one from a list; wt.dice.chance(p) is true that often. All of them, and Math.random, are seeded from the turn, so the same moment rolls the same way again. Date is the moment of the event, not the real clock.

const r = wt.dice.roll('2d6+1');            // { total: 9, dice: [3, 5] }
const span = wt.dice.pick(['a night', 'a week', 'a season']);
if (wt.dice.chance(0.25)) { /* one time in four */ }

Asking a question

wt.ask(question, { options, context })

wt.ask asks a decision model about the scene. It reads the scene so far (and any context you give) and picks one of your options, or yes or no when you give none. It answers with the option and how sure it is.

It can answer null: on a world that uses its own AI endpoint or an agent storyteller, or when the call fails. Plan for null. A question takes a moment (about two seconds), and a plugin may ask at most twice per event.

wt.on('character.create', async (e) => {
  const a = await wt.ask('Is this character, by their backstory, navy, pirate or neither?', {
    options: ['navy', 'pirate', 'neither'],
    context: 'THEIR BACKSTORY: ' + e.backstory,
  });
  if (!a || a.answer === 'neither') return;   // no answer, or no side
  e.character.store.side = a.answer;
});

Buttons and questions

wt.command(id, { label, icon, hidden, input }, listener)

wt.command adds a button for players, shown over the box where they type under This world's actions. A hidden one isn't shown there: it is reached only from a card's buttons. Its listener gets e.character, e.place and e.data (what the card's button carried). A button runs outside any turn, so what it tells the storyteller is told on the next turn.

A button can ask the player for a few words first: give it input: { label, placeholder, max }. Pressing it opens a small line to type in, up to max characters, and the listener gets what they typed as e.text. A card's buttons take input too.

icon picks the button's picture from a fixed set, the same set map marks use: skull flame drop sword shield star crown hourglass moon sun eye feather flag lightning heart coins scroll map-pin chat bell key leaf paw ghost

wt.story(id, question, listener)

wt.story asks a yes-or-no question after each scene; see questions after the scene.

wt.command('carve', { label: wt.t('carve') }, async (e) => {
  await wt.ui.card(e.character, {
    title: wt.t('pick'),
    buttons: [{ label: wt.t('tryFire'), command: 'carve-it', data: { text: 'Try fire' } }],
  });
});

wt.command('carve-it', { label: wt.t('carve'), hidden: true }, async (e) => {
  if (!e.character.place) return;
  await wt.store.shared.set('msg:' + e.character.place.id, e.data.text);
});
wt.command('carve-own', {
  label: wt.t('carve'), icon: 'feather',
  input: { label: wt.t('whatDoYouCarve'), placeholder: wt.t('aFewWords'), max: 80 },
}, async (e) => {
  if (!e.character.place || !e.text) return;
  await wt.store.shared.set('msg:' + e.character.place.id, e.text);
});

Words and settings

wt.t(key, vars) is a string from the plugin's Words players see, with each name in braces filled in from vars. In a language edition it is the translated string. A key with no string comes back as the key itself.

wt.config holds the plugin's settings: each one's default, overridden by what the builder chose.

wt.log(...) and console.log write to What it did.

// Words players see:  diedTitle = You died    diedBody = You wake at {lamp}.
await wt.ui.card(c, { title: wt.t('diedTitle'), body: wt.t('diedBody', { lamp: lamp.name }) });

const tag = wt.config.restTag;     // the builder's choice, or the default
wt.log('woke at', lamp.name);

Rules a plugin brings

In a world with a rule system, a plugin can bring rules of its own beside its code. Its rules field holds tracks, tags (lists, with their conditions), rule cards, classes, abilities, kinds, pools, gear, creatures (bestiary) and words (what a roll and a level are called), each written the way the world's rule system writes its own.

They are merged into the world's rules when the plugins are saved, and taken out when the plugin changes or goes. An id the world's own rules already use is refused, and the save says why. A world on Worldtable's own engine skips them.

In the Studio, an open plugin's Rules it brings lists what is in them and holds them as JSON to edit.

export default { onEnable(wt) { /* … */ } };

// beside the source, in the plugin's rules field (Lamp return's own):
{
  tracks: [{ id: 'hollowing', name: 'Hollowing', role: 'other', max: '10', start: 'zero', display: 'dots', refill: 'never',
             description: 'How far the curse has hollowed them: it rises with each death.' }],
}