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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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() === ''));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user