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:
@@ -49,3 +49,7 @@ export function deleteCampaign(id) {
|
||||
export function getSystems() {
|
||||
return request('/systems');
|
||||
}
|
||||
|
||||
export function getTable(name) {
|
||||
return request(`/tables/${name}`);
|
||||
}
|
||||
|
||||
+189
-1
@@ -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} [${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} [${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(', ')} → ${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);
|
||||
|
||||
+48
-1
@@ -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);
|
||||
|
||||
+124
-1
@@ -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 = `<span class="placeholder">${NO_CAMPAIGN_MESSAGE}</span>`;
|
||||
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 = `
|
||||
<div class="result-main ${mainColorClass}">${mainText}</div>
|
||||
<div class="result-sub">${subText}</div>
|
||||
<div class="result-roll">Roll: ${roll} | Threshold: ${threshold} | Prob: ${selectedProb.label} | CF: ${chaos}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function rollEventCheck() {
|
||||
const box = document.getElementById('eventResult');
|
||||
const campaign = getActiveCampaign();
|
||||
|
||||
if (!campaign) {
|
||||
box.className = 'event-result';
|
||||
box.innerHTML = `<span class="placeholder">${NO_CAMPAIGN_MESSAGE}</span>`;
|
||||
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 = `
|
||||
<div class="result-main blue-color">Random Event!</div>
|
||||
<div class="result-sub">Roll: ${roll} ≤ CF: ${chaos}</div>
|
||||
`;
|
||||
} else {
|
||||
box.className = 'event-result animate';
|
||||
box.style.borderColor = 'var(--border)';
|
||||
box.innerHTML = `
|
||||
<div class="result-main" style="color:var(--text-secondary);">No Event</div>
|
||||
<div class="result-sub">Roll: ${roll} > CF: ${chaos} — scene proceeds as expected.</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initProbGrid();
|
||||
document.getElementById('fateRollBtn').addEventListener('click', rollFate);
|
||||
document.getElementById('eventRollBtn').addEventListener('click', rollEventCheck);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
+60
-1
@@ -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 = `<span class="relationship-text">${rel}</span>`;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user