Mentat

Planning. The minds decide what (a task: get the radio working, make dinner); Mentat works out how. Named for Dune’s human computers.

The ideas come from hierarchical task networks (Guerrilla’s Decima engine, SHOP): a task is a list of methods tried in order, each with conditions and steps. For getting hold of things, where hand-written methods would never keep up, it uses a short backward search over every way to get an item, the strength of goal-oriented action planning (GOAP).

Tasks and methods

world.task({
  id: 'fix_radio', label: 'getting the radio working',
  relieves: { fun: 0.3 }, base: 0.05, if: ['has:broken_radio'], cooldown: 12 * 60,
  methods: [
    { name: 'take it in', if: ['thinks:open:workshop'], do: ['repair_service'] },
    { name: 'buy another', if: ['money>=60'], do: ['get:radio'] },
    { name: 'do without', do: ['shrug'] },
  ],
});

TaskDef:

Field Meaning
id The task’s id
label A readable name
relieves, base What it’s worth when chosen freely (the same as an action’s). This is only the expectation: the relief itself comes from the actions the plan does
at Places to weigh it at (travel time and opening hours count against it). It doesn’t decide where the steps happen
if, who Conditions to start it, and who may
rank The rank it’s chosen at freely; below 0 means only when required
considerations Extra factors
cooldown Minutes before it can be chosen again, after the plan completes
tags As for actions (Reverie boosts read them)
target 'person': aimed at someone. It’s chosen only when something names who (a directive’s target, a module’s option: Morgul’s revenge), never freely, and the plan keeps them (below)
methods The ways to do it, tried in order

MethodDef: { name?, if?, do }. A method’s if is checked when the plan starts (or replans), for the person; the first method that isn’t marked failed and whose conditions hold is used. Methods are named 'way 1', 'way 2'… in the log if they have no name.

A task is chosen like an action: freely (if it has relieves or base), by a directive (world.direct(id, { do: 'fix_radio' })), by a routine entry, or as a job’s action. A Reverie break can be a task too.

A task aimed at someone

A task with target: 'person' keeps who it’s aimed at (a directive’s target, or the option a module offers: Morgul’s avenge): the plan’s target. Its methods’ conditions read them as the target (target.hurt<0.5, odds>0.5), a step whose action is aimed at someone (target: 'person') is aimed at them, a step’s target: 'target' is them, and 'target' as a place (go:target, an action’s at: 'target') is wherever they are:

world.task({ id: 'avenge', target: 'person', tags: ['harm'], methods: [
  { name: 'have it out', if: ['odds>0.5'], do: [{ action: 'confront', target: 'target' }] },
  { name: 'spike their supper', if: ['has:nightshade'], do: ['go:cafe', { action: 'spike', target: 'target' }] },
  { name: 'let it lie', do: ['brood'] },
] });
world.direct('sam', { do: 'avenge', target: 'dev' });

Steps

Step Means
'repair_service' An action: done at the nearest place it can be done now
'dinner' Another task: expanded by its own methods
'go:park' Walk somewhere: any place spec ('go:home', 'go:work', 'go:tag:food', 'go:person:ruth', a place id)
'wait:15' Wait 15 minutes where they are
'get:radio' Get hold of an item by whatever means (a search, below). Skipped if they have one
{ action, at?, target?, minutes? } An action at a place spec, aimed at someone, or taking minutes instead of its own length

An action step goes to the nearest place (by the action’s at, or the step’s) where it isn’t ruled out now. If every place is ruled out, the step fails.

A go: step walks to the nearest declared place its spec names that can be reached. If there’s none (an unknown id, nobody’s home, no path), the step fails and the plan replans. The walk’s body has next: '@go:<place>'.

get: searches

get:<item> searches every way to get the item: every action that gives it or buys it. For each, it works out a route and its cost, and takes the cheapest:

So the same get:food buys for someone with money, and steals for someone broke and dishonest, and finds no way at all for someone broke and honest:

world.mentat.acquire(world.get('lena'), 'food', 3, new Set());
// { steps: [{ action: 'buy_food', at: 'market' }], cost: 35.45 }   (minutes of effort)

A chain works the same way: with scavenge giving wire, and make_antenna (if has:wire, uses wire) giving antenna, get:antenna plans scavenge then make_antenna.

Running a plan

When a task is picked, Mentat chooses a method and returns the first step. The person does it; when it ends, the next step follows, at the same rank, with layer 'plan'. When the last step is done, the plan is complete: the planned event fires with ok: true, a directive for the task is marked done, and the task’s cooldown starts.

When a step fails (the place was shut on arrival, the slot was taken, a step can’t be done now), the plan replans: the innermost method is marked failed and the next method of that task is tried; if it has none left, the task above it replans, and so on. After maxTries (3) replans (times the headline knob persistence), or when nothing is left, the plan is given up: planned fires with ok: false and the reason, the person feels frustrated (with Reverie), and the task can’t be chosen for two hours (giveUp, or its cooldown, if longer).

If no method fits at the start, the task isn’t planned at all (a no-plan log entry) and the person waits (config.idle minutes) before thinking again. Nothing stops them choosing the same task again, so give a task if conditions that match its methods (a method with no conditions as the last resort does the same).

Plans rest on beliefs

Methods’ conditions are checked against what the person believes, through the condition language: thinks:open:workshop reads their belief, falling back to the truth. And an action step at a place that’s closed is still taken if the person believes it’s open (open:<place> = true). They walk there, find it shut, and learn: they now believe open:<place> is false (as something seen), and the plan replans.

world.fact('closed:workshop', true);                                 // shut today...
world.reverie.believe('ruth', 'open:workshop', true, 'assumed');    // ...but Ruth thinks it's open
world.direct('ruth', { do: 'fix_radio' });
// she walks to the workshop, finds it shut, believes so, and (if she can afford it) buys a radio instead

Interrupted plans

When something outranks a plan’s step (an urgent need, a routine entry, a directive), the plan is set aside, with the interrupted step to be done again. At the person’s next decision, the set-aside plan is picked up again, unless the new choice outranks it, or the new choice is the same task (then it starts fresh), or it was started more than a day ago (stale; then it’s dropped). At most two plans are kept set aside (setAside).

State

A person’s plan is in their state: person.plan (a PlanState: the task, a stack of frames { task, method, steps, i }, the methods failed per task, tries, when it started, its rank and layer) and person.suspended (plans set aside). Mentat keeps no module state of its own.

API

world.mentat:

Method Returns  
providers(item) ActionDef[] Every action that gives or buys the item
acquire(person, item, depth, seen) { steps, cost } or null The cheapest route for a person (a PersonState) to get an item. Pass world.mentat.config.depth and new Set(). Reads only
cost(person, action) number An action’s cost for a person, in minutes of effort (Infinity if impossible)
begin, stepDone, stepFailed, suspend, resume   Engine. Used by the clock to run plans
config   The config

Terms, events and log

Term: planning / planning:<task>.

Event Payload
planned { id, task, ok: true } when a plan completes; { id, task, ok: false, why } when it’s given up

Log entries: planned (a plan started, with the method), no-plan, route (a get: route, with its steps and cost), replanned, done-plan, gave-up, set-aside, resumed. Note that the log’s planned marks a start, while the event marks the end.

Config

Tyrell.create({ moduleConfig: { mentat: { ... } } }):

Field Default Meaning
maxTries 3 Replans before giving up
depth 3 How deep get: looks (getting a part to make the thing to get)
priceWeight 1 Minutes of effort one unit of money is worth, before thrift
persistence 1 Headline knob: how long people keep at a plan (maxTries times it); 0 gives up at the first failure
scruples 1 Headline knob: what scruples cost (the risk of illegal ways and the cost of ways against their values, times it)
giveUp 120 Minutes, at least, before a task given up on is chosen again
setAside, stale 2, 1440 Plans kept set aside; minutes after which a set-aside plan is dropped
honestNever, riskBase, riskHonesty, riskHeat 0.8, 60, 300, 20 What an illegal way costs
valuesNever, valuesCost 0.9, 200 What a way against a value costs

A person’s own tune: { mentat: { persistence, scruples } } multiplies the knobs for them. See Tuning; during play, world.tune('mentat', { ... }).

With other modules

Example

world.place({ id: 'yard', tags: ['scrap'], region: 'river' });
world.link('workshop', 'yard', 6);
world.action({ id: 'scavenge', at: 'tag:scrap', minutes: 30, gives: { wire: 1 } });
world.action({ id: 'make_antenna', at: 'home', minutes: 20, if: ['has:wire'], uses: { wire: 1 }, gives: { antenna: 1 } });
world.action({ id: 'tune_in', at: 'home', minutes: 60, if: ['has:radio', 'has:antenna'], relieves: { fun: 0.4 }, pose: 'sit' });
world.task({ id: 'rig_antenna', base: 0.2, if: ['has:radio', '!has:antenna'], methods: [{ do: ['get:antenna', 'tune_in'] }] });

world.give('ruth', 'radio');
world.direct('ruth', { do: 'rig_antenna' });
world.on('planned', (e) => console.log(e.id, e.task, e.ok ? 'done' : 'gave up: ' + e.why));