feat: Phase 4 port Oracle, Meaning, UNE, and Dice

Ports the four core Mythic GME tools from reference/index.html into
the modular structure. Fate Check and Random Event Check preserve
the exact probability matrix and roll logic, now reading chaos
factor live from the active campaign instead of local state. Meaning
and UNE tables are served from data/tables/ via a new tables route.
UNE motivation uses the published verb+noun tables instead of the
reference's flattened phrase list. Dice (pool builder, custom roll,
percentile, ability score) is ported verbatim as pure frontend logic.
This commit is contained in:
claudecode
2026-07-01 00:13:00 -04:00
parent 3a7340975f
commit ec28933623
15 changed files with 1211 additions and 8 deletions
+189 -1
View File
@@ -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 = '<span class="placeholder">Build a pool and roll.</span>';
}
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 = '<span class="placeholder">No dice in pool — click above to add.</span>';
} 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 = '<span class="placeholder">Add dice to the pool first.</span>';
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 `<div class="pool-result-group"><span class="pool-group-type">d${sides}</span>: ${rollStr}</div>`;
})
.join('');
box.className = 'dice-result-box animate';
box.innerHTML = `
<div class="dice-total">${grandTotal}</div>
<div class="dice-divider"></div>
<div class="pool-result-groups">${groupedHTML}</div>
`;
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 = `
<div class="dice-total">${total}</div>
<div class="dice-breakdown">${qty}d${sides} &nbsp;[${rolls.join(', ')}]</div>
`;
void box.offsetWidth;
}
function clearCustomRoll() {
const box = document.getElementById('customResult');
box.className = 'dice-result-box';
box.innerHTML = '<span class="placeholder">Enter a quantity and die size.</span>';
}
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 = `
<div class="dice-total">${total}</div>
<div class="dice-breakdown">${label} &nbsp;[${rolls.join(', ')}]</div>
`;
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(', ')} &nbsp;→&nbsp; ${total}`);
}
const box = document.getElementById('abilityResult');
box.className = 'dice-result-box animate';
box.innerHTML = `<div class="dice-breakdown">${lines.join('<br>')}</div>`;
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);