diff --git a/packages/oracle/package.json b/packages/oracle/package.json new file mode 100644 index 0000000..72eff4c --- /dev/null +++ b/packages/oracle/package.json @@ -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" + } +} diff --git a/packages/oracle/src/csv.js b/packages/oracle/src/csv.js new file mode 100644 index 0000000..a59e288 --- /dev/null +++ b/packages/oracle/src/csv.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() === '')); +} diff --git a/packages/oracle/src/dice.js b/packages/oracle/src/dice.js new file mode 100644 index 0000000..96e62bb --- /dev/null +++ b/packages/oracle/src/dice.js @@ -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 }} + */ +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 }; +} diff --git a/packages/oracle/src/index.js b/packages/oracle/src/index.js new file mode 100644 index 0000000..bd57840 --- /dev/null +++ b/packages/oracle/src/index.js @@ -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'; diff --git a/packages/oracle/src/oracle.js b/packages/oracle/src/oracle.js new file mode 100644 index 0000000..3583f9b --- /dev/null +++ b/packages/oracle/src/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 }; +} diff --git a/packages/oracle/src/tables.js b/packages/oracle/src/tables.js new file mode 100644 index 0000000..552aed2 --- /dev/null +++ b/packages/oracle/src/tables.js @@ -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 }; +}