@tablefolk/script 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/actions.js +73 -0
- package/expr.js +146 -0
- package/index.js +48 -0
- package/package.json +30 -4
- package/run.js +197 -0
- package/state.js +42 -0
- package/triggers.js +49 -0
- package/validate.js +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jason
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tablefolk/script
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scripted stories for games and chat apps: world **flags** and the **facts**
|
|
4
|
+
characters know while they hold, one **condition** language, **actions**, a
|
|
5
|
+
step **runner** for workflows, **triggers**, and a **validator**. It has no
|
|
6
|
+
DOM and no game in it: your app supplies a small host (who carries what, run
|
|
7
|
+
a character's command, what was said to it, optionally a language model). It
|
|
8
|
+
runs in the browser and in Node.
|
|
9
|
+
|
|
10
|
+
Tablefolk uses it for menus, workflows and model-free worlds, through
|
|
11
|
+
`app/story.js` in the repository.
|
|
12
|
+
|
|
13
|
+
## Conditions and templates
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
world.cat_found a flag holds (not false, 0, "", "no", "none", "off")
|
|
17
|
+
world.favours >= 2 == != >= <= > < contains
|
|
18
|
+
player has lost cat someone carries it (player lacks ...)
|
|
19
|
+
player has "bread and butter" quote names that contain and/or
|
|
20
|
+
player.gold >= 10, me.gold, bert.items contains key
|
|
21
|
+
answer contains kettle a workflow run's own variables
|
|
22
|
+
not world.paid, (a or b) and c not / and / or, brackets ("and" binds first)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **Item names match whole words:** "cat" finds "lost cat", but "key" doesn't
|
|
26
|
+
find "keyring".
|
|
27
|
+
- **Templates:** `{{player}}`, `{{me}}`, `{{world.flag}}`, `{{player.gold}}`,
|
|
28
|
+
and run variables.
|
|
29
|
+
- **Errors:** `parse()` throws `ExprError` with a position.
|
|
30
|
+
|
|
31
|
+
## Actions
|
|
32
|
+
|
|
33
|
+
A menu choice, a workflow's `do` step and a trigger's `do` are one list of
|
|
34
|
+
effects, run in this order:
|
|
35
|
+
|
|
36
|
+
| effect | example |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `take` | `"take": "lost cat"` |
|
|
39
|
+
| `set` | `"set": {"cat_found": true, "favours": "+1"}` |
|
|
40
|
+
| `say` | the player's line |
|
|
41
|
+
| `reply` | the character's line |
|
|
42
|
+
| `act` | command lines: `"act": ["give(silver locket)"]` |
|
|
43
|
+
| `give` | `"give": "pie"` |
|
|
44
|
+
| `start` | a workflow: `"start": "riddle"` (also `workflow`) |
|
|
45
|
+
| `stop` | `"stop": true` |
|
|
46
|
+
|
|
47
|
+
`world.x` is always a flag. A bare name is a flag in a menu or a trigger, and
|
|
48
|
+
a run variable inside a workflow.
|
|
49
|
+
|
|
50
|
+
## Workflows (the runner)
|
|
51
|
+
|
|
52
|
+
Step types:
|
|
53
|
+
|
|
54
|
+
| step | what it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `await` | waits for `"speech"`, or for `"turns"` |
|
|
57
|
+
| `set` | sets run variables or flags |
|
|
58
|
+
| `take` | the player hands an item over, with `"else"` when they don't carry it |
|
|
59
|
+
| `if` / `then` / `else` | branches on a condition |
|
|
60
|
+
| `choose` | `"by": ["decider", "model", "default"]`, the order of who answers |
|
|
61
|
+
| `prompt` | a question for the model |
|
|
62
|
+
| `command` | a command line; takes the character's turn |
|
|
63
|
+
| `menu` | the player picks; `"options"` maps a choice id to a step, words that fit no choice go to `"other"` (or the menu asks again), closing goes to `"closed"` |
|
|
64
|
+
| `do` | actions (above) |
|
|
65
|
+
|
|
66
|
+
Any step can also have `goto`, `end`, and `max` + `exit`.
|
|
67
|
+
|
|
68
|
+
A saved run keeps the step's **id** and a version, so it survives edits to
|
|
69
|
+
the scenario. Runs pause and resume (a menu opened mid-workflow).
|
|
70
|
+
`advance(id, { until: 'turn' })` stops after a command that took the
|
|
71
|
+
character's turn; `until: 'wait'` runs on until the flow waits.
|
|
72
|
+
|
|
73
|
+
## Triggers
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
"triggers": [ { "on": "flag", "flag": "cat_found", "to": true, "as": "bert", "once": true, "do": { "reply": "Whiskers is home!" } },
|
|
77
|
+
{ "on": "given", "who": "pell", "item": "lost cat", "do": { "set": { "cat_found": true } } },
|
|
78
|
+
{ "on": "start", "as": "hobb", "do": { "start": "open_shop" } } ]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **Optional fields:** `if` and `once`.
|
|
82
|
+
- **No chains:** a trigger set off while another runs is skipped, so they
|
|
83
|
+
can't loop.
|
|
84
|
+
|
|
85
|
+
## Validate
|
|
86
|
+
|
|
87
|
+
`validate(scenario)` returns `[{ level, where, message }]`. It finds:
|
|
88
|
+
- conditions that don't parse;
|
|
89
|
+
- `goto`/`then`/`other` targets that don't exist, and steps nothing reaches;
|
|
90
|
+
- menus and workflows that are named but missing;
|
|
91
|
+
- flags that are read but never set;
|
|
92
|
+
- in a world with no model: a `choose` without a default, and a menu step
|
|
93
|
+
without `other`.
|
|
94
|
+
|
|
95
|
+
Tablefolk's world generator sends the errors back to its model to fix.
|
|
96
|
+
|
|
97
|
+
## Use
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import { createState, createRunner, createTriggers, perform, visibleMenu, pickMenu, check, validate, memoryHost } from '@tablefolk/script';
|
|
101
|
+
|
|
102
|
+
const host = memoryHost({ player: { gold: 10, items: [] }, chars: { bert: { inventory: { items: ['lost cat'] } } } });
|
|
103
|
+
const flags = {}, state = createState({ flags: () => flags, facts: () => scenario.facts }); host._state = state;
|
|
104
|
+
const runs = {}, store = { get: id => runs[id] || null, set: (id, r) => { if (r) runs[id] = r; else delete runs[id]; } };
|
|
105
|
+
const runner = createRunner({ flows: () => scenario.workflows, state, host, store });
|
|
106
|
+
const triggers = createTriggers({ list: () => scenario.triggers, state, host, runner });
|
|
107
|
+
|
|
108
|
+
await perform(menuItem, { host, state, me: 'pell', runner, triggers }); // a menu choice
|
|
109
|
+
runner.start('hobb', 'riddle'); await runner.advance('hobb', { until: 'wait' });
|
|
110
|
+
host.heard.hobb = ['a kettle']; await runner.advance('hobb', { until: 'wait' });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A host for your own app implements:
|
|
114
|
+
|
|
115
|
+
| method | what it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `scope(me, vars)` | gives conditions access to `inventory(who)`, `playerName()` and `flag(name)` |
|
|
118
|
+
| `transfer(from, to, item)` | moves an item from one holder to another |
|
|
119
|
+
| `run(actorId, line)` | runs a command line as a character; returns `{ tookTurn }` |
|
|
120
|
+
| `hear(actorId)` | what was said to the character since it last listened |
|
|
121
|
+
| `ask(actorId, question, parse, options)` | asks the model; null when there is none |
|
|
122
|
+
|
|
123
|
+
Optional: `decide`, `menu(actorId, ref)`, `playerSays`, `log`, `ended`.
|
|
124
|
+
|
|
125
|
+
Tests: `node test.mjs` (35 checks, including a full playthrough of
|
|
126
|
+
Tablefolk's Willow Lane with no model).
|
|
127
|
+
|
|
128
|
+
Part of [Tablefolk](https://github.com/runvnc/tablefolk) (`libs/`). MIT.
|
package/actions.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What a menu choice, a workflow step ("do") or a trigger does. One list of
|
|
2
|
+
// effects, run in this order:
|
|
3
|
+
// take the player hands this item to the character (stops here when they lack it)
|
|
4
|
+
// set flags: { "cat_found": true, "favours": "+1" } (world.x is always a flag;
|
|
5
|
+
// a bare name is a flag here, a run variable inside a workflow)
|
|
6
|
+
// say the player's line (shown; the host decides who hears it)
|
|
7
|
+
// reply the character says this
|
|
8
|
+
// act command lines the character runs: "give(silver locket)", "walk(well)" (a string or a list)
|
|
9
|
+
// give the character hands the player this item (a give() command)
|
|
10
|
+
// start starts a workflow on the character: "riddle" or { "name": "riddle", "input": "..." }
|
|
11
|
+
// ("workflow": the same, the name menus used first)
|
|
12
|
+
// stop stops the character's workflow (true)
|
|
13
|
+
// Texts take {{player}}, {{me}}, {{world.flag}}, {{player.gold}}, run variables.
|
|
14
|
+
//
|
|
15
|
+
// await perform(actions, { host, state, me, vars, runner, triggers }) -> { ok, missing?, started? }
|
|
16
|
+
// host: { run(actorId, line) -> Promise<{ tookTurn }>, transfer(from, to, item) -> bool,
|
|
17
|
+
// playerSays?(actorId, text), scope(me, vars) -> expr scope }
|
|
18
|
+
import { render, applyValue, findItem } from './expr.js';
|
|
19
|
+
|
|
20
|
+
// "+1" adds to what was there; "3+1" (a rendered "{{tries}}+1") is worked out
|
|
21
|
+
export function arith(was, v) {
|
|
22
|
+
const m = typeof v === 'string' && v.match(/^\s*(-?\d+(?:\.\d+)?)\s*([+-])\s*(-?\d+(?:\.\d+)?)\s*$/);
|
|
23
|
+
return m ? (m[2] === '+' ? +m[1] + +m[3] : +m[1] - +m[3]) : applyValue(was, v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const q = s => '"' + String(s).replace(/"/g, "'") + '"';
|
|
27
|
+
export async function perform(a, ctx) {
|
|
28
|
+
if (!a) return { ok: true };
|
|
29
|
+
const { host, state, me, vars = {}, runner = null, triggers = null } = ctx;
|
|
30
|
+
const scope = host.scope(me, vars), t = x => render(x, scope);
|
|
31
|
+
if (a.take != null) {
|
|
32
|
+
const item = t(a.take);
|
|
33
|
+
if (!host.transfer('player', me, item)) return { ok: false, missing: item };
|
|
34
|
+
triggers?.fire('given', { who: me, item, from: 'player' });
|
|
35
|
+
}
|
|
36
|
+
for (const [k, v] of Object.entries(a.set || {})) {
|
|
37
|
+
// world.x is always a flag; a bare name is a flag in a menu or trigger, a run variable in a workflow
|
|
38
|
+
if (/^world\./i.test(k) || !ctx.inRun) state.set(k, typeof v === 'string' ? t(v) : v);
|
|
39
|
+
else vars[k] = arith(vars[k], typeof v === 'string' ? t(v) : v);
|
|
40
|
+
}
|
|
41
|
+
if (a.say != null) host.playerSays?.(me, t(a.say));
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (a.reply != null) lines.push(`say(${q(t(a.reply))})`);
|
|
44
|
+
for (const l of [].concat(a.act ?? [])) lines.push(t(l));
|
|
45
|
+
if (a.give != null) lines.push(`give(${t(a.give)})`);
|
|
46
|
+
for (const line of lines) if (me) await host.run(me, line);
|
|
47
|
+
if (a.stop && runner && me) runner.stop(me, 'stopped');
|
|
48
|
+
let started = null;
|
|
49
|
+
const startRef = a.start ?? a.workflow; // "workflow": the menu field's older name
|
|
50
|
+
if (startRef != null && runner && me) {
|
|
51
|
+
const s = typeof startRef === 'string' ? { name: startRef } : startRef;
|
|
52
|
+
if (runner.start(me, t(s.name), s.input != null ? t(s.input) : (a.say ?? ''))) started = s.name;
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, started };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The choices of a menu that show now: their "if" holds, and a "take" item is
|
|
58
|
+
// carried by the player. Labels, says, title and text rendered.
|
|
59
|
+
// visibleMenu(menu, scope) -> menu with items filtered (scope from host.scope(me, {}))
|
|
60
|
+
import { check } from './expr.js';
|
|
61
|
+
export function visibleMenu(menu, scope) {
|
|
62
|
+
if (!menu || !Array.isArray(menu.items)) return menu || null;
|
|
63
|
+
const inv = scope.inventory?.('player');
|
|
64
|
+
const items = menu.items.filter(i => i && check(i.if, scope) && (i.take == null || !inv || findItem(inv.items, render(i.take, scope))))
|
|
65
|
+
.map(i => ({ ...i, label: render(i.label || i.say, scope), ...(i.say != null ? { say: render(i.say, scope) } : {}) }));
|
|
66
|
+
return { ...menu, ...(menu.title ? { title: render(menu.title, scope) } : {}), ...(menu.text ? { text: render(menu.text, scope) } : {}), items };
|
|
67
|
+
}
|
|
68
|
+
// a character's menu: an id or object, or a list whose first entry with a holding "if" wins
|
|
69
|
+
export function pickMenu(ref, menus, scope) {
|
|
70
|
+
if (Array.isArray(ref)) { const hit = ref.find(m => m && check(m.if, scope)); return hit ? pickMenu(hit.menu ?? hit, menus, scope) : null; }
|
|
71
|
+
if (typeof ref === 'string') return menus?.[ref] ? { id: ref, ...menus[ref] } : null;
|
|
72
|
+
return ref || null;
|
|
73
|
+
}
|
package/expr.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Conditions and templates: one language for menus, workflows, triggers and goals.
|
|
2
|
+
//
|
|
3
|
+
// world.cat_found a flag holds (not false, 0, "", "no", "none", "off")
|
|
4
|
+
// world.favours >= 2 compare: == != >= <= > < contains
|
|
5
|
+
// player has lost cat someone carries it (player lacks lost cat)
|
|
6
|
+
// player has "bread and butter" quote names that contain and/or
|
|
7
|
+
// player.gold >= 10, me.gold, bert.items contains key
|
|
8
|
+
// answer contains kettle a run's own variables
|
|
9
|
+
// not world.paid, (a or b) and c not / and / or, brackets
|
|
10
|
+
//
|
|
11
|
+
// Item names match whole words: "cat" finds "lost cat", "key" does not find "keyring".
|
|
12
|
+
//
|
|
13
|
+
// const ast = parse('player has lost cat and not world.paid'); // throws ExprError (with .pos) on bad input
|
|
14
|
+
// evaluate(ast, scope) / check(text, scope) -> boolean
|
|
15
|
+
// render('Thanks, {{player}}! You have {{player.gold}} gold.', scope)
|
|
16
|
+
// scope: { flag(name), vars: {}, me, playerName(), inventory(who) -> { gold, items: [{ name, qty }] } | null }
|
|
17
|
+
|
|
18
|
+
export class ExprError extends Error { constructor(msg, pos, text) { super(`${msg} at ${pos} in "${text}"`); this.pos = pos; this.text = text; } }
|
|
19
|
+
|
|
20
|
+
const OPS = ['==', '!=', '>=', '<=', '>', '<'];
|
|
21
|
+
function tokenize(text) {
|
|
22
|
+
const out = []; let i = 0;
|
|
23
|
+
while (i < text.length) {
|
|
24
|
+
const c = text[i];
|
|
25
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
26
|
+
if (c === '"' || c === "'") {
|
|
27
|
+
const end = text.indexOf(c, i + 1); if (end < 0) throw new ExprError('unclosed quote', i, text);
|
|
28
|
+
out.push({ t: 'str', v: text.slice(i + 1, end), pos: i }); i = end + 1; continue;
|
|
29
|
+
}
|
|
30
|
+
if (c === '(' || c === ')') { out.push({ t: c, pos: i }); i++; continue; }
|
|
31
|
+
if (c === '!' && text[i + 1] !== '=') { out.push({ t: 'not', pos: i }); i++; continue; }
|
|
32
|
+
const op = OPS.find(o => text.startsWith(o, i)); if (op) { out.push({ t: 'op', v: op, pos: i }); i += op.length; continue; }
|
|
33
|
+
const m = text.slice(i).match(/^[^\s()"'=!<>]+/);
|
|
34
|
+
const w = m[0], low = w.toLowerCase();
|
|
35
|
+
if (low === 'and' || low === 'or' || low === 'not') out.push({ t: low, pos: i });
|
|
36
|
+
else if (low === 'contains') out.push({ t: 'op', v: 'contains', pos: i });
|
|
37
|
+
else if (low === 'has' || low === 'lacks' || low === 'carries') out.push({ t: 'has', v: low === 'lacks' ? 'lacks' : 'has', pos: i });
|
|
38
|
+
else out.push({ t: 'word', v: w, pos: i });
|
|
39
|
+
i += w.length;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parse(text) {
|
|
45
|
+
text = String(text ?? '').trim();
|
|
46
|
+
if (!text) return { k: 'lit', v: true };
|
|
47
|
+
const toks = tokenize(text); let p = 0;
|
|
48
|
+
const peek = () => toks[p], take = t => { const x = toks[p]; if (!x || (t && x.t !== t)) throw new ExprError(`expected ${t || 'more'}`, x ? x.pos : text.length, text); p++; return x; };
|
|
49
|
+
const stop = x => !x || x.t === 'and' || x.t === 'or' || x.t === ')';
|
|
50
|
+
// words up to and/or/): an item name or a right-hand side ("lost cat")
|
|
51
|
+
function phrase() {
|
|
52
|
+
const x = peek(); if (!x || stop(x)) throw new ExprError('expected a value', x ? x.pos : text.length, text);
|
|
53
|
+
if (x.t === 'str') { p++; return { k: 'lit', v: x.v }; }
|
|
54
|
+
const words = []; while (peek() && !stop(peek()) && peek().t === 'word') words.push(take().v);
|
|
55
|
+
if (!words.length) throw new ExprError('expected a value', x.pos, text);
|
|
56
|
+
if (words.length === 1) return /^-?\d+(\.\d+)?$/.test(words[0]) ? { k: 'lit', v: +words[0] } : { k: 'ref', v: words[0] };
|
|
57
|
+
return { k: 'lit', v: words.join(' ') };
|
|
58
|
+
}
|
|
59
|
+
function primary() {
|
|
60
|
+
const x = peek();
|
|
61
|
+
if (!x) throw new ExprError('unexpected end', text.length, text);
|
|
62
|
+
if (x.t === 'not') { p++; return { k: 'not', a: primary() }; }
|
|
63
|
+
if (x.t === '(') { p++; const e = or(); take(')'); return e; }
|
|
64
|
+
// holder has item
|
|
65
|
+
if (x.t === 'word' && toks[p + 1]?.t === 'has') { p++; const h = take('has'); return { k: 'has', who: x.v, item: phrase(), neg: h.v === 'lacks' }; }
|
|
66
|
+
const a = x.t === 'str' ? (p++, { k: 'lit', v: x.v }) : x.t === 'word' ? (p++, /^-?\d+(\.\d+)?$/.test(x.v) ? { k: 'lit', v: +x.v } : { k: 'ref', v: x.v }) : null;
|
|
67
|
+
if (!a) throw new ExprError(`unexpected "${x.v || x.t}"`, x.pos, text);
|
|
68
|
+
if (peek()?.t === 'op') { const op = take().v; return { k: 'cmp', op, a, b: phrase() }; }
|
|
69
|
+
return { k: 'truthy', a };
|
|
70
|
+
}
|
|
71
|
+
function and() { let e = primary(); while (peek()?.t === 'and') { p++; e = { k: 'and', a: e, b: primary() }; } return e; }
|
|
72
|
+
function or() { let e = and(); while (peek()?.t === 'or') { p++; e = { k: 'or', a: e, b: and() }; } return e; }
|
|
73
|
+
const e = or();
|
|
74
|
+
if (p < toks.length) throw new ExprError(`unexpected "${toks[p].v || toks[p].t}"`, toks[p].pos, text);
|
|
75
|
+
return e;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const truthy = v => v != null && v !== false && !/^(|0|no|false|none|off)$/i.test(String(v).trim());
|
|
79
|
+
const norm = s => String(s ?? '').toLowerCase().replace(/^(a|an|the|some)\s+/, '').trim();
|
|
80
|
+
// whole words: "cat" finds "lost cat", "key" does not find "keyring"
|
|
81
|
+
export function itemMatches(name, want) {
|
|
82
|
+
const n = norm(name), w = norm(want); if (!n || !w) return false;
|
|
83
|
+
if (n === w) return true;
|
|
84
|
+
const re = s => new RegExp('(^|\\W)' + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\W|$)');
|
|
85
|
+
return re(w).test(n) || re(n).test(w);
|
|
86
|
+
}
|
|
87
|
+
export function findItem(items, want) {
|
|
88
|
+
const live = (items || []).map(i => typeof i === 'string' ? { name: i, qty: 1 } : i).filter(i => i && (i.qty ?? 1) > 0);
|
|
89
|
+
return live.find(i => norm(i.name) === norm(want)) || live.find(i => itemMatches(i.name, want)) || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// a name's value: world.x (a flag), <who>.gold / <who>.items, a run variable, else the word itself
|
|
93
|
+
export function lookup(name, scope = {}) {
|
|
94
|
+
const n = String(name);
|
|
95
|
+
if (/^world\./i.test(n)) return scope.flag?.(n.slice(6));
|
|
96
|
+
const m = n.match(/^([\w-]+)\.(gold|items)$/i);
|
|
97
|
+
if (m) {
|
|
98
|
+
const inv = scope.inventory?.(m[1] === 'me' ? scope.me : m[1]);
|
|
99
|
+
if (inv) return m[2].toLowerCase() === 'gold' ? Number(inv.gold) || 0 : (inv.items || []).filter(i => (i.qty ?? 1) > 0).map(i => i.name).join(', ');
|
|
100
|
+
}
|
|
101
|
+
if (n === 'me') return scope.me ?? '';
|
|
102
|
+
if (n === 'player') return scope.playerName?.() ?? 'the player';
|
|
103
|
+
if (scope.vars && n in scope.vars) { const v = scope.vars[n]; return /^\s*-?\d+(\.\d+)?\s*$/.test(String(v)) ? +v : v; }
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function evaluate(e, scope = {}) {
|
|
108
|
+
const val = x => x.k === 'lit' ? x.v : lookup(x.v, scope);
|
|
109
|
+
switch (e.k) {
|
|
110
|
+
case 'lit': return truthy(e.v);
|
|
111
|
+
case 'not': return !evaluate(e.a, scope);
|
|
112
|
+
case 'and': return evaluate(e.a, scope) && evaluate(e.b, scope);
|
|
113
|
+
case 'or': return evaluate(e.a, scope) || evaluate(e.b, scope);
|
|
114
|
+
case 'truthy': return truthy(val(e.a));
|
|
115
|
+
case 'has': { const inv = scope.inventory?.(e.who === 'me' ? scope.me : e.who); const r = !!(inv && findItem(inv.items, val(e.item.k === 'ref' ? { k: 'lit', v: e.item.v } : e.item))); return e.neg ? !r : r; }
|
|
116
|
+
case 'cmp': {
|
|
117
|
+
const a = val(e.a), b = e.b.k === 'ref' && !/^world\.|\.(gold|items)$/i.test(e.b.v) && !(scope.vars && e.b.v in scope.vars) ? e.b.v : val(e.b);
|
|
118
|
+
if (e.op === 'contains') return String(a ?? '').toLowerCase().includes(String(b ?? '').toLowerCase());
|
|
119
|
+
const num = x => typeof x === 'number' ? x : /^\s*-?\d+(\.\d+)?\s*$/.test(String(x)) ? +x : (x === true ? 1 : x === false || x == null ? 0 : NaN);
|
|
120
|
+
const na = num(a), nb = num(b), both = !Number.isNaN(na) && !Number.isNaN(nb);
|
|
121
|
+
const x = both ? na : String(a ?? '').toLowerCase(), y = both ? nb : String(b ?? '').toLowerCase();
|
|
122
|
+
return e.op === '==' ? x == y : e.op === '!=' ? x != y : e.op === '>=' ? x >= y : e.op === '<=' ? x <= y : e.op === '>' ? x > y : x < y;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const cache = new Map();
|
|
128
|
+
export function check(text, scope = {}) {
|
|
129
|
+
if (text == null || text === '' || text === true) return true;
|
|
130
|
+
if (text === false) return false;
|
|
131
|
+
if (Array.isArray(text)) return text.every(t => check(t, scope));
|
|
132
|
+
let ast = cache.get(text); if (!ast) { ast = parse(text); if (cache.size > 500) cache.clear(); cache.set(text, ast); }
|
|
133
|
+
return evaluate(ast, scope);
|
|
134
|
+
}
|
|
135
|
+
export function render(tpl, scope = {}) {
|
|
136
|
+
return String(tpl ?? '').replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, k) => { const v = lookup(k, scope); return v == null || v === k && !(scope.vars && k in scope.vars) && k !== 'me' && k !== 'player' ? '' : String(v); });
|
|
137
|
+
}
|
|
138
|
+
// "+1" / "-2" add to a number; "true"/"false" and numbers become themselves
|
|
139
|
+
export function applyValue(was, value) {
|
|
140
|
+
if (typeof value !== 'string') return value;
|
|
141
|
+
const m = value.match(/^\s*([+-])\s*(\d+(?:\.\d+)?)\s*$/);
|
|
142
|
+
if (m) return (Number(was) || 0) + (m[1] === '+' ? +m[2] : -m[2]);
|
|
143
|
+
if (/^\s*-?\d+(\.\d+)?\s*$/.test(value)) return +value;
|
|
144
|
+
if (value === 'true') return true; if (value === 'false') return false;
|
|
145
|
+
return value;
|
|
146
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @tablefolk/script -- scripted stories: world flags and facts, one condition
|
|
2
|
+
// language, actions, a step runner for workflows (menus as steps the player
|
|
3
|
+
// answers), triggers, and a validator. No DOM and no game: the app gives a
|
|
4
|
+
// small host (who carries what, run a character's command, what was said).
|
|
5
|
+
// Runs in the browser and in Node. README.md has the scenario format.
|
|
6
|
+
//
|
|
7
|
+
// import { createState, createRunner, createTriggers, perform, check, render, validate } from '@tablefolk/script';
|
|
8
|
+
export { parse, evaluate, check, render, lookup, truthy, itemMatches, findItem, applyValue, ExprError } from './expr.js';
|
|
9
|
+
export { createState } from './state.js';
|
|
10
|
+
export { perform, arith, visibleMenu, pickMenu } from './actions.js';
|
|
11
|
+
export { createRunner, parseAnswer, RUN_VERSION } from './run.js';
|
|
12
|
+
export { createTriggers } from './triggers.js';
|
|
13
|
+
export { validate } from './validate.js';
|
|
14
|
+
|
|
15
|
+
// A plain in-memory host (tests, tools, a text adventure): inventories and a
|
|
16
|
+
// say log; run() understands say("..."), give(item), sell("item", n).
|
|
17
|
+
export function memoryHost({ player = { gold: 0, items: [] }, chars = {}, playerName = 'Traveller' } = {}) {
|
|
18
|
+
const inv = who => (who === 'player' || who === 'you' ? player : chars[who]?.inventory) || null;
|
|
19
|
+
const said = [], heard = {};
|
|
20
|
+
const norm = i => typeof i === 'string' ? { name: i, qty: 1 } : i;
|
|
21
|
+
const move = (from, to, name) => {
|
|
22
|
+
const a = inv(from), b = inv(to); if (!a || !b) return false;
|
|
23
|
+
a.items = (a.items || []).map(norm); b.items = (b.items || []).map(norm);
|
|
24
|
+
const it = a.items.find(i => (i.qty ?? 1) > 0 && (i.name.toLowerCase() === String(name).toLowerCase() || new RegExp('(^|\\W)' + String(name).toLowerCase() + '(\\W|$)').test(i.name.toLowerCase())));
|
|
25
|
+
if (!it) return false;
|
|
26
|
+
if ((it.qty ?? 1) > 1) it.qty--; else a.items.splice(a.items.indexOf(it), 1);
|
|
27
|
+
const have = b.items.find(i => i.name === it.name); if (have) have.qty = (have.qty || 1) + 1; else b.items.push({ name: it.name, qty: 1 });
|
|
28
|
+
return it.name;
|
|
29
|
+
};
|
|
30
|
+
const host = {
|
|
31
|
+
player, chars, said, heard,
|
|
32
|
+
scope: (me, vars) => ({ me, vars, playerName: () => playerName, inventory: inv, flag: n => host._state?.get(n) }),
|
|
33
|
+
transfer: (from, to, item) => !!move(from, to, item),
|
|
34
|
+
async run(actor, line) {
|
|
35
|
+
let m;
|
|
36
|
+
if ((m = line.match(/^say\("(.*)"\)$/s))) said.push(`${actor}: ${m[1]}`);
|
|
37
|
+
else if ((m = line.match(/^give\((.+)\)$/))) move(actor, 'player', m[1].replace(/^["']|["']$/g, ''));
|
|
38
|
+
else if ((m = line.match(/^sell\("(.+)",\s*(\d+)\)$/))) { if ((player.gold || 0) >= +m[2] && move(actor, 'player', m[1])) { player.gold -= +m[2]; inv(actor).gold = (inv(actor).gold || 0) + +m[2]; } }
|
|
39
|
+
else said.push(`${actor} does: ${line}`);
|
|
40
|
+
return { tookTurn: true };
|
|
41
|
+
},
|
|
42
|
+
hear: actor => { const q = heard[actor]; return q?.length ? q.shift() : null; },
|
|
43
|
+
ask: async () => null,
|
|
44
|
+
playerSays: (actor, text) => said.push(`player -> ${actor}: ${text}`),
|
|
45
|
+
log: () => {},
|
|
46
|
+
};
|
|
47
|
+
return host; // set host._state = state so conditions can read flags
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tablefolk/script",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scripted stories for games and chat apps: world flags and facts, one condition language, actions, a step runner for workflows (menus as steps the player answers), triggers, and a validator. No DOM; runs in the browser and Node.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./*": "./*"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/runvnc/tablefolk.git",
|
|
15
|
+
"directory": "libs/script"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/runvnc/tablefolk/tree/main/libs/script",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"tablefolk",
|
|
20
|
+
"browser",
|
|
21
|
+
"dialogue",
|
|
22
|
+
"workflow",
|
|
23
|
+
"interactive-fiction",
|
|
24
|
+
"game",
|
|
25
|
+
"rpg",
|
|
26
|
+
"llm"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node test.mjs"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/run.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The step runner: workflows ("flows") that characters run, one at a time
|
|
2
|
+
// each. A flow is a list of steps; each may have an "id", and the next one
|
|
3
|
+
// follows unless it says "goto", "end", or branches.
|
|
4
|
+
//
|
|
5
|
+
// await "speech": the next words addressed to the character ("save" keeps them);
|
|
6
|
+
// "turns": waits "turns": N turns
|
|
7
|
+
// set { "set": { "tries": "{{tries}}+1", "world.paid": true } } run variables; world.x are flags
|
|
8
|
+
// take { "take": "lost cat", "else": "stepId" } the player hands it over
|
|
9
|
+
// if { "if": "<condition>", "then": "a", "else": "b" } (expr.js)
|
|
10
|
+
// choose { "choose": "Fight or flee?", "options": { "fight": "s1", "flee": "s2" },
|
|
11
|
+
// "by": ["decider", "model", "default"], "default": "flee" }
|
|
12
|
+
// prompt { "prompt": "...", "parse": "text|line|number|yesno|word", "save": "x" } (asks the model)
|
|
13
|
+
// command { "command": "say(\"hi\")" } a command line; takes the character's turn
|
|
14
|
+
// menu { "menu": "bert-sell", "save": "pick", "options": { "pay": "s1" }, "then": "s2",
|
|
15
|
+
// "other": "s3", "closed": "s4" } the player chooses: an item's id branches by
|
|
16
|
+
// "options" (else "then"); words that fit none are saved and go to "other"
|
|
17
|
+
// (else the menu asks again); closing goes to "closed" (else the flow ends)
|
|
18
|
+
// do { "do": { "reply": "...", "act": [...], "set": {...}, "give": "..." } } actions.js
|
|
19
|
+
// goto / end, and "max": N visits then "exit"
|
|
20
|
+
//
|
|
21
|
+
// const runner = createRunner({ flows: () => scenario.workflows, state, host, store, choosers });
|
|
22
|
+
// runner.start(actorId, name, input) / stop / active / pause / resume
|
|
23
|
+
// await runner.advance(actorId, { until: 'turn' | 'wait' }) -> { acted }
|
|
24
|
+
// 'turn': stops after a command that took the character's turn; 'wait': runs on
|
|
25
|
+
// until the flow waits (speech, turns, its end): a flow started from a menu
|
|
26
|
+
// store: { get(actorId) -> run | null, set(actorId, run | null) } (runs are saved with the world)
|
|
27
|
+
// host: actions.js's, plus hear(actorId) -> text | null, ask(actorId, question, parse, options) ->
|
|
28
|
+
// text | null (no model: null), decide?(actorId, question, options) -> { choice, confidence },
|
|
29
|
+
// menu?(actorId, ref) -> { item } | { other, text } | null, log?(text), scope(me, vars)
|
|
30
|
+
// Runs keep the step's id (or "#<index>" for a step without one) and a version,
|
|
31
|
+
// so a saved run survives edits that add or move steps.
|
|
32
|
+
import { check, render, findItem } from './expr.js';
|
|
33
|
+
import { perform, arith } from './actions.js';
|
|
34
|
+
|
|
35
|
+
export const RUN_VERSION = 2;
|
|
36
|
+
const stepKey = (def, i) => def.steps[i]?.id ?? '#' + i;
|
|
37
|
+
function indexOf(def, ref) {
|
|
38
|
+
if (ref == null) return -1;
|
|
39
|
+
if (typeof ref === 'number') return ref; // old saves: an index
|
|
40
|
+
if (/^#\d+$/.test(ref)) return +ref.slice(1);
|
|
41
|
+
return def.steps.findIndex(s => s.id === ref);
|
|
42
|
+
}
|
|
43
|
+
function parseAnswer(text, how, options = []) {
|
|
44
|
+
const t = String(text ?? '').trim();
|
|
45
|
+
switch (how) {
|
|
46
|
+
case 'number': { const m = t.replace(/,/g, '').match(/-?\d+(\.\d+)?/); return m ? +m[0] : 0; }
|
|
47
|
+
case 'yesno': return /^\W*(yes|yeah|yep|sure|ok|okay|true|y)\b/i.test(t) ? 'yes' : 'no';
|
|
48
|
+
case 'word': return (t.match(/[A-Za-z0-9_-]+/) || [''])[0].toLowerCase();
|
|
49
|
+
case 'choice': { const low = t.toLowerCase(); return options.find(o => low.startsWith(o.toLowerCase())) || options.find(o => low.includes(o.toLowerCase())) || null; }
|
|
50
|
+
case 'line': return t.split('\n').map(s => s.trim()).find(Boolean)?.replace(/^say\((.*)\)$/i, '$1').replace(/^["'“]|["'”]$/g, '') || '';
|
|
51
|
+
default: return t;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export { parseAnswer };
|
|
55
|
+
|
|
56
|
+
export function createRunner({ flows, state, host, store, choosers = ['decider', 'model', 'default'], triggers = null, maxSteps = 40, deciderMin = 0.5 }) {
|
|
57
|
+
const defs = () => flows() || {};
|
|
58
|
+
const log = t => host.log?.(t);
|
|
59
|
+
function start(actor, name, input = '') {
|
|
60
|
+
const def = defs()[name];
|
|
61
|
+
if (!def?.steps?.length) { log(`${actor}: no workflow called ${name}`); return false; }
|
|
62
|
+
const at = def.start != null ? (typeof def.start === 'number' ? stepKey(def, def.start) : def.start) : stepKey(def, 0);
|
|
63
|
+
store.set(actor, { v: RUN_VERSION, name, at, vars: { input: String(input ?? ''), ...(def.vars || {}) }, visits: {}, wait: 0, paused: false });
|
|
64
|
+
log(`${actor} [workflow] starts ${name}${input ? ' (' + input + ')' : ''}`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
function stop(actor, why = '') {
|
|
68
|
+
const run = store.get(actor); if (!run) return;
|
|
69
|
+
store.set(actor, null);
|
|
70
|
+
log(`${actor} [workflow] ${run.name} ended${why ? ': ' + why : ''}`);
|
|
71
|
+
host.ended?.(actor, run);
|
|
72
|
+
}
|
|
73
|
+
const active = actor => !!store.get(actor);
|
|
74
|
+
const pause = actor => { const r = store.get(actor); if (r) { r.paused = true; store.set(actor, r); } };
|
|
75
|
+
const resume = actor => { const r = store.get(actor); if (r) { r.paused = false; store.set(actor, r); } };
|
|
76
|
+
|
|
77
|
+
async function choose(actor, s, run, scope) {
|
|
78
|
+
const options = Object.keys(s.options || {}), q = render(s.choose, scope);
|
|
79
|
+
for (const who of [].concat(s.by || choosers)) {
|
|
80
|
+
if (who === 'default') return s.default ?? options[0];
|
|
81
|
+
if (who === 'decider' && host.decide) {
|
|
82
|
+
const r = await host.decide(actor, q, options).catch(() => null);
|
|
83
|
+
if (r?.choice && (r.confidence ?? 1) >= (s.min ?? deciderMin)) return r.choice;
|
|
84
|
+
}
|
|
85
|
+
if (who === 'model') {
|
|
86
|
+
const text = await host.ask(actor, q, 'choice', options);
|
|
87
|
+
const p = text == null ? null : parseAnswer(text, 'choice', options);
|
|
88
|
+
if (p) return p;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return s.default ?? options[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Run steps until the flow waits, ends, or (until: 'turn') a command took the turn.
|
|
95
|
+
async function advance(actor, { until = 'turn' } = {}) {
|
|
96
|
+
let acted = false;
|
|
97
|
+
for (let guard = 0; ; guard++) {
|
|
98
|
+
const run = store.get(actor); if (!run || run.paused) return { acted };
|
|
99
|
+
const def = defs()[run.name]; if (!def) { stop(actor, 'definition missing'); return { acted }; }
|
|
100
|
+
if (run.at === '#end') { stop(actor); return { acted }; }
|
|
101
|
+
const i = indexOf(def, run.at);
|
|
102
|
+
if (i < 0 || i >= def.steps.length) { stop(actor, i < 0 && run.at != null ? `step ${run.at} no longer exists` : ''); return { acted }; }
|
|
103
|
+
if (guard >= maxSteps) { stop(actor, 'too many steps without a pause'); return { acted }; }
|
|
104
|
+
const s = def.steps[i], key = stepKey(def, i);
|
|
105
|
+
run.at = key; run.visits[key] = (run.visits[key] || 0) + 1;
|
|
106
|
+
if (s.max && run.visits[key] > s.max) {
|
|
107
|
+
if (s.exit == null || indexOf(def, s.exit) < 0) { stop(actor, `step ${key} ran ${s.max} times`); return { acted }; }
|
|
108
|
+
run.at = s.exit; store.set(actor, run); continue;
|
|
109
|
+
}
|
|
110
|
+
const go = ref => { run.at = ref == null ? null : ref; };
|
|
111
|
+
const next = () => { if (s.end) run.at = '#end'; else if (s.goto != null) run.at = s.goto; else run.at = stepKey(def, i + 1); };
|
|
112
|
+
const scope = host.scope(actor, run.vars);
|
|
113
|
+
try {
|
|
114
|
+
if (s.await) {
|
|
115
|
+
if (s.await === 'turns') {
|
|
116
|
+
if (until === 'wait' && (run.wait || 0) + 1 < (+s.turns || 1)) { run.visits[key]--; store.set(actor, run); return { acted }; }
|
|
117
|
+
run.wait = (run.wait || 0) + 1;
|
|
118
|
+
if (run.wait < (+s.turns || 1)) { run.visits[key]--; store.set(actor, run); return { acted }; }
|
|
119
|
+
run.wait = 0; next(); store.set(actor, run); continue;
|
|
120
|
+
}
|
|
121
|
+
const heard = host.hear(actor);
|
|
122
|
+
if (heard == null) { run.visits[key]--; store.set(actor, run); return { acted }; }
|
|
123
|
+
if (s.save) run.vars[s.save] = heard;
|
|
124
|
+
next(); store.set(actor, run); continue;
|
|
125
|
+
}
|
|
126
|
+
if (s.set) {
|
|
127
|
+
for (const [k, v] of Object.entries(s.set)) {
|
|
128
|
+
const val = typeof v === 'string' ? render(v, scope) : v;
|
|
129
|
+
if (/^world\./i.test(k)) state.set(k, val); else run.vars[k] = arith(run.vars[k], val);
|
|
130
|
+
}
|
|
131
|
+
if (s.take == null && s.if == null && s.command == null) { next(); store.set(actor, run); continue; }
|
|
132
|
+
}
|
|
133
|
+
if (s.take != null) {
|
|
134
|
+
const item = render(s.take, scope), ok = host.transfer('player', actor, item);
|
|
135
|
+
if (ok) triggers?.fire('given', { who: actor, item, from: 'player' });
|
|
136
|
+
if (s.save) run.vars[s.save] = ok ? 'yes' : 'no';
|
|
137
|
+
if (!ok && s.else != null) { go(s.else); store.set(actor, run); continue; }
|
|
138
|
+
next(); store.set(actor, run); continue;
|
|
139
|
+
}
|
|
140
|
+
if (s.if != null) {
|
|
141
|
+
const ok = check(s.if, scope);
|
|
142
|
+
const to = ok ? s.then : s.else;
|
|
143
|
+
if (to != null) go(to); else run.at = stepKey(def, i + 1);
|
|
144
|
+
store.set(actor, run); continue;
|
|
145
|
+
}
|
|
146
|
+
if (s.choose != null) {
|
|
147
|
+
const pickd = await choose(actor, s, run, scope);
|
|
148
|
+
if (s.save) run.vars[s.save] = pickd;
|
|
149
|
+
go(s.options?.[pickd] ?? stepKey(def, i + 1)); store.set(actor, run); continue;
|
|
150
|
+
}
|
|
151
|
+
if (s.prompt != null) {
|
|
152
|
+
const text = await host.ask(actor, render(s.prompt, scope), s.parse || 'text');
|
|
153
|
+
if (s.save) run.vars[s.save] = text == null ? (s.default ?? '') : parseAnswer(text, s.parse || 'text');
|
|
154
|
+
next(); store.set(actor, run); continue;
|
|
155
|
+
}
|
|
156
|
+
if (s.menu != null) {
|
|
157
|
+
if (!host.menu) { next(); store.set(actor, run); continue; }
|
|
158
|
+
store.set(actor, run);
|
|
159
|
+
const r = await host.menu(actor, s.menu);
|
|
160
|
+
const cur = store.get(actor); if (cur !== run && cur?.name !== run.name) return { acted }; // replaced meanwhile
|
|
161
|
+
if (!r) { if (s.closed != null) { go(s.closed); store.set(actor, run); continue; } stop(actor, 'menu closed'); return { acted }; }
|
|
162
|
+
if (r.item) {
|
|
163
|
+
if (s.save) run.vars[s.save] = r.item.id;
|
|
164
|
+
go(s.options?.[r.item.id] ?? s.then ?? stepKey(def, i + 1)); store.set(actor, run); acted = true; continue;
|
|
165
|
+
}
|
|
166
|
+
if (s.save) run.vars[s.save] = r.text ?? '';
|
|
167
|
+
if (s.other != null) { go(s.other); store.set(actor, run); continue; }
|
|
168
|
+
run.visits[key]--; store.set(actor, run); continue; // asks again
|
|
169
|
+
}
|
|
170
|
+
if (s.do != null) {
|
|
171
|
+
const r = await perform(s.do, { host, state, me: actor, vars: run.vars, runner: api, triggers, inRun: true });
|
|
172
|
+
if (r.started) return { acted: true }; // another flow replaced this one
|
|
173
|
+
if (!r.ok && s.else != null) { go(s.else); store.set(actor, run); continue; }
|
|
174
|
+
next(); store.set(actor, run); acted = true; continue;
|
|
175
|
+
}
|
|
176
|
+
if (s.command != null) {
|
|
177
|
+
const line = render(s.command, scope).replace(/"([^"]*)"/g, (m, inner) => '"' + inner.replace(/"/g, "'") + '"');
|
|
178
|
+
next(); store.set(actor, run);
|
|
179
|
+
log(`${actor} [workflow] ${line}`);
|
|
180
|
+
const r = await host.run(actor, line);
|
|
181
|
+
acted = true;
|
|
182
|
+
if (run.at === '#end') { stop(actor); return { acted }; }
|
|
183
|
+
if (until === 'turn' && r?.tookTurn !== false) return { acted };
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
next(); store.set(actor, run);
|
|
187
|
+
if (run.at === '#end') { stop(actor); return { acted }; }
|
|
188
|
+
} catch (e) {
|
|
189
|
+
stop(actor, 'error: ' + e.message);
|
|
190
|
+
return { acted };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const api = { start, stop, active, pause, resume, advance, get: actor => store.get(actor), describe: () => Object.entries(defs()).map(([n, d]) => `${n}${d.description ? ' (' + d.description + ')' : ''}`) };
|
|
195
|
+
return api;
|
|
196
|
+
}
|
|
197
|
+
export { findItem };
|
package/state.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// World flags and the facts that hold while they do.
|
|
2
|
+
//
|
|
3
|
+
// const state = createState({ flags: () => myFlagsObject, facts: () => scenario.facts });
|
|
4
|
+
// state.get('cat_found'); state.set('favours', '+1'); state.all()
|
|
5
|
+
// state.on(({ name, value, was }) => ...) every change
|
|
6
|
+
// state.facts(meId, scope) -> texts facts whose flag holds, for this character
|
|
7
|
+
// flags: where the values live (an object you save with your world; changed in
|
|
8
|
+
// place). Names may be written world.x or x: the same flag either way.
|
|
9
|
+
// Keys starting with "__" are the library's own (triggers fired once).
|
|
10
|
+
import { applyValue, truthy, render } from './expr.js';
|
|
11
|
+
|
|
12
|
+
export function createState({ flags = null, facts = () => ({}) } = {}) {
|
|
13
|
+
let own = {};
|
|
14
|
+
const store = () => (flags ? flags() : own) || own;
|
|
15
|
+
const key = n => String(n).replace(/^world\./i, '');
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
return {
|
|
18
|
+
get: n => store()[key(n)],
|
|
19
|
+
set(n, value) {
|
|
20
|
+
const k = key(n), s = store(), was = s[k], v = applyValue(was, value);
|
|
21
|
+
s[k] = v;
|
|
22
|
+
for (const fn of listeners) { try { fn({ name: k, value: v, was }); } catch (e) { console.warn('[script] flag listener', e); } }
|
|
23
|
+
return v;
|
|
24
|
+
},
|
|
25
|
+
all: () => Object.fromEntries(Object.entries(store()).filter(([k]) => !k.startsWith('__'))),
|
|
26
|
+
on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
27
|
+
// the facts of flags that hold: "text", or { text, for: [ids] }
|
|
28
|
+
facts(me, scope = {}) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const [k, f] of Object.entries(facts() || {})) {
|
|
31
|
+
if (!truthy(store()[k])) continue;
|
|
32
|
+
const text = typeof f === 'string' ? f : f?.text; if (!text) continue;
|
|
33
|
+
if (Array.isArray(f?.for) && !f.for.map(s => String(s).toLowerCase()).includes(String(me).toLowerCase())) continue;
|
|
34
|
+
out.push(render(text, { ...scope, me }));
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
},
|
|
38
|
+
// replace every value (a new world): returns the new object, in case the caller keeps it
|
|
39
|
+
reset(start = {}) { const s = store(); for (const k of Object.keys(s)) delete s[k]; Object.assign(s, start || {}); return s; },
|
|
40
|
+
raw: store,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/triggers.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Triggers: things that happen by themselves when the story moves.
|
|
2
|
+
//
|
|
3
|
+
// "triggers": [
|
|
4
|
+
// { "on": "flag", "flag": "cat_found", "to": true, "as": "pell", "do": { "reply": "Whiskers is home!" }, "once": true },
|
|
5
|
+
// { "on": "given", "who": "pell", "item": "lost cat", "do": { "set": { "cat_found": true } } },
|
|
6
|
+
// { "on": "start", "as": "hobb", "do": { "start": "open_shop" } },
|
|
7
|
+
// { "on": "flag", "flag": "riddle_solved", "if": "world.cat_found", "as": "hobb", "do": { "act": "emote(dance)" } }
|
|
8
|
+
// ]
|
|
9
|
+
// on: "flag" (a flag changed; "to": only to that value), "given" (the player
|
|
10
|
+
// handed a character an item; "who", "item" narrow it), "start" (the world
|
|
11
|
+
// began). "if": a condition; "once": at most once per world (remembered in
|
|
12
|
+
// the flags as __fired). "as": the character the actions run as. A trigger
|
|
13
|
+
// set off while another runs is skipped (no chains, no loops).
|
|
14
|
+
//
|
|
15
|
+
// const triggers = createTriggers({ list: () => scenario.triggers, state, host, runner });
|
|
16
|
+
// triggers.fire('flag', { name, value }) — state changes are watched for you
|
|
17
|
+
import { check, itemMatches, applyValue } from './expr.js';
|
|
18
|
+
import { perform } from './actions.js';
|
|
19
|
+
|
|
20
|
+
export function createTriggers({ list, state, host, runner = null }) {
|
|
21
|
+
let busy = false;
|
|
22
|
+
const fired = () => (state.raw().__fired ||= []);
|
|
23
|
+
const api = {
|
|
24
|
+
async fire(on, data = {}) {
|
|
25
|
+
if (busy) return;
|
|
26
|
+
const all = list() || [];
|
|
27
|
+
const hits = all.map((t, i) => [t, i]).filter(([t, i]) => {
|
|
28
|
+
if (!t || t.on !== on || (t.once && fired().includes(i))) return false;
|
|
29
|
+
if (on === 'flag' && (t.flag != null && String(t.flag).replace(/^world\./i, '') !== data.name)) return false;
|
|
30
|
+
if (on === 'flag' && 'to' in t && String(applyValue(undefined, t.to)) !== String(data.value)) return false;
|
|
31
|
+
if (on === 'given' && ((t.who && String(t.who).toLowerCase() !== String(data.who).toLowerCase()) || (t.item && !itemMatches(data.item, t.item)))) return false;
|
|
32
|
+
return check(t.if, host.scope(t.as || data.who || null, {}));
|
|
33
|
+
});
|
|
34
|
+
if (!hits.length) return;
|
|
35
|
+
busy = true;
|
|
36
|
+
try {
|
|
37
|
+
for (const [t, i] of hits) {
|
|
38
|
+
if (t.once) fired().push(i);
|
|
39
|
+
host.log?.(`trigger ${on}${t.flag ? ' ' + t.flag : ''}${t.as ? ' as ' + t.as : ''}`);
|
|
40
|
+
const me = t.as || data.who || null;
|
|
41
|
+
const r = await perform(t.do, { host, state, me, vars: {}, runner, triggers: null });
|
|
42
|
+
if (r.started && me) await runner?.advance(me, { until: 'wait' });
|
|
43
|
+
}
|
|
44
|
+
} finally { busy = false; }
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
state.on(({ name, value }) => { api.fire('flag', { name, value }); });
|
|
48
|
+
return api;
|
|
49
|
+
}
|
package/validate.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// validate(scenario) -> [{ level: 'error' | 'warning', where, message }]
|
|
2
|
+
// Checks the scripted parts of a scenario before anyone plays it (the world
|
|
3
|
+
// generator writes these, so mistakes are likely):
|
|
4
|
+
// conditions that do not parse; goto/then/else/exit/other/closed/options
|
|
5
|
+
// targets that do not exist; steps nothing reaches; menus and workflows named
|
|
6
|
+
// but missing; flags read but never set nor given a starting value; a
|
|
7
|
+
// "choose" with no default, or a flow menu step with no "other", in a world
|
|
8
|
+
// with no model ("brain": "none"); triggers with an unknown "on".
|
|
9
|
+
import { parse, ExprError } from './expr.js';
|
|
10
|
+
|
|
11
|
+
const TRIGGER_ON = ['flag', 'given', 'start'];
|
|
12
|
+
export function validate(scn = {}) {
|
|
13
|
+
const out = [], err = (where, message) => out.push({ level: 'error', where, message }), warn = (where, message) => out.push({ level: 'warning', where, message });
|
|
14
|
+
const menus = scn.menus || {}, flows = scn.workflows || {}, noModel = scn.brain === 'none';
|
|
15
|
+
const read = new Map(), set = new Set(Object.keys(scn.flags || {}));
|
|
16
|
+
const flagsIn = (text, where) => { for (const m of String(text ?? '').matchAll(/world\.([\w-]+)/gi)) if (!read.has(m[1])) read.set(m[1], where); };
|
|
17
|
+
const cond = (text, where) => {
|
|
18
|
+
if (text == null || typeof text === 'boolean') return;
|
|
19
|
+
for (const t of [].concat(text)) { try { parse(t); } catch (e) { err(where, e instanceof ExprError ? e.message : String(e)); } flagsIn(t, where); }
|
|
20
|
+
};
|
|
21
|
+
const sets = obj => { for (const k of Object.keys(obj || {})) set.add(k.replace(/^world\./i, '')); };
|
|
22
|
+
const actions = (a, where) => {
|
|
23
|
+
if (!a) return;
|
|
24
|
+
sets(a.set);
|
|
25
|
+
for (const t of [a.reply, a.say, ...[].concat(a.act ?? [])]) flagsIn(t, where);
|
|
26
|
+
const st = typeof a.start === 'string' ? a.start : a.start?.name;
|
|
27
|
+
if (st && !flows[st]) err(where, `workflow "${st}" does not exist`);
|
|
28
|
+
if (a.workflow && !flows[a.workflow]) err(where, `workflow "${a.workflow}" does not exist`);
|
|
29
|
+
};
|
|
30
|
+
const menuRef = (ref, where) => { if (typeof ref === 'string' && !menus[ref]) err(where, `menu "${ref}" does not exist`); };
|
|
31
|
+
|
|
32
|
+
// menus a workflow opens: the workflow acts on the choice, the items need no actions
|
|
33
|
+
const flowMenus = new Set(Object.values(flows).flatMap(f => (f?.steps || []).map(s => s?.menu).filter(x => typeof x === 'string')));
|
|
34
|
+
for (const [id, m] of Object.entries(menus)) {
|
|
35
|
+
if (!Array.isArray(m?.items) || !m.items.length) { err(`menus.${id}`, 'a menu needs items'); continue; }
|
|
36
|
+
m.items.forEach((it, n) => {
|
|
37
|
+
const where = `menus.${id}.items[${it?.id || n}]`;
|
|
38
|
+
cond(it?.if, where); actions(it, where); menuRef(it?.menu, where);
|
|
39
|
+
if (noModel && it && !flowMenus.has(id) && !it.menu && !it.do && it.reply == null && it.act == null && it.set == null && it.take == null && it.give == null && !it.workflow && !it.start && !it.stop && !/^(bye|leave|close|exit|goodbye)$/i.test(it.id || ''))
|
|
40
|
+
warn(where, 'nothing happens in a world with no model: give it a reply or act');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
for (const a of scn.agents || []) {
|
|
44
|
+
const where = `agents.${a?.id}`;
|
|
45
|
+
if (Array.isArray(a?.menu)) a.menu.forEach(e => { cond(e?.if, where + '.menu'); menuRef(e?.menu, where + '.menu'); });
|
|
46
|
+
else menuRef(a?.menu, where + '.menu');
|
|
47
|
+
if (a?.workflow && !flows[a.workflow]) err(where, `workflow "${a.workflow}" does not exist`);
|
|
48
|
+
}
|
|
49
|
+
for (const [name, f] of Object.entries(flows)) {
|
|
50
|
+
const steps = f?.steps; if (!Array.isArray(steps) || !steps.length) { err(`workflows.${name}`, 'a workflow needs steps'); continue; }
|
|
51
|
+
const ids = new Map(steps.map((s, i) => [s?.id ?? '#' + i, i]));
|
|
52
|
+
const at = ref => ref == null ? -1 : typeof ref === 'number' ? ref : ids.has(ref) ? ids.get(ref) : -2;
|
|
53
|
+
const targets = (s, i) => {
|
|
54
|
+
const t = [];
|
|
55
|
+
for (const k of ['goto', 'then', 'else', 'exit', 'other', 'closed']) if (s[k] != null) t.push([k, s[k]]);
|
|
56
|
+
for (const [o, ref] of Object.entries(s.options || {})) t.push([`options.${o}`, ref]);
|
|
57
|
+
return t;
|
|
58
|
+
};
|
|
59
|
+
const reach = new Set(), todo = [f.start != null ? at(f.start) : 0];
|
|
60
|
+
while (todo.length) {
|
|
61
|
+
const i = todo.pop(); if (i < 0 || i >= steps.length || reach.has(i)) continue; reach.add(i);
|
|
62
|
+
const s = steps[i] || {};
|
|
63
|
+
const branches = targets(s, i).map(([, r]) => at(r)).filter(x => x >= 0);
|
|
64
|
+
todo.push(...branches);
|
|
65
|
+
const falls = !s.end && s.goto == null && !(s.if != null && s.then != null && s.else != null) && !(s.choose != null && s.options);
|
|
66
|
+
if (falls) todo.push(i + 1);
|
|
67
|
+
}
|
|
68
|
+
steps.forEach((s, i) => {
|
|
69
|
+
const where = `workflows.${name}.steps[${s?.id || i}]`;
|
|
70
|
+
if (!s || typeof s !== 'object') { err(where, 'a step must be an object'); return; }
|
|
71
|
+
for (const [k, ref] of targets(s, i)) if (at(ref) === -2) err(where, `${k} goes to "${ref}", which is not a step`);
|
|
72
|
+
cond(s.if, where); sets(Object.fromEntries(Object.entries(s.set || {}).filter(([k]) => /^world\./i.test(k))));
|
|
73
|
+
for (const t of [s.command, s.prompt, s.choose]) flagsIn(t, where);
|
|
74
|
+
actions(s.do, where); menuRef(s.menu, where);
|
|
75
|
+
if (noModel && s.choose != null && s.default == null) warn(where, 'choose has no "default", and this world has no model to answer it');
|
|
76
|
+
if (noModel && s.prompt != null) warn(where, 'prompt needs a model, and this world has none');
|
|
77
|
+
if (s.menu != null && noModel && s.other == null) warn(where, 'words that fit no choice make this menu ask again: add "other" to branch on them');
|
|
78
|
+
if (!reach.has(i)) warn(where, 'nothing leads to this step');
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
for (const [i, t] of (scn.triggers || []).entries()) {
|
|
82
|
+
const where = `triggers[${i}]`;
|
|
83
|
+
if (!TRIGGER_ON.includes(t?.on)) err(where, `"on" must be one of ${TRIGGER_ON.join(', ')}`);
|
|
84
|
+
cond(t?.if, where); actions(t?.do, where);
|
|
85
|
+
if (t?.on === 'flag' && t.flag) flagsIn('world.' + String(t.flag).replace(/^world\./i, ''), where);
|
|
86
|
+
}
|
|
87
|
+
if (typeof scn.goal?.win?.if === 'string') cond(scn.goal.win.if, 'goal.win');
|
|
88
|
+
for (const [f, where] of read) if (!set.has(f)) warn(where, `flag "${f}" is read but never set and has no starting value in "flags"`);
|
|
89
|
+
for (const k of Object.keys(scn.facts || {})) if (!set.has(k)) warn(`facts.${k}`, `flag "${k}" is never set, so this fact never shows`);
|
|
90
|
+
return out;
|
|
91
|
+
}
|