feat(oracle): add dice, d6 oracle, and table engine

The oracle and CSV table logic live in one dependency-free package so
every source of randomness in the project flows through a single library.
This commit is contained in:
2026-09-13 16:41:13 +08:00
parent 3353c23a0b
commit 02933a483d
6 changed files with 270 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@solo/oracle",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Dice, the d6 yes/no/but oracle, and CSV spark-table engine.",
"main": "src/index.js",
"exports": {
".": "./src/index.js"
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* A small RFC 4180-ish CSV parser. Kept dependency-free so the tooling runs
* with nothing but Node.
*/
/**
* Parse CSV text into an array of rows (each row an array of string fields).
*
* @param {string} text
* @returns {string[][]}
*/
export function parseCsv(text) {
const src = String(text).replace(/^\uFEFF/, '');
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let started = false;
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (inQuotes) {
if (c === '"') {
if (src[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += c;
}
continue;
}
if (c === '"') {
inQuotes = true;
started = true;
} else if (c === ',') {
row.push(field);
field = '';
started = true;
} else if (c === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
started = false;
} else if (c === '\r') {
// Normalize CRLF by ignoring the carriage return.
} else {
field += c;
started = true;
}
}
if (started || field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => !(r.length === 1 && r[0].trim() === ''));
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Dice primitives. Every source of randomness in the project flows through
* here so that the CLI is the single place dice are ever rolled.
*/
const TERM = /^(\d*)d(\d+)$/i;
/**
* Roll a single die with `sides` faces, returning 1..sides.
*
* @param {number} sides
* @param {() => number} [rng]
* @returns {number}
*/
export function rollDie(sides, rng = Math.random) {
if (!Number.isInteger(sides) || sides < 1) {
throw new Error(`Invalid die: d${sides}`);
}
return Math.floor(rng() * sides) + 1;
}
/**
* Roll a dice expression such as `2d6+1`, `d20`, or `3d6-1d4+2`.
*
* @param {string} expr
* @param {() => number} [rng]
* @returns {{ expr: string, total: number, parts: Array<object> }}
*/
export function rollExpr(expr, rng = Math.random) {
const cleaned = String(expr).replace(/\s+/g, '');
if (!cleaned) throw new Error('Empty dice expression');
const terms = cleaned.match(/[+-]?[^+-]+/g);
if (!terms) throw new Error(`Invalid dice expression: ${expr}`);
let total = 0;
const parts = [];
for (const raw of terms) {
const sign = raw.startsWith('-') ? -1 : 1;
const body = raw.replace(/^[+-]/, '');
const match = body.match(TERM);
if (match) {
const count = match[1] === '' ? 1 : Number(match[1]);
const sides = Number(match[2]);
const rolls = [];
for (let i = 0; i < count; i++) rolls.push(rollDie(sides, rng));
const sum = rolls.reduce((a, b) => a + b, 0);
total += sign * sum;
parts.push({ sign, count, sides, rolls, sum });
} else if (/^\d+$/.test(body)) {
const value = Number(body);
total += sign * value;
parts.push({ sign, constant: value });
} else {
throw new Error(`Invalid term: ${raw}`);
}
}
return { expr: cleaned, total, parts };
}
+4
View File
@@ -0,0 +1,4 @@
export { rollDie, rollExpr } from './dice.js';
export { parseCsv } from './csv.js';
export { parseTable, tableRange, rollTable } from './tables.js';
export { D6_ORACLE, oracle } from './oracle.js';
+34
View File
@@ -0,0 +1,34 @@
import { rollDie } from './dice.js';
/**
* The d6 yes/no/but oracle.
*
* 6 Yes, and… (a fortunate extra)
* 5 Yes
* 4 Yes, but… (a cost or complication)
* 3 No, but… (a consolation or opening)
* 2 No
* 1 No, and… (an additional setback)
*
* @type {ReadonlyArray<{ roll: number, outcome: 'yes' | 'no', twist: 'and' | 'but' | null, label: string }>}
*/
export const D6_ORACLE = Object.freeze([
{ roll: 6, outcome: 'yes', twist: 'and', label: 'Yes, and…' },
{ roll: 5, outcome: 'yes', twist: null, label: 'Yes' },
{ roll: 4, outcome: 'yes', twist: 'but', label: 'Yes, but…' },
{ roll: 3, outcome: 'no', twist: 'but', label: 'No, but…' },
{ roll: 2, outcome: 'no', twist: null, label: 'No' },
{ roll: 1, outcome: 'no', twist: 'and', label: 'No, and…' },
]);
/**
* Consult the oracle.
*
* @param {() => number} [rng]
* @returns {{ roll: number, outcome: 'yes' | 'no', twist: 'and' | 'but' | null, label: string }}
*/
export function oracle(rng = Math.random) {
const roll = rollDie(6, rng);
const face = D6_ORACLE.find((f) => f.roll === roll);
return { roll, ...face };
}
+95
View File
@@ -0,0 +1,95 @@
import { parseCsv } from './csv.js';
import { rollDie } from './dice.js';
/**
* Parse a spark table from CSV text.
*
* The header must contain `min`, `max`, and `text`; `tags` is optional and may
* hold several tags separated by `;` or `|`.
*
* @param {string} text
* @returns {Array<{ min: number, max: number, text: string, tags: string[] }>}
*/
export function parseTable(text) {
const rows = parseCsv(text);
if (!rows.length) throw new Error('Table is empty');
const header = rows[0].map((h) => h.trim().toLowerCase());
const iMin = header.indexOf('min');
const iMax = header.indexOf('max');
const iText = header.indexOf('text');
const iTags = header.indexOf('tags');
if (iMin === -1 || iMax === -1 || iText === -1) {
throw new Error('Table header must include min, max, and text columns');
}
return rows.slice(1).map((r, n) => {
const line = n + 2;
const min = Number(r[iMin]);
const max = Number(r[iMax]);
if (!Number.isInteger(min) || !Number.isInteger(max)) {
throw new Error(`Row ${line}: min and max must be integers`);
}
if (min > max) {
throw new Error(`Row ${line}: min (${min}) is greater than max (${max})`);
}
const text = (r[iText] ?? '').trim();
if (!text) throw new Error(`Row ${line}: text is empty`);
const tags =
iTags === -1
? []
: (r[iTags] ?? '')
.split(/[;|]/)
.map((t) => t.trim())
.filter(Boolean);
return { min, max, text, tags };
});
}
/**
* The inclusive range covered by a set of table entries.
*
* @param {Array<{ min: number, max: number }>} entries
* @returns {{ min: number, max: number }}
*/
export function tableRange(entries) {
if (!entries.length) throw new Error('Table has no entries');
return {
min: Math.min(...entries.map((e) => e.min)),
max: Math.max(...entries.map((e) => e.max)),
};
}
/**
* Roll on a table.
*
* Without a tag, a die is rolled across the table's full range and the covering
* entry is returned. With a tag, an entry is chosen uniformly from the entries
* carrying that tag (ranges are not meaningful once the table is filtered).
*
* @param {Array<{ min: number, max: number, text: string, tags: string[] }>} entries
* @param {{ tag?: string, rng?: () => number }} [options]
* @returns {{ roll: number | null, entry: object }}
*/
export function rollTable(entries, { tag, rng = Math.random } = {}) {
if (!entries.length) throw new Error('Table has no entries');
if (tag) {
const pool = entries.filter((e) => e.tags.includes(tag));
if (!pool.length) throw new Error(`No entries tagged "${tag}"`);
const entry = pool[Math.floor(rng() * pool.length)];
return { roll: null, entry };
}
const { min, max } = tableRange(entries);
const roll = rollDie(max - min + 1, rng) + min - 1;
const entry = entries.find((e) => roll >= e.min && roll <= e.max);
if (!entry) throw new Error(`No entry covers roll ${roll}`);
return { roll, entry };
}