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
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Action",
"entries": [
"Abandon", "Accomplish", "Acquire", "Advance", "Affect", "Antagonize", "Approach", "Arrive",
"Attain", "Attract", "Balance", "Befriend", "Betray", "Block", "Break", "Build", "Capture",
"Change", "Communicate", "Complete", "Conceal", "Conflict", "Confront", "Create", "Damage",
"Deceive", "Decrease", "Delay", "Deny", "Destroy", "Discover", "Dispute", "Dominate", "Encourage",
"Endure", "Escape", "Examine", "Expose", "Fight", "Follow", "Fulfill", "Harm", "Help", "Hide",
"Hinder", "Ignore", "Imprison", "Improve", "Inform", "Investigate", "Journey", "Kill", "Lead",
"Leave", "Lose", "Manipulate", "Move", "Oppose", "Overcome", "Persevere", "Possess", "Prevent",
"Proceed", "Protect", "Pursue", "Realize", "Release", "Remove", "Resist", "Restore", "Reveal",
"Ruin", "Search", "Seek", "Separate", "Stop", "Struggle", "Support", "Take", "Transform", "Travel",
"Trick", "Understand", "Unite", "Weaken", "Work", "Wound"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Descriptor",
"entries": [
"A burden", "A conflict", "A dream", "A enemy", "A fear", "A friend", "A goal", "A group",
"A hidden truth", "A journey", "A leader", "A lie", "A loss", "A message", "A mission",
"A mistake", "A mystery", "A need", "A opportunity", "A plan", "A power", "A problem",
"A relationship", "A resource", "A rumor", "A secret", "A skill", "A stranger", "A threat",
"A truth", "A weapon", "An agreement", "An ally", "An ambition", "An authority", "An emotion",
"An enemy", "An event", "An idea", "An obstacle", "Beauty", "Chaos", "Comfort", "Community",
"Competition", "Corruption", "Courage", "Creation", "Danger", "Death", "Destiny", "Discord",
"Dominance", "Duty", "Emotion", "Energy", "Evil", "Failure", "Faith", "Fame", "Fate", "Fear",
"Freedom", "Good", "Greed", "Hatred", "Health", "History", "Honor", "Hope", "Identity", "Knowledge",
"Law", "Liberty", "Life", "Love", "Loyalty", "Magic", "Nature", "Order", "Pain", "Peace", "Power",
"Pride", "Protection", "Purpose", "Reason", "Revenge", "Safety", "Strength", "Success", "Survival",
"Time", "Victory", "Violence", "Wealth", "Wisdom"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "UNE Character",
"entries": [
"A betrayed idealist", "A burned-out soldier", "A charlatan with a conscience",
"A desperate parent", "A disgraced noble", "A disillusioned priest", "A failed hero",
"A fallen guardian", "A fanatic true believer", "A hardened survivor", "A haunted veteran",
"A hidden manipulator", "A jaded mercenary", "A loyal servant", "A opportunistic thief",
"A principled outlaw", "A reluctant leader", "A ruthless pragmatist", "A scarred wanderer",
"A scholar obsessed", "A secret keeper", "A self-made merchant", "A shattered idealist",
"A struggling reformer", "A tortured artist", "A true believer", "A vengeful survivor",
"A weary traveler", "A willful contrarian", "An ambitious upstart", "An ancient relic",
"An unlikely prophet", "Someone hiding grief", "Someone hiding guilt", "Someone seeking peace"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "UNE Demeanor",
"entries": [
"Aggressive", "Aloof", "Ambitious", "Amiable", "Apathetic", "Arrogant", "Bitter", "Calm",
"Cautious", "Charming", "Cold", "Competitive", "Confident", "Conflicted", "Curious", "Cynical",
"Deceptive", "Defensive", "Desperate", "Devoted", "Dignified", "Earnest", "Eccentric",
"Elusive", "Erratic", "Fearful", "Fierce", "Formal", "Friendly", "Guarded", "Humble",
"Imperious", "Impulsive", "Insecure", "Intense", "Jovial", "Manipulative", "Melancholic",
"Mercurial", "Methodical", "Nervous", "Neutral", "Obsequious", "Paranoid", "Passionate",
"Patient", "Pragmatic", "Righteous", "Secretive", "Serious", "Suspicious", "Threatening",
"Timid", "Unpredictable", "Wary", "Weary", "Zealous"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "UNE Motivation Noun",
"entries": [
"wealth", "hardship", "affluence", "resources", "prosperity", "poverty", "opulence", "deprivation", "success", "distress",
"contraband", "music", "literature", "technology", "alcohol", "medicines", "beauty", "strength", "intelligence", "force",
"the wealthy", "the populous", "enemies", "the public", "religion", "the poor", "family", "the elite", "academia", "the forsaken",
"the law", "the government", "the oppressed", "friends", "criminals", "allies", "secret societies", "the world", "military", "the church",
"dreams", "discretion", "love", "freedom", "pain", "faith", "slavery", "enlightenment", "racism", "sensuality",
"dissonance", "peace", "discrimination", "disbelief", "pleasure", "hate", "happiness", "servitude", "harmony", "justice",
"gluttony", "lust", "envy", "greed", "laziness", "wrath", "pride", "purity", "moderation", "vigilance",
"zeal", "composure", "charity", "modesty", "atrocities", "cowardice", "narcissism", "compassion", "valor", "patience",
"advice", "propaganda", "science", "knowledge", "communications", "lies", "myths", "riddles", "stories", "legends",
"industry", "new religions", "progress", "animals", "ghosts", "magic", "nature", "old religions", "expertise", "spirits"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "UNE Motivation Verb",
"entries": [
"advise", "obtain", "attempt", "spoil", "oppress", "interact", "create", "abduct", "promote", "conceive",
"blight", "progress", "distress", "possess", "record", "embrace", "contact", "pursue", "associate", "prepare",
"shepherd", "abuse", "indulge", "chronicle", "fulfill", "drive", "review", "aid", "follow", "advance",
"guard", "conquer", "hinder", "plunder", "construct", "encourage", "agonize", "comprehend", "administer", "relate",
"take", "discover", "deter", "acquire", "damage", "publicize", "burden", "advocate", "implement", "understand",
"collaborate", "strive", "complete", "compel", "join", "assist", "defile", "produce", "institute", "account",
"work", "accompany", "offend", "guide", "learn", "persecute", "communicate", "process", "report", "develop",
"steal", "suggest", "weaken", "achieve", "secure", "inform", "patronize", "depress", "determine", "seek",
"manage", "suppress", "proclaim", "operate", "access", "refine", "compose", "undermine", "explain", "discourage",
"attend", "detect", "execute", "maintain", "realize", "convey", "rob", "establish", "overthrow", "support"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "UNE Relationship",
"entries": [
"Hostile — actively works against the PC",
"Unfriendly — suspicious and unhelpful",
"Neutral — no strong feelings either way",
"Neutral — cautious but open",
"Friendly — willing to help if it costs little",
"Friendly — genuinely likes the PC",
"Helpful — goes out of their way for the PC",
"Devoted — would take serious risks for the PC"
]
}
+493
View File
@@ -23,6 +23,10 @@
--accent: #c9a35c;
--accent-hover: #ddb876;
--accent-text: #121110;
--success: #83a374;
--danger: #c4705a;
--info: #7a9fb5;
}
[data-theme="light"] {
@@ -38,6 +42,10 @@
--accent: #a3822f;
--accent-hover: #8c6f27;
--accent-text: #f5f1ea;
--success: #5c7a4e;
--danger: #a14a36;
--info: #4d7189;
}
* ,
@@ -333,3 +341,488 @@ h1, h2, h3, h4, h5, h6 {
.view.active {
display: block;
}
/* --- Cards (Oracle, Meaning, UNE, Dice) --- */
.card {
max-width: 700px;
margin: 0 auto 16px;
padding: 20px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
}
.card-title {
margin-bottom: 14px;
font-family: var(--font-heading);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
}
.card-note {
margin-bottom: 12px;
font-style: italic;
font-size: 0.85rem;
color: var(--text-secondary);
}
.placeholder {
font-style: italic;
color: var(--text-muted);
font-size: 0.9rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate {
animation: fadeIn 0.25s ease forwards;
}
/* --- Buttons shared by Oracle/Meaning/UNE/Dice --- */
.roll-btn {
width: 100%;
margin-top: 8px;
padding: 12px;
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 3px;
color: var(--accent);
font-family: var(--font-heading);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
transition: background var(--transition-speed) ease, border-color var(--transition-speed) ease;
}
.roll-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-hover);
}
.roll-btn:active {
transform: scale(0.98);
}
.roll-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.clear-btn {
padding: 12px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease;
}
.clear-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.clear-btn:active {
transform: scale(0.98);
}
/* --- Fate Check --- */
.prob-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.prob-btn {
padding: 8px 4px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 0.82rem;
text-align: center;
cursor: pointer;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
}
.prob-btn:hover {
border-color: var(--accent-hover);
color: var(--text-primary);
}
.prob-btn.selected {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.result-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 64px;
margin-top: 12px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
text-align: center;
}
.result-box.yes {
border-color: var(--success);
}
.result-box.no {
border-color: var(--danger);
}
.result-box.exceptional-yes {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg));
}
.result-box.exceptional-no {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 8%, var(--bg));
}
.result-box.random-event {
border-color: var(--info);
background: color-mix(in srgb, var(--info) 8%, var(--bg));
}
.result-main {
font-family: var(--font-heading);
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.08em;
}
.result-main.yes-color {
color: var(--success);
}
.result-main.no-color {
color: var(--danger);
}
.result-main.blue-color {
color: var(--info);
}
.result-sub {
margin-top: 4px;
font-style: italic;
font-size: 0.85rem;
color: var(--text-secondary);
}
.result-roll {
margin-top: 6px;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text-muted);
}
/* --- Random Event Check / NPC Relationship --- */
.event-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 48px;
margin-top: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
text-align: center;
}
.relationship-text {
font-style: italic;
font-size: 1rem;
color: var(--accent);
}
/* --- Meaning Tables --- */
.meaning-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 12px;
}
.meaning-individual-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.meaning-individual-grid .roll-btn {
margin-top: 0;
}
.meaning-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 56px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
text-align: center;
}
.meaning-label {
margin-bottom: 6px;
font-family: var(--font-heading);
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-muted);
}
.meaning-word {
font-size: 1.15rem;
font-style: italic;
color: var(--info);
}
/* --- UNE --- */
.une-result {
margin-top: 12px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
}
.une-row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--bg-elevated);
}
.une-row:last-child {
border-bottom: none;
}
.une-key {
min-width: 6rem;
font-family: var(--font-heading);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
}
.une-val {
font-style: italic;
font-size: 1rem;
color: var(--accent);
text-align: right;
}
/* --- Dice --- */
.dice-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.die-btn {
padding: 12px 4px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-align: center;
cursor: pointer;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
}
.die-btn:hover {
border-color: var(--accent-hover);
color: var(--text-primary);
}
.die-btn.selected {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.die-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pool-display {
display: flex;
align-items: center;
justify-content: center;
min-height: 38px;
margin: 12px 0 6px;
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
font-family: var(--font-heading);
font-size: 0.95rem;
letter-spacing: 0.04em;
color: var(--accent);
text-align: center;
}
.pool-cap-msg {
margin-bottom: 6px;
font-style: italic;
font-size: 0.8rem;
color: var(--danger);
text-align: center;
}
.dice-actions-row {
display: flex;
gap: 10px;
margin-top: 8px;
}
.dice-actions-row .roll-btn {
flex: 1;
margin-top: 0;
}
.pool-result-groups {
width: 100%;
font-style: italic;
font-size: 0.85rem;
color: var(--text-secondary);
text-align: center;
}
.pool-result-group {
padding: 2px 0;
}
.pool-group-type {
font-family: var(--font-heading);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
}
.dice-divider {
width: 70%;
height: 1px;
margin: 9px auto;
background: var(--border);
}
.custom-roll-row {
display: flex;
align-items: stretch;
gap: 10px;
margin-bottom: 12px;
}
.custom-roll-input {
flex: 1;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 0.95rem;
line-height: 1.2;
text-align: center;
}
.custom-roll-input:focus {
outline: none;
border-color: var(--accent-hover);
}
.custom-die-input {
display: flex;
align-items: center;
flex: 1;
gap: 6px;
}
.custom-die-prefix {
font-family: var(--font-heading);
font-size: 0.9rem;
color: var(--text-secondary);
}
.dice-result-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 64px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
text-align: center;
}
.dice-total {
font-family: var(--font-heading);
font-size: 2.2rem;
font-weight: 600;
color: var(--accent);
line-height: 1;
}
.dice-breakdown {
margin-top: 6px;
font-style: italic;
font-size: 0.8rem;
color: var(--text-secondary);
}
+160 -4
View File
@@ -135,10 +135,162 @@
</aside>
<main class="main-content">
<div class="view active" id="view-oracle"></div>
<div class="view" id="view-meaning"></div>
<div class="view" id="view-une"></div>
<div class="view" id="view-dice"></div>
<div class="view active" id="view-oracle">
<div class="card">
<div class="card-title">Fate Check</div>
<div class="prob-grid" id="probGrid">
<button class="prob-btn" data-label="Impossible" data-base="5">Impossible</button>
<button class="prob-btn" data-label="No Way" data-base="15">No Way</button>
<button class="prob-btn" data-label="Very Unlikely" data-base="25">Very Unlikely</button>
<button class="prob-btn" data-label="Unlikely" data-base="35">Unlikely</button>
<button class="prob-btn selected" data-label="50/50" data-base="50">50 / 50</button>
<button class="prob-btn" data-label="Somewhat Likely" data-base="65">Somewhat Likely</button>
<button class="prob-btn" data-label="Likely" data-base="75">Likely</button>
<button class="prob-btn" data-label="Very Likely" data-base="85">Very Likely</button>
<button class="prob-btn" data-label="Near Certain" data-base="95">Near Certain</button>
</div>
<button class="roll-btn" id="fateRollBtn">Ask the Fates</button>
<div class="result-box" id="fateResult">
<span class="placeholder">Select a probability and ask.</span>
</div>
</div>
<div class="card">
<div class="card-title">Random Event Check</div>
<p class="card-note">Roll at the start of each scene. A random event occurs if the result is equal to or under the chaos factor.</p>
<button class="roll-btn" id="eventRollBtn">Check for Random Event</button>
<div class="event-result" id="eventResult">
<span class="placeholder">Roll to check.</span>
</div>
</div>
</div>
<div class="view" id="view-meaning">
<div class="card">
<div class="card-title">Meaning Tables</div>
<p class="card-note">Use these to interpret random events, oracle results, or whenever you need narrative inspiration.</p>
<button class="roll-btn" id="meaningRollBtn">Roll Action + Descriptor</button>
<div class="meaning-grid">
<div class="meaning-result">
<div class="meaning-label">Action</div>
<div class="meaning-word" id="meaningAction"><span class="placeholder"></span></div>
</div>
<div class="meaning-result">
<div class="meaning-label">Descriptor</div>
<div class="meaning-word" id="meaningDescriptor"><span class="placeholder"></span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Individual Tables</div>
<div class="meaning-individual-grid">
<button class="roll-btn" id="actionRollBtn">Roll Action</button>
<button class="roll-btn" id="descriptorRollBtn">Roll Descriptor</button>
</div>
</div>
</div>
<div class="view" id="view-une">
<div class="card">
<div class="card-title">NPC Generator — UNE</div>
<p class="card-note">Generate an NPC's core traits. Drop the results into your NPC note.</p>
<button class="roll-btn" id="uneRollBtn">Generate NPC</button>
<div class="une-result" id="uneResult" style="display:none;">
<div class="une-row">
<span class="une-key">Motivation</span>
<span class="une-val" id="uneMotivation"></span>
</div>
<div class="une-row">
<span class="une-key">Demeanor</span>
<span class="une-val" id="uneDemeanor"></span>
</div>
<div class="une-row">
<span class="une-key">Character</span>
<span class="une-val" id="uneCharacter"></span>
</div>
</div>
</div>
<div class="card">
<div class="card-title">NPC Relationship</div>
<p class="card-note">How does this NPC feel about the PC?</p>
<button class="roll-btn" id="relationshipRollBtn">Roll Relationship</button>
<div class="event-result" id="relationshipResult">
<span class="placeholder">Roll to find out.</span>
</div>
</div>
</div>
<div class="view" id="view-dice">
<div class="card">
<div class="card-title">Dice Pool Builder</div>
<div class="dice-grid" id="diceGrid">
<button class="die-btn" data-sides="4">d4</button>
<button class="die-btn" data-sides="6">d6</button>
<button class="die-btn" data-sides="8">d8</button>
<button class="die-btn" data-sides="10">d10</button>
<button class="die-btn" data-sides="12">d12</button>
<button class="die-btn" data-sides="20">d20</button>
<button class="die-btn" data-sides="100">d100</button>
<button class="die-btn" data-sides="2">d2</button>
</div>
<div class="pool-display" id="poolDisplay">
<span class="placeholder">No dice in pool — click above to add.</span>
</div>
<div class="pool-cap-msg" id="poolCapMsg" style="display:none;">Pool is full — 25 dice maximum.</div>
<div class="dice-actions-row">
<button class="roll-btn" id="poolRollBtn">Roll Pool</button>
<button class="clear-btn" id="poolClearBtn">Clear</button>
</div>
<div class="dice-result-box" style="margin-top:1rem;" id="poolResult">
<span class="placeholder">Build a pool and roll.</span>
</div>
</div>
<div class="card">
<div class="card-title">Custom Roll</div>
<div class="custom-roll-row">
<input type="number" id="customQty" class="custom-roll-input" placeholder="Qty" min="1" step="1">
<div class="custom-die-input">
<span class="custom-die-prefix">d</span>
<input type="number" id="customSides" class="custom-roll-input" placeholder="sides" min="1" step="1">
</div>
</div>
<div class="dice-actions-row">
<button class="roll-btn" id="customRollBtn" disabled>Roll</button>
<button class="clear-btn" id="customClearBtn">Clear</button>
</div>
<div class="dice-result-box" style="margin-top:1rem;" id="customResult">
<span class="placeholder">Enter a quantity and die size.</span>
</div>
</div>
<div class="card">
<div class="card-title">Percentile</div>
<div class="dice-actions-row">
<button class="roll-btn" id="percentileTensBtn">Tens</button>
<button class="roll-btn" id="percentileOnesBtn">Ones</button>
</div>
<div class="dice-result-box" style="margin-top:1rem;" id="percentileResult">
<span class="placeholder">Roll tens or ones.</span>
</div>
</div>
<div class="card">
<div class="card-title">Ability Score</div>
<button class="roll-btn" id="ability4d6Btn">4d6 dl</button>
<button class="roll-btn" id="abilityArrayBtn">Full Array</button>
<div class="dice-result-box" style="margin-top:1rem;" id="abilityResult">
<span class="placeholder">Roll for an ability score.</span>
</div>
</div>
</div>
<div class="view" id="view-tables"></div>
<div class="view" id="view-threads"></div>
<div class="view" id="view-npcs"></div>
@@ -150,4 +302,8 @@
</body>
<script src="js/app.js" type="module"></script>
<script src="js/campaigns.js" type="module"></script>
<script src="js/oracle.js" type="module"></script>
<script src="js/meaning.js" type="module"></script>
<script src="js/une.js" type="module"></script>
<script src="js/dice.js" type="module"></script>
</html>
+4
View File
@@ -49,3 +49,7 @@ export function deleteCampaign(id) {
export function getSystems() {
return request('/systems');
}
export function getTable(name) {
return request(`/tables/${name}`);
}
+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);
+48 -1
View File
@@ -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
View File
@@ -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} &nbsp;|&nbsp; Threshold: ${threshold} &nbsp;|&nbsp; Prob: ${selectedProb.label} &nbsp;|&nbsp; 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} &nbsp;≤&nbsp; 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} &nbsp;&gt;&nbsp; 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
View File
@@ -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);
+31
View File
@@ -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;