-
-
-
-
+
+
+
Fate Check
+
+ + + + + + + + + +
+ +
+ Select a probability and ask. +
+
+ +
+
Random Event Check
+

Roll at the start of each scene. A random event occurs if the result is equal to or under the chaos factor.

+ +
+ Roll to check. +
+
+
+ +
+
+
Meaning Tables
+

Use these to interpret random events, oracle results, or whenever you need narrative inspiration.

+ +
+
+
Action
+
+
+
+
Descriptor
+
+
+
+
+ +
+
Individual Tables
+
+ + +
+
+
+ +
+
+
NPC Generator — UNE
+

Generate an NPC's core traits. Drop the results into your NPC note.

+ + +
+ +
+
NPC Relationship
+

How does this NPC feel about the PC?

+ +
+ Roll to find out. +
+
+
+ +
+
+
Dice Pool Builder
+ +
+ + + + + + + + +
+ +
+ No dice in pool — click above to add. +
+ + +
+ + +
+ +
+ Build a pool and roll. +
+
+ +
+
Custom Roll
+
+ +
+ d + +
+
+
+ + +
+
+ Enter a quantity and die size. +
+
+ +
+
Percentile
+
+ + +
+
+ Roll tens or ones. +
+
+ +
+
Ability Score
+ + +
+ Roll for an ability score. +
+
+
+
@@ -150,4 +302,8 @@ + + + + diff --git a/public/js/api.js b/public/js/api.js index 81477c8..6a579d8 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -49,3 +49,7 @@ export function deleteCampaign(id) { export function getSystems() { return request('/systems'); } + +export function getTable(name) { + return request(`/tables/${name}`); +} diff --git a/public/js/dice.js b/public/js/dice.js index c1ebc26..13ffede 100644 --- a/public/js/dice.js +++ b/public/js/dice.js @@ -1 +1,189 @@ -// Mythic Oracle — dice +// Mythic Oracle — Dice: pool builder, custom roll, percentile, ability score +// Pure frontend — no backend calls. + +const dicePool = { 4: 0, 6: 0, 8: 0, 10: 0, 12: 0, 20: 0, 100: 0, 2: 0 }; +const POOL_MAX = 25; +const DIE_ORDER = [4, 6, 8, 10, 12, 20, 100, 2]; + +function addDie(sides) { + const total = DIE_ORDER.reduce((s, d) => s + dicePool[d], 0); + if (total >= POOL_MAX) return; + dicePool[sides]++; + updatePoolDisplay(); +} + +function clearPool() { + DIE_ORDER.forEach((d) => { + dicePool[d] = 0; + }); + updatePoolDisplay(); + const box = document.getElementById('poolResult'); + box.className = 'dice-result-box'; + box.innerHTML = 'Build a pool and roll.'; +} + +function updatePoolDisplay() { + const total = DIE_ORDER.reduce((s, d) => s + dicePool[d], 0); + const display = document.getElementById('poolDisplay'); + const capMsg = document.getElementById('poolCapMsg'); + + const parts = DIE_ORDER.filter((d) => dicePool[d] > 0).map((d) => `${dicePool[d]}d${d}`); + if (parts.length === 0) { + display.innerHTML = 'No dice in pool — click above to add.'; + } else { + display.textContent = parts.join(' + '); + } + + const capped = total >= POOL_MAX; + capMsg.style.display = capped ? '' : 'none'; + document.querySelectorAll('#diceGrid .die-btn').forEach((btn) => { + btn.disabled = capped; + }); +} + +function rollPool() { + const total = DIE_ORDER.reduce((s, d) => s + dicePool[d], 0); + const box = document.getElementById('poolResult'); + if (total === 0) { + box.className = 'dice-result-box animate'; + box.innerHTML = 'Add dice to the pool first.'; + void box.offsetWidth; + return; + } + + const results = {}; + let grandTotal = 0; + DIE_ORDER.forEach((sides) => { + if (dicePool[sides] > 0) { + results[sides] = []; + for (let i = 0; i < dicePool[sides]; i++) { + const roll = Math.floor(Math.random() * sides) + 1; + results[sides].push(roll); + grandTotal += roll; + } + } + }); + + const activeDice = DIE_ORDER.filter((s) => results[s]); + + const groupedHTML = activeDice + .map((sides) => { + const rolls = results[sides]; + const sum = rolls.reduce((a, b) => a + b, 0); + const rollStr = rolls.length > 1 ? `${rolls.join(' + ')} = ${sum}` : `${sum}`; + return `
d${sides}: ${rollStr}
`; + }) + .join(''); + + box.className = 'dice-result-box animate'; + box.innerHTML = ` +
${grandTotal}
+
+
${groupedHTML}
+ `; + void box.offsetWidth; +} + +function validateCustomRoll() { + const qty = document.getElementById('customQty').value; + const sides = document.getElementById('customSides').value; + const btn = document.getElementById('customRollBtn'); + + const isPosInt = (v) => v !== '' && Number.isInteger(Number(v)) && Number(v) > 0; + const valid = isPosInt(qty) && Number(qty) <= 25 && isPosInt(sides); + + btn.disabled = !valid; +} + +function rollCustom() { + const qty = Number(document.getElementById('customQty').value); + const sides = Number(document.getElementById('customSides').value); + const box = document.getElementById('customResult'); + + const rolls = []; + let total = 0; + for (let i = 0; i < qty; i++) { + const roll = Math.floor(Math.random() * sides) + 1; + rolls.push(roll); + total += roll; + } + + box.className = 'dice-result-box animate'; + box.innerHTML = ` +
${total}
+
${qty}d${sides}  [${rolls.join(', ')}]
+ `; + void box.offsetWidth; +} + +function clearCustomRoll() { + const box = document.getElementById('customResult'); + box.className = 'dice-result-box'; + box.innerHTML = 'Enter a quantity and die size.'; +} + +function quickRoll(sides, qty, mod, label, boxId) { + const rolls = []; + for (let i = 0; i < qty; i++) { + rolls.push(Math.floor(Math.random() * sides) + 1); + } + let total = rolls.reduce((a, b) => a + b, 0) + mod; + if (label.includes('drop lowest')) { + const sorted = [...rolls].sort((a, b) => a - b); + sorted.shift(); + total = sorted.reduce((a, b) => a + b, 0); + } + const box = document.getElementById(boxId); + box.classList.add('animate'); + box.innerHTML = ` +
${total}
+
${label}  [${rolls.join(', ')}]
+ `; + void box.offsetWidth; +} + +function rollAbilityArray() { + const lines = []; + for (let i = 0; i < 6; i++) { + const rolls = []; + for (let j = 0; j < 4; j++) { + rolls.push(Math.floor(Math.random() * 6) + 1); + } + const sorted = [...rolls].sort((a, b) => a - b); + sorted.shift(); + const total = sorted.reduce((a, b) => a + b, 0); + lines.push(`${rolls.join(', ')}  →  ${total}`); + } + const box = document.getElementById('abilityResult'); + box.className = 'dice-result-box animate'; + box.innerHTML = `
${lines.join('
')}
`; + void box.offsetWidth; +} + +function init() { + document.querySelectorAll('#diceGrid .die-btn').forEach((btn) => { + btn.addEventListener('click', () => addDie(Number(btn.dataset.sides))); + }); + + document.getElementById('poolRollBtn').addEventListener('click', rollPool); + document.getElementById('poolClearBtn').addEventListener('click', clearPool); + + document.getElementById('customQty').addEventListener('input', validateCustomRoll); + document.getElementById('customSides').addEventListener('input', validateCustomRoll); + document.getElementById('customRollBtn').addEventListener('click', rollCustom); + document.getElementById('customClearBtn').addEventListener('click', clearCustomRoll); + + document.getElementById('percentileTensBtn').addEventListener('click', () => { + quickRoll(10, 1, 0, 'Percentile (tens)', 'percentileResult'); + }); + document.getElementById('percentileOnesBtn').addEventListener('click', () => { + quickRoll(10, 1, 0, 'Percentile (ones)', 'percentileResult'); + }); + + document.getElementById('ability4d6Btn').addEventListener('click', () => { + quickRoll(6, 4, -4, '4d6 drop lowest (approx)', 'abilityResult'); + }); + document.getElementById('abilityArrayBtn').addEventListener('click', rollAbilityArray); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/public/js/meaning.js b/public/js/meaning.js index 7163bf4..e1325f8 100644 --- a/public/js/meaning.js +++ b/public/js/meaning.js @@ -1 +1,48 @@ -// Mythic Oracle — meaning tables +// Mythic Oracle — Meaning tables (Action + Descriptor) + +import { getTable } from './api.js'; + +let actionEntries = null; +let descriptorEntries = null; + +async function loadTables() { + if (!actionEntries) { + actionEntries = (await getTable('action')).entries; + } + if (!descriptorEntries) { + descriptorEntries = (await getTable('descriptor')).entries; + } +} + +function renderWord(elId, word) { + const el = document.getElementById(elId); + el.textContent = word; + el.classList.remove('animate'); + void el.offsetWidth; + el.classList.add('animate'); +} + +async function rollAction() { + await loadTables(); + const word = actionEntries[Math.floor(Math.random() * actionEntries.length)]; + renderWord('meaningAction', word); +} + +async function rollDescriptor() { + await loadTables(); + const word = descriptorEntries[Math.floor(Math.random() * descriptorEntries.length)]; + renderWord('meaningDescriptor', word); +} + +async function rollMeaning() { + await rollAction(); + await rollDescriptor(); +} + +function init() { + document.getElementById('meaningRollBtn').addEventListener('click', rollMeaning); + document.getElementById('actionRollBtn').addEventListener('click', rollAction); + document.getElementById('descriptorRollBtn').addEventListener('click', rollDescriptor); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/public/js/oracle.js b/public/js/oracle.js index a1789e2..f1454b2 100644 --- a/public/js/oracle.js +++ b/public/js/oracle.js @@ -1 +1,124 @@ -// Mythic Oracle — oracle +// Mythic Oracle — Fate Check and Random Event Check + +import { getActiveCampaign } from './app.js'; + +const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the oracle.'; + +// Mythic GME v2 probability matrix — base% -> thresholds indexed by chaos factor 1-9 +const probTable = { + 5: [1, 1, 2, 3, 4, 5, 6, 7, 8], + 15: [1, 2, 4, 6, 8, 10, 12, 15, 18], + 25: [2, 4, 7, 11, 15, 19, 23, 27, 32], + 35: [3, 6, 10, 15, 20, 25, 30, 35, 41], + 50: [5, 9, 14, 20, 26, 32, 38, 44, 50], + 65: [9, 14, 20, 28, 36, 44, 52, 60, 68], + 75: [11, 17, 24, 33, 42, 51, 60, 68, 76], + 85: [14, 21, 30, 40, 50, 60, 70, 78, 85], + 95: [18, 27, 38, 50, 62, 73, 82, 89, 95], +}; + +let selectedProb = { label: '50/50', base: 50 }; + +function initProbGrid() { + const buttons = document.querySelectorAll('#probGrid .prob-btn'); + buttons.forEach((btn) => { + btn.addEventListener('click', () => { + buttons.forEach((b) => b.classList.remove('selected')); + btn.classList.add('selected'); + selectedProb = { label: btn.dataset.label, base: Number(btn.dataset.base) }; + }); + }); +} + +function rollFate() { + const box = document.getElementById('fateResult'); + const campaign = getActiveCampaign(); + + if (!campaign) { + box.className = 'result-box'; + box.innerHTML = `${NO_CAMPAIGN_MESSAGE}`; + return; + } + + const chaos = campaign.chaos_factor; + const roll = Math.floor(Math.random() * 100) + 1; + const thresholds = probTable[selectedProb.base]; + const threshold = thresholds[chaos - 1]; + + // Exceptional results occur on doubles (11,22,33...) or within 10% of threshold + const isDouble = roll % 11 === 0 || (roll < 100 && Math.floor(roll / 10) === roll % 10); + const yesResult = roll <= threshold; + + let resultClass, mainText, subText; + + if (yesResult && isDouble) { + resultClass = 'exceptional-yes'; + mainText = 'Exceptional Yes'; + subText = 'Something beyond what was asked occurs in your favor.'; + } else if (!yesResult && isDouble) { + resultClass = 'exceptional-no'; + mainText = 'Exceptional No'; + subText = 'Something beyond a simple no — complications arise.'; + } else if (yesResult) { + resultClass = 'yes'; + mainText = 'Yes'; + subText = 'The answer is affirmative.'; + } else { + resultClass = 'no'; + mainText = 'No'; + subText = 'The answer is negative.'; + } + + // Random event check bundled into fate check (Mythic v2 behavior) + if (roll <= chaos) { + subText += ' · Random event triggered.'; + resultClass = yesResult ? resultClass : 'random-event'; + } + + const mainColorClass = yesResult ? 'yes-color' : resultClass === 'random-event' ? 'blue-color' : 'no-color'; + + box.className = `result-box animate ${resultClass}`; + box.innerHTML = ` +
${mainText}
+
${subText}
+
Roll: ${roll}  |  Threshold: ${threshold}  |  Prob: ${selectedProb.label}  |  CF: ${chaos}
+ `; +} + +function rollEventCheck() { + const box = document.getElementById('eventResult'); + const campaign = getActiveCampaign(); + + if (!campaign) { + box.className = 'event-result'; + box.innerHTML = `${NO_CAMPAIGN_MESSAGE}`; + return; + } + + const chaos = campaign.chaos_factor; + const roll = Math.floor(Math.random() * 10) + 1; + + if (roll <= chaos) { + box.className = 'event-result animate'; + box.style.borderColor = 'var(--info)'; + box.innerHTML = ` +
Random Event!
+
Roll: ${roll}  ≤  CF: ${chaos}
+ `; + } else { + box.className = 'event-result animate'; + box.style.borderColor = 'var(--border)'; + box.innerHTML = ` +
No Event
+
Roll: ${roll}  >  CF: ${chaos} — scene proceeds as expected.
+ `; + } +} + +function init() { + initProbGrid(); + document.getElementById('fateRollBtn').addEventListener('click', rollFate); + document.getElementById('eventRollBtn').addEventListener('click', rollEventCheck); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/public/js/une.js b/public/js/une.js index 6f4cc09..ea6a747 100644 --- a/public/js/une.js +++ b/public/js/une.js @@ -1 +1,60 @@ -// Mythic Oracle — UNE +// Mythic Oracle — UNE (Universal NPC Emulator) + +import { getTable } from './api.js'; + +const tableCache = {}; + +async function loadTable(name) { + if (!tableCache[name]) { + tableCache[name] = (await getTable(name)).entries; + } + return tableCache[name]; +} + +function pickRandom(entries) { + return entries[Math.floor(Math.random() * entries.length)]; +} + +function capitalize(word) { + return word.charAt(0).toUpperCase() + word.slice(1); +} + +async function rollUNE() { + const [verbs, nouns, demeanors, characters] = await Promise.all([ + loadTable('une-motivation-verb'), + loadTable('une-motivation-noun'), + loadTable('une-demeanor'), + loadTable('une-character'), + ]); + + const motivation = `${capitalize(pickRandom(verbs))} ${pickRandom(nouns)}`; + const demeanor = pickRandom(demeanors); + const character = pickRandom(characters); + + document.getElementById('uneMotivation').textContent = motivation; + document.getElementById('uneDemeanor').textContent = demeanor; + document.getElementById('uneCharacter').textContent = character; + + const result = document.getElementById('uneResult'); + result.style.display = 'block'; + result.classList.remove('animate'); + void result.offsetWidth; + result.classList.add('animate'); +} + +async function rollRelationship() { + const relationships = await loadTable('une-relationship'); + const rel = pickRandom(relationships); + const box = document.getElementById('relationshipResult'); + box.innerHTML = `${rel}`; + box.classList.remove('animate'); + void box.offsetWidth; + box.classList.add('animate'); +} + +function init() { + document.getElementById('uneRollBtn').addEventListener('click', rollUNE); + document.getElementById('relationshipRollBtn').addEventListener('click', rollRelationship); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/server/routes/tables.js b/server/routes/tables.js index f577a6c..f017604 100644 --- a/server/routes/tables.js +++ b/server/routes/tables.js @@ -1,4 +1,35 @@ const express = require('express'); +const fs = require('fs'); +const path = require('path'); + const router = express.Router(); +const TABLES_DIR = path.join(__dirname, '..', '..', 'data', 'tables'); +const NAME_PATTERN = /^[a-z0-9-]+$/; + +router.get('/:name', (req, res) => { + const { name } = req.params; + + if (!NAME_PATTERN.test(name)) { + return res.status(400).json({ error: 'invalid table name' }); + } + + const filePath = path.join(TABLES_DIR, `${name}.json`); + + fs.readFile(filePath, 'utf8', (err, data) => { + if (err) { + if (err.code === 'ENOENT') { + return res.status(404).json({ error: 'table not found' }); + } + return res.status(500).json({ error: 'failed to read table' }); + } + + try { + res.json(JSON.parse(data)); + } catch { + res.status(500).json({ error: 'invalid table data' }); + } + }); +}); + module.exports = router;