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