MediaWiki:Common.js
Appearance
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* Any JavaScript here will be loaded for all users on every page load. */
$('#mw-mywiki-test').html('<html><script src="http://ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min.js" type="text/javascript"></script><script src="http://dev.d20futurepath.com/roller.js" type="text/javascript"></script><body><p>Please input the dice IE: d20+d4:</p><input id="dice"><button type="button" onclick="myFunction()">Submit</button><p id="diceRoll"></p></body></html>');
/**
* dice_calc_wiki_ui.js
*
* MediaWiki Interactive DC Probability Calculator Widget
*
* Embed this file into MediaWiki:Common.js or a MediaWiki Gadget.
* To display the calculator on any wiki page, add:
* <div id="dc-calc-widget"></div>
*/
(function () {
// =========================================================================
// 1. Core Calculation Engine & Pre-Populated Data
// =========================================================================
const EXPLODABLE_DICE = new Set([2, 4, 6, 8, 10, 12]);
const DEFAULT_DCS = [5, 10, 15, 20, 25, 30, 35, 40, 45];
const Classicd20 = [
'd20+0', 'd20+1', 'd20+2', 'd20+3', 'd20+4', 'd20+5',
'd20+6', 'd20+7', 'd20+8', 'd20+9', 'd20+10', 'd20+11',
'd20+12', 'd20+13', 'd20+14', 'd20+15', 'd20+16', 'd20+17',
'd20+18', 'd20+19', 'd20+20'
];
const SkillRanks = [
'd20+0', 'd20+d2', 'd20+2d2', 'd20+2d2+1', 'd20+d4+d2+1', 'd20+2d4+1', 'd20+2d4+2',
'd20+d6+d4+2', 'd20+2d6+2', 'd20+2d6+3', 'd20+d8+d6+3', 'd20+2d8+3', 'd20+2d8+4',
'd20+d10+d8+4', 'd20+2d10+4', 'd20+2d10+5', 'd20+d12+d10+5', 'd20+2d12+5', 'd20+2d12+6',
'd20+2d6+d12+6', 'd20+2d6+2d6+6'
];
const AdvantageDieRanks = [
'd20+0', 'd20+d2+1', 'd20+2d2+1', 'd20+2d2+2', 'd20+d4+d2+2', 'd20+2d4+2', 'd20+2d4+3',
'd20+d6+d4+3', 'd20+2d6+3', 'd20+2d6+4', 'd20+d8+d6+4', 'd20+2d8+4', 'd20+2d8+5',
'd20+d10+d8+5', 'd20+2d10+5', 'd20+2d10+6', 'd20+d12+d10+6', 'd20+2d12+6', 'd20+2d12+7',
'd20+2d6+d12+7', 'd20+2d6+2d6+7'
];
const PathAdvantageDieProgression = [
'd20+0',
'd20+d2+1',
'd20+2d2+1',
'd20+2d2+2',
'd20+d4+d2+2',
'd20+d4+d2+2',
'd20+2d4+2',
'd20+2d4+3',
'd20+d6+d4+3',
'd20+2d6+3',
'd20+2d6+4',
'd20+2d6+4',
'd20+d8+d6+4',
'd20+2d8+4',
'd20+2d8+5',
'd20+d10+d8+5',
'd20+2d10+5',
'd20+2d10+5',
'd20+2d10+6',
'd20+d12+d10+6',
'd20+2d12+6'
];
const PRESETS = {
'Classicd20': Classicd20,
'SkillRanks': SkillRanks,
'AdvantageDieRanks': AdvantageDieRanks,
'PathAdvantageDieProgression': PathAdvantageDieProgression
};
function parseRollGroup(exprStr, defaultMod = 0, explosions = 0, luck = false, unlucky = false) {
const token = exprStr.trim();
if (!token) throw new Error("Empty roll expression.");
const regex = /([+-]?)\s*(?:(\d*)d(\d+)|(\d+))/gi;
const matches = [];
let m;
while ((m = regex.exec(token)) !== null) matches.push(m);
if (matches.length === 0) throw new Error("Invalid dice expression.");
const diceSides = [];
const diceTokens = [];
let hasInlineMod = false;
let inlineMod = 0;
for (const match of matches) {
const sign = match[1];
const countStr = match[2];
const sidesStr = match[3];
const numStr = match[4];
const isNegative = (sign === '-');
if (sidesStr !== undefined && sidesStr !== null) {
const count = countStr ? parseInt(countStr, 10) : 1;
const sides = parseInt(sidesStr, 10);
for (let i = 0; i < count; i++) diceSides.push(sides);
let dieName = count > 1 ? `${count}d${sides}` : `d${sides}`;
if (sides === 20 && luck) dieName += "^";
else if (sides === 20 && unlucky) dieName += "v";
else if (explosions > 0 && EXPLODABLE_DICE.has(sides)) dieName += "*";
diceTokens.push(dieName);
} else if (numStr !== undefined && numStr !== null) {
hasInlineMod = true;
const val = parseInt(numStr, 10);
inlineMod += isNegative ? -val : val;
}
}
if (diceSides.length === 0 && !hasInlineMod) throw new Error("No dice found.");
const finalMod = inlineMod + defaultMod;
const dicePart = diceTokens.join("+");
let canonicalExpr = dicePart;
if (dicePart.length === 0) {
canonicalExpr = finalMod >= 0 ? `+${finalMod}` : `${finalMod}`;
} else if (finalMod > 0) {
canonicalExpr += `+${finalMod}`;
} else if (finalMod < 0) {
canonicalExpr += `${finalMod}`;
}
return { diceSides, diceExpr: canonicalExpr, mod: finalMod };
}
function computeDieTransitions(sides, e = 0) {
if (e <= 0 || !EXPLODABLE_DICE.has(sides)) {
const trans = [];
const prob = 1.0 / sides;
for (let face = 1; face <= sides; face++) {
trans.push({ val: face, remE: 0, p: prob });
}
return trans;
}
const trans = [];
const probPerFace = 1.0 / sides;
for (let face = 1; face < sides; face++) {
trans.push({ val: face, remE: e, p: probPerFace });
}
const subTrans = computeDieTransitions(sides, e - 1);
for (let i = 0; i < subTrans.length; i++) {
const sub = subTrans[i];
trans.push({
val: sides + sub.val,
remE: sub.remE,
p: probPerFace * sub.p
});
}
return trans;
}
function computeSingleDiePmf(sides, explosions = 0, luck = false, unlucky = false) {
return computePmf([sides], explosions, luck, unlucky);
}
function computePmf(dice, explosions = 0, luck = false, unlucky = false) {
const explodable = [];
const nonExplodable = [];
for (const sides of dice) {
if (EXPLODABLE_DICE.has(sides)) {
explodable.push(sides);
} else {
nonExplodable.push(sides);
}
}
// Sort explodable dice descending by sides (highest die gets explosion priority)
explodable.sort((a, b) => b - a);
// State: "currentSum,remainingExplosions" -> probability
let state = new Map();
state.set(`0,${explosions}`, 1.0);
for (const sides of explodable) {
const nextState = new Map();
const transitionsCache = new Map();
for (const [stateKey, p1] of state.entries()) {
const commaIdx = stateKey.indexOf(',');
const curSum = parseInt(stateKey.slice(0, commaIdx), 10);
const remE = parseInt(stateKey.slice(commaIdx + 1), 10);
let trans = transitionsCache.get(remE);
if (!trans) {
trans = computeDieTransitions(sides, remE);
transitionsCache.set(remE, trans);
}
for (let i = 0; i < trans.length; i++) {
const tr = trans[i];
const nextSum = curSum + tr.val;
const nextKey = `${nextSum},${tr.remE}`;
nextState.set(nextKey, (nextState.get(nextKey) || 0.0) + p1 * tr.p);
}
}
state = nextState;
}
// Marginalize out remaining explosions
let pmf = {};
for (const [stateKey, p] of state.entries()) {
const commaIdx = stateKey.indexOf(',');
const curSum = parseInt(stateKey.slice(0, commaIdx), 10);
pmf[curSum] = (pmf[curSum] || 0.0) + p;
}
// Convolve with non-explodable dice
for (const sides of nonExplodable) {
let diePmf = {};
if (sides === 20 && luck) {
for (let face = 1; face <= 20; face++) {
diePmf[face] = (2 * face - 1) / 400.0;
}
} else if (sides === 20 && unlucky) {
for (let face = 1; face <= 20; face++) {
diePmf[face] = (41 - 2 * face) / 400.0;
}
} else {
const prob = 1.0 / sides;
for (let face = 1; face <= sides; face++) {
diePmf[face] = prob;
}
}
const newPmf = {};
for (const [curSumStr, p1] of Object.entries(pmf)) {
const curSum = parseInt(curSumStr, 10);
for (const [dieValStr, p2] of Object.entries(diePmf)) {
const dieVal = parseInt(dieValStr, 10);
const sum = curSum + dieVal;
newPmf[sum] = (newPmf[sum] || 0.0) + p1 * p2;
}
}
pmf = newPmf;
}
return pmf;
}
function calculateProbabilities(diceStr, defaultMod, customDcStr, explosions, luck = false, unlucky = false) {
const customDcs = customDcStr.split(',')
.map(s => s.trim())
.filter(s => s !== '' && !isNaN(parseInt(s, 10)))
.map(s => parseInt(s, 10));
const dcs = Array.from(new Set([...DEFAULT_DCS, ...customDcs])).sort((a, b) => a - b);
const rollTokens = diceStr.split(/\s+/).filter(Boolean);
const rows = [];
for (const token of rollTokens) {
const group = parseRollGroup(token, defaultMod, explosions, luck, unlucky);
const pmf = computePmf(group.diceSides, explosions, luck, unlucky);
const probs = {};
for (const dc of dcs) {
let pSuccess = 0.0;
for (const [sumVal, p] of Object.entries(pmf)) {
if (parseInt(sumVal, 10) + group.mod >= dc) pSuccess += p;
}
probs[dc] = pSuccess;
}
rows.push({ expr: group.diceExpr, probs });
}
return { dcs, rows };
}
// =========================================================================
// 2. UI Builder & Event Handling (Theme-Aware)
// =========================================================================
const CSS_STYLES = `
.dc-calc-container {
--dc-card-bg: #f8f9fa;
--dc-card-border: #c8ccd1;
--dc-text: #202122;
--dc-subtext: #54595d;
--dc-input-bg: #ffffff;
--dc-input-border: #a2a9b1;
--dc-input-text: #202122;
--dc-table-hdr-bg: #eaecf0;
--dc-table-hdr-text: #202122;
--dc-table-border: #a2a9b1;
--dc-table-bg: #ffffff;
--dc-btn-calc: #3366cc;
--dc-btn-calc-hover: #2a4b8d;
--dc-btn-copy: #2ea44f;
--dc-btn-copy-hover: #22863a;
--dc-link: #3366cc;
--dc-error: #d33;
--dc-success: #2ea44f;
--dc-textarea-bg: #ffffff;
background: var(--dc-card-bg);
border: 1px solid var(--dc-card-border);
color: var(--dc-text);
border-radius: 4px;
padding: 15px;
margin: 15px 0;
max-width: 1100px;
width: 100%;
box-sizing: border-box;
font-family: sans-serif;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* MediaWiki Vector 2022 Night mode & Dark Mode Gadget/Skin Classes */
html.skin-theme-clientpref-night .dc-calc-container,
html.dark-mode .dc-calc-container,
body.skin-night .dc-calc-container,
body.theme-dark .dc-calc-container,
html.client-dark-mode .dc-calc-container {
--dc-card-bg: #202122;
--dc-card-border: #54595d;
--dc-text: #f8f9fa;
--dc-subtext: #a2a9b1;
--dc-input-bg: #27292d;
--dc-input-border: #72777d;
--dc-input-text: #f8f9fa;
--dc-table-hdr-bg: #2e3034;
--dc-table-hdr-text: #ffffff;
--dc-table-border: #54595d;
--dc-table-bg: #202122;
--dc-btn-calc: #447ff5;
--dc-btn-calc-hover: #3366cc;
--dc-btn-copy: #34b359;
--dc-btn-copy-hover: #2ea44f;
--dc-link: #6699ff;
--dc-error: #ff6b6b;
--dc-success: #34b359;
--dc-textarea-bg: #27292d;
}
/* OS automatic Dark mode (when MediaWiki client preference is 'os' or unset) */
@media (prefers-color-scheme: dark) {
html.skin-theme-clientpref-os .dc-calc-container,
html:not(.skin-theme-clientpref-day):not(.skin-theme-clientpref-night) .dc-calc-container {
--dc-card-bg: #202122;
--dc-card-border: #54595d;
--dc-text: #f8f9fa;
--dc-subtext: #a2a9b1;
--dc-input-bg: #27292d;
--dc-input-border: #72777d;
--dc-input-text: #f8f9fa;
--dc-table-hdr-bg: #2e3034;
--dc-table-hdr-text: #ffffff;
--dc-table-border: #54595d;
--dc-table-bg: #202122;
--dc-btn-calc: #447ff5;
--dc-btn-calc-hover: #3366cc;
--dc-btn-copy: #34b359;
--dc-btn-copy-hover: #2ea44f;
--dc-link: #6699ff;
--dc-error: #ff6b6b;
--dc-success: #34b359;
--dc-textarea-bg: #27292d;
}
}
.dc-calc-input {
background: var(--dc-input-bg) !important;
border: 1px solid var(--dc-input-border) !important;
color: var(--dc-input-text) !important;
border-radius: 3px;
padding: 6px;
font-size: 0.9em;
}
.dc-calc-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.dc-calc-table {
width: 100%;
border-collapse: collapse;
text-align: center;
font-size: 0.9em;
margin-top: 10px;
}
.dc-calc-table th {
background: var(--dc-table-hdr-bg);
color: var(--dc-table-hdr-text);
border: 1px solid var(--dc-table-border);
padding: 6px 8px;
}
.dc-calc-table td {
background: var(--dc-table-bg);
color: var(--dc-text);
border: 1px solid var(--dc-table-border);
padding: 6px 8px;
}
.dc-calc-btn-calc {
background: var(--dc-btn-calc);
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.dc-calc-btn-calc:hover {
background: var(--dc-btn-calc-hover);
}
.dc-calc-btn-copy {
background: var(--dc-btn-copy);
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
margin-left: 8px;
}
.dc-calc-btn-copy:hover {
background: var(--dc-btn-copy-hover);
}
`;
function injectStyles() {
if (!document.getElementById('dc-calc-injected-styles')) {
const style = document.createElement('style');
style.id = 'dc-calc-injected-styles';
style.textContent = CSS_STYLES;
document.head.appendChild(style);
}
}
function initDcCalcWidget() {
const container = document.getElementById('dc-calc-widget');
if (!container || container.dataset.loaded) return;
container.dataset.loaded = "true";
injectStyles();
container.innerHTML = `
<div class="dc-calc-container">
<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin-bottom: 15px;">
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px;">Preset:</label>
<select id="dc-select-preset" class="dc-calc-input" style="width: 195px;">
<option value="custom">Custom Input</option>
<option value="Classicd20">Classic d20 (0-20)</option>
<option value="SkillRanks">Skill Ranks (0-20)</option>
<option value="AdvantageDieRanks">Advantage Die (0-20)</option>
<option value="PathAdvantageDieProgression">Path Advantage Die (0-20)</option>
</select>
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px;">Dice Expression(s):</label>
<input id="dc-input-dice" class="dc-calc-input" type="text" value="d20+5 d20+2d6+1" style="width: 200px;" placeholder="e.g. d20+5 d20+2d6">
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px;">Modifier:</label>
<input id="dc-input-mod" class="dc-calc-input" type="number" value="0" style="width: 70px;">
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px;">Exploding Dice:</label>
<select id="dc-select-exploding" class="dc-calc-input">
<option value="0">Off (0)</option>
<option value="1">1 Explosion</option>
<option value="2">2 Explosions</option>
<option value="3">3 Explosions</option>
<option value="4">4 Explosions</option>
<option value="5">5 Explosions</option>
<option value="6">6 Explosions</option>
<option value="100">Infinity (∞)</option>
</select>
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px;">Custom DC(s):</label>
<input id="dc-input-custom-dc" class="dc-calc-input" type="text" value="" style="width: 110px;" placeholder="e.g. 17, 22">
</div>
<div style="display: flex; align-items: center; padding-bottom: 6px;">
<label style="font-weight: bold; cursor: pointer; color: var(--dc-text); display: flex; align-items: center; gap: 4px;">
<input id="dc-check-row-count" type="checkbox" checked> Row #
</label>
</div>
<div style="display: flex; align-items: center; gap: 6px; padding-bottom: 6px;">
<label style="font-weight: bold; cursor: pointer; color: var(--dc-text); display: flex; align-items: center; margin: 0;">
<input id="dc-check-luck" type="checkbox" style="cursor: pointer; margin: 0;">
</label>
<select id="dc-select-luck-mode" class="dc-calc-input" style="height: 28px; padding: 2px 8px; font-size: 0.85em; font-weight: bold; cursor: pointer;" disabled>
<option value="luck">Luck</option>
<option value="unlucky">Unlucky</option>
</select>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="dc-btn-calc" class="dc-calc-btn-calc">Calculate</button>
<button id="dc-btn-copy" class="dc-calc-btn-copy" style="margin-left: 0;">Copy WikiText</button>
<span id="dc-copy-status" style="color: var(--dc-success); font-weight: bold; display: none;">Copied!</span>
</div>
</div>
<div id="dc-preview-container" style="overflow-x: auto; margin-top: 10px;"></div>
<details style="margin-top: 12px;">
<summary style="cursor: pointer; color: var(--dc-link); font-size: 0.9em; font-weight: 500;">View Generated WikiText</summary>
<textarea id="dc-wikitext-output" class="dc-calc-input" readonly style="width: 100%; height: 90px; margin-top: 5px; font-family: monospace; font-size: 0.85em;"></textarea>
</details>
</div>
`;
function render() {
try {
const diceStr = document.getElementById('dc-input-dice').value;
const defaultMod = parseInt(document.getElementById('dc-input-mod').value, 10) || 0;
const exploding = parseInt(document.getElementById('dc-select-exploding').value, 10) || 0;
const customDcStr = document.getElementById('dc-input-custom-dc').value;
const luckChecked = document.getElementById('dc-check-luck').checked;
const luckMode = document.getElementById('dc-select-luck-mode').value;
const luck = luckChecked && luckMode === 'luck';
const unlucky = luckChecked && luckMode === 'unlucky';
const showRowCount = document.getElementById('dc-check-row-count').checked;
const { dcs, rows } = calculateProbabilities(diceStr, defaultMod, customDcStr, exploding, luck, unlucky);
// Build WikiText Output
const wikiHeaders = [];
if (showRowCount) wikiHeaders.push("#");
wikiHeaders.push("Dice");
wikiHeaders.push(...dcs.map(dc => `DC ${dc}`));
const wikiHeader = `{| class="wikitable"\n! ` + wikiHeaders.join(' || ');
const wikiRows = rows.map((r, i) => {
const rowCells = [];
if (showRowCount) rowCells.push(String(i));
rowCells.push(r.expr);
rowCells.push(...dcs.map(dc => `${(r.probs[dc] * 100).toFixed(2)}%`));
return `|-\n| ` + rowCells.join(' || ');
}).join('\n');
const fullWikiText = `${wikiHeader}\n${wikiRows}\n|}`;
document.getElementById('dc-wikitext-output').value = fullWikiText;
// Build HTML Live Preview Table
let html = '<table class="dc-calc-table">';
html += '<tr>' + (showRowCount ? '<th>#</th>' : '') + '<th>Dice</th>' + dcs.map(dc => `<th>DC ${dc}</th>`).join('') + '</tr>';
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
html += '<tr>' + (showRowCount ? `<td style="color: var(--dc-subtext); font-size: 0.85em;">${i}</td>` : '') + `<td style="font-weight: bold;">${r.expr}</td>` + dcs.map(dc => `<td>${(r.probs[dc] * 100).toFixed(2)}%</td>`).join('') + '</tr>';
}
html += '</table>';
document.getElementById('dc-preview-container').innerHTML = html;
} catch (err) {
document.getElementById('dc-preview-container').innerHTML = `<div style="color: var(--dc-error); font-weight: bold;">Error: ${err.message}</div>`;
}
}
document.getElementById('dc-select-preset').addEventListener('change', function () {
const val = this.value;
if (val !== 'custom' && PRESETS[val]) {
document.getElementById('dc-input-dice').value = PRESETS[val].join(' ');
render();
}
});
document.getElementById('dc-input-dice').addEventListener('input', function () {
const currentVal = this.value.trim();
const matchedKey = Object.keys(PRESETS).find(k => PRESETS[k].join(' ') === currentVal);
document.getElementById('dc-select-preset').value = matchedKey || 'custom';
});
document.getElementById('dc-check-row-count').addEventListener('change', render);
document.getElementById('dc-check-luck').addEventListener('change', function () {
const selectMode = document.getElementById('dc-select-luck-mode');
selectMode.disabled = !this.checked;
if (this.checked && !selectMode.value) {
selectMode.value = 'luck';
}
render();
});
document.getElementById('dc-select-luck-mode').addEventListener('change', render);
document.getElementById('dc-select-exploding').addEventListener('change', render);
document.getElementById('dc-input-mod').addEventListener('input', render);
document.getElementById('dc-input-custom-dc').addEventListener('input', render);
document.getElementById('dc-btn-calc').addEventListener('click', render);
document.getElementById('dc-btn-copy').addEventListener('click', function () {
const textarea = document.getElementById('dc-wikitext-output');
textarea.select();
navigator.clipboard.writeText(textarea.value);
const status = document.getElementById('dc-copy-status');
status.style.display = 'inline';
setTimeout(() => { status.style.display = 'none'; }, 2000);
});
// Trigger initial calculation
render();
}
// Initialize when DOM / MediaWiki is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDcCalcWidget);
} else {
initDcCalcWidget();
}
})();
/**
* ability_score_calc_wiki_ui.js
*
* MediaWiki Interactive Ability Theme & Score Calculator Widget
*
* Supports 3 distribution methods:
* 1. Standard Spread (Default 15, 14, 13, 12, 10, 8 with Low/High fantasy options)
* 2. Dice Roller (Low / Standard / High / Epic Fantasy via FuturePathAPI /tasks/roll/character/<level>)
* 3. Point Buy (Low / Standard / High / Epic fantasy budgets, scores 7-18 with official costs)
*
* Embed this script into MediaWiki:Common.js or a MediaWiki Gadget.
* To display the widget on any wiki page, add:
* <div id="ability-score-calc-widget"></div>
*/
(function () {
'use strict';
// =========================================================================
// 1. Constants & Core Data
// =========================================================================
const CORE_ATTRIBUTES = [
{ key: 'STR', name: 'Strength', desc: 'Physical power and melee prowess' },
{ key: 'DEX', name: 'Dexterity', desc: 'Agility, reflexes, and ranged accuracy' },
{ key: 'CON', name: 'Constitution', desc: 'Health, stamina, and hit point bonus' },
{ key: 'INT', name: 'Intelligence', desc: 'Knowledge, reasoning, and skill points' },
{ key: 'WIS', name: 'Wisdom', desc: 'Perception, insight, and willpower' },
{ key: 'CHA', name: 'Charisma', desc: 'Leadership, persuasiveness, and force of personality' }
];
const SPREADS = {
'standard': {
name: 'Standard Fantasy (15, 14, 13, 12, 10, 8)',
scores: [15, 14, 13, 12, 10, 8]
},
'low': {
name: 'Low Fantasy (14, 12, 10, 10, 8, 8)',
scores: [14, 12, 10, 10, 8, 8]
},
'high': {
name: 'High Fantasy (19, 18, 16, 13, 12, 10)',
scores: [19, 18, 16, 13, 12, 10]
}
};
const POINT_BUY_BUDGETS = {
'low': { name: 'Low Fantasy', points: 10 },
'standard': { name: 'Standard Fantasy', points: 15 },
'high': { name: 'High Fantasy', points: 20 },
'epic': { name: 'Epic Fantasy', points: 25 }
};
const POINT_BUY_COSTS = {
7: -4,
8: -2,
9: -1,
10: 0,
11: 1,
12: 2,
13: 3,
14: 5,
15: 7,
16: 10,
17: 13,
18: 17
};
const ROLLER_CONFIGS = {
'low': {
name: 'Low Fantasy',
apiLevel: 'low',
desc: '3d6 (reroll total <= 7), 6 rolls',
diceCount: 3,
sides: 6,
dropLowest: 0,
rerollTotal: 7,
totalRolls: 6,
dropLowestTotal: 0
},
'standard': {
name: 'Standard Fantasy',
apiLevel: 'normal',
desc: '4d6 drop lowest 1 (reroll total <= 8), 6 rolls',
diceCount: 4,
sides: 6,
dropLowest: 1,
rerollTotal: 8,
totalRolls: 6,
dropLowestTotal: 0
},
'high': {
name: 'High Fantasy',
apiLevel: 'high',
desc: '4d6 drop lowest 1 (reroll total <= 9), 7 rolls drop lowest',
diceCount: 4,
sides: 6,
dropLowest: 1,
rerollTotal: 9,
totalRolls: 7,
dropLowestTotal: 1
},
'epic': {
name: 'Epic Fantasy',
apiLevel: 'epic',
desc: '5d6 drop lowest 2 (reroll total <= 10), 7 rolls drop lowest',
diceCount: 5,
sides: 6,
dropLowest: 2,
rerollTotal: 10,
totalRolls: 7,
dropLowestTotal: 1
}
};
// =========================================================================
// 2. Calculation Helpers
// =========================================================================
function calculateModifier(score) {
if (score === null || score === undefined || isNaN(score)) return 0;
return Math.floor((score - 10) / 2);
}
function formatModifier(mod) {
if (mod > 0) return `+${mod}`;
return `${mod}`;
}
// Client-side dice rolling fallback replicating FuturePathAPI/Rolling.py
function rollSingleDie(sides) {
return Math.floor(Math.random() * sides) + 1;
}
function rollScore(diceCount, sides, dropLowest, rerollTotal) {
for (let attempt = 0; attempt < 100; attempt++) {
const dice = [];
for (let i = 0; i < diceCount; i++) {
dice.push(rollSingleDie(sides));
}
const sorted = [...dice].sort((a, b) => a - b);
const kept = sorted.slice(dropLowest);
const total = kept.reduce((a, b) => a + b, 0);
if (total > rerollTotal) {
return { total, dice, kept };
}
}
return { total: rerollTotal + 1, dice: [], kept: [] };
}
function rollCharacterLocally(config) {
const rolls = [];
for (let i = 0; i < config.totalRolls; i++) {
rolls.push(rollScore(config.diceCount, config.sides, config.dropLowest, config.rerollTotal));
}
if (config.dropLowestTotal > 0) {
let minIndex = 0;
for (let i = 1; i < rolls.length; i++) {
if (rolls[i].total < rolls[minIndex].total) {
minIndex = i;
}
}
rolls.splice(minIndex, 1);
}
return rolls.map(r => r.total);
}
// =========================================================================
// 3. Theme-Aware CSS Styles
// =========================================================================
const CSS_STYLES = `
.asc-container {
--asc-card-bg: #f8f9fa;
--asc-card-border: #c8ccd1;
--asc-text: #202122;
--asc-subtext: #54595d;
--asc-input-bg: #ffffff;
--asc-input-border: #a2a9b1;
--asc-input-text: #202122;
--asc-table-hdr-bg: #eaecf0;
--asc-table-hdr-text: #202122;
--asc-table-border: #a2a9b1;
--asc-table-bg: #ffffff;
--asc-btn-primary: #3366cc;
--asc-btn-primary-hover: #2a4b8d;
--asc-btn-copy: #2ea44f;
--asc-btn-copy-hover: #22863a;
--asc-btn-secondary: #eaecf0;
--asc-btn-secondary-hover: #dadde3;
--asc-btn-secondary-text: #202122;
--asc-tab-active-border: #3366cc;
--asc-tab-active-bg: #ffffff;
--asc-badge-bg: #eaecf0;
--asc-badge-text: #202122;
--asc-badge-active-bg: #3366cc;
--asc-badge-active-text: #ffffff;
--asc-link: #3366cc;
--asc-error: #d33;
--asc-success: #2ea44f;
--asc-warning: #b35900;
--asc-highlight-bg: #eaf3ff;
background: var(--asc-card-bg);
border: 1px solid var(--asc-card-border);
color: var(--asc-text);
border-radius: 6px;
padding: 16px;
margin: 15px 0;
max-width: 1100px;
width: 100%;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* MediaWiki Vector 2022 Night mode & Dark Mode Gadget/Skin Classes */
html.skin-theme-clientpref-night .asc-container,
html.dark-mode .asc-container,
body.skin-night .asc-container,
body.theme-dark .asc-container,
html.client-dark-mode .asc-container {
--asc-card-bg: #202122;
--asc-card-border: #54595d;
--asc-text: #f8f9fa;
--asc-subtext: #a2a9b1;
--asc-input-bg: #27292d;
--asc-input-border: #72777d;
--asc-input-text: #f8f9fa;
--asc-table-hdr-bg: #2e3034;
--asc-table-hdr-text: #ffffff;
--asc-table-border: #54595d;
--asc-table-bg: #202122;
--asc-btn-primary: #447ff5;
--asc-btn-primary-hover: #3366cc;
--asc-btn-copy: #34b359;
--asc-btn-copy-hover: #2ea44f;
--asc-btn-secondary: #2e3034;
--asc-btn-secondary-hover: #3a3d42;
--asc-btn-secondary-text: #f8f9fa;
--asc-tab-active-border: #447ff5;
--asc-tab-active-bg: #27292d;
--asc-badge-bg: #2e3034;
--asc-badge-text: #f8f9fa;
--asc-badge-active-bg: #447ff5;
--asc-badge-active-text: #ffffff;
--asc-link: #6699ff;
--asc-error: #ff6b6b;
--asc-success: #34b359;
--asc-warning: #ffaa33;
--asc-highlight-bg: #1c2b42;
}
/* OS automatic Dark mode */
@media (prefers-color-scheme: dark) {
html.skin-theme-clientpref-os .asc-container,
html:not(.skin-theme-clientpref-day):not(.skin-theme-clientpref-night) .asc-container {
--asc-card-bg: #202122;
--asc-card-border: #54595d;
--asc-text: #f8f9fa;
--asc-subtext: #a2a9b1;
--asc-input-bg: #27292d;
--asc-input-border: #72777d;
--asc-input-text: #f8f9fa;
--asc-table-hdr-bg: #2e3034;
--asc-table-hdr-text: #ffffff;
--asc-table-border: #54595d;
--asc-table-bg: #202122;
--asc-btn-primary: #447ff5;
--asc-btn-primary-hover: #3366cc;
--asc-btn-copy: #34b359;
--asc-btn-copy-hover: #2ea44f;
--asc-btn-secondary: #2e3034;
--asc-btn-secondary-hover: #3a3d42;
--asc-btn-secondary-text: #f8f9fa;
--asc-tab-active-border: #447ff5;
--asc-tab-active-bg: #27292d;
--asc-badge-bg: #2e3034;
--asc-badge-text: #f8f9fa;
--asc-badge-active-bg: #447ff5;
--asc-badge-active-text: #ffffff;
--asc-link: #6699ff;
--asc-error: #ff6b6b;
--asc-success: #34b359;
--asc-warning: #ffaa33;
--asc-highlight-bg: #1c2b42;
}
}
.asc-tabs {
display: flex;
border-bottom: 2px solid var(--asc-card-border);
gap: 6px;
margin-bottom: 16px;
overflow-x: auto;
}
.asc-tab-btn {
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: var(--asc-subtext);
padding: 10px 18px;
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.asc-tab-btn:hover {
color: var(--asc-text);
background: var(--asc-btn-secondary);
}
.asc-tab-btn.active {
color: var(--asc-btn-primary);
border-bottom: 3px solid var(--asc-tab-active-border);
background: var(--asc-tab-active-bg);
}
.asc-tab-content {
display: none;
}
.asc-tab-content.active {
display: block;
}
.asc-control-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 15px;
}
.asc-input {
background: var(--asc-input-bg) !important;
border: 1px solid var(--asc-input-border) !important;
color: var(--asc-input-text) !important;
border-radius: 4px;
padding: 6px 10px;
font-size: 0.9em;
box-sizing: border-box;
}
.asc-btn {
border: none;
padding: 7px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: background 0.15s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.asc-btn-primary {
background: var(--asc-btn-primary);
color: #ffffff !important;
}
.asc-btn-primary:hover {
background: var(--asc-btn-primary-hover);
}
.asc-btn-copy {
background: var(--asc-btn-copy);
color: #ffffff !important;
}
.asc-btn-copy:hover {
background: var(--asc-btn-copy-hover);
}
.asc-btn-secondary {
background: var(--asc-btn-secondary);
color: var(--asc-btn-secondary-text) !important;
border: 1px solid var(--asc-card-border);
}
.asc-btn-secondary:hover {
background: var(--asc-btn-secondary-hover);
}
.asc-stepper-btn {
width: 30px;
height: 30px;
border-radius: 4px;
border: 1px solid var(--asc-input-border);
background: var(--asc-btn-secondary);
color: var(--asc-text);
font-weight: bold;
font-size: 1.1em;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.asc-stepper-btn:hover:not(:disabled) {
background: var(--asc-btn-primary);
color: #ffffff;
border-color: var(--asc-btn-primary);
}
.asc-stepper-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.asc-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
background: var(--asc-badge-bg);
color: var(--asc-badge-text);
}
.asc-badge-pool {
font-size: 1em;
padding: 4px 10px;
cursor: default;
border: 1px solid var(--asc-card-border);
}
.asc-badge-pool.assigned {
opacity: 0.45;
text-decoration: line-through;
}
.asc-attr-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.asc-attr-card {
background: var(--asc-card-bg);
border: 1px solid var(--asc-card-border);
border-radius: 6px;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s ease;
}
.asc-attr-card:hover {
border-color: var(--asc-btn-primary);
}
.asc-attr-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.asc-attr-title {
font-weight: bold;
font-size: 0.95em;
}
.asc-attr-desc {
font-size: 0.78em;
color: var(--asc-subtext);
}
.asc-table {
width: 100%;
border-collapse: collapse;
text-align: center;
font-size: 0.9em;
margin-top: 10px;
}
.asc-table th {
background: var(--asc-table-hdr-bg);
color: var(--asc-table-hdr-text);
border: 1px solid var(--asc-table-border);
padding: 6px 10px;
}
.asc-table td {
background: var(--asc-table-bg);
color: var(--asc-text);
border: 1px solid var(--asc-table-border);
padding: 6px 10px;
}
.asc-budget-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
background: var(--asc-highlight-bg);
border: 1px solid var(--asc-card-border);
border-radius: 6px;
padding: 10px 16px;
margin-bottom: 14px;
}
.asc-budget-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
}
.asc-status-text {
font-weight: bold;
font-size: 0.85em;
margin-left: 8px;
}
`;
function injectStyles() {
if (!document.getElementById('asc-calc-injected-styles')) {
const style = document.createElement('style');
style.id = 'asc-calc-injected-styles';
style.textContent = CSS_STYLES;
document.head.appendChild(style);
}
}
// =========================================================================
// 4. Widget Initialization & Logic
// =========================================================================
function initAbilityScoreWidget() {
const container = document.getElementById('ability-score-calc-widget') ||
document.getElementById('ability-theme-calc-widget') ||
document.querySelector('.ability-score-calc-widget');
if (!container || container.dataset.loaded) return;
container.dataset.loaded = 'true';
injectStyles();
// ---------------------------------------------------------------------
// State Management
// ---------------------------------------------------------------------
const state = {
activeTab: 'spread',
spread: {
presetKey: 'standard',
assignments: { STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8 }
},
roller: {
fantasyLevel: 'standard',
rolledScores: [],
assignmentMode: 'order',
assignments: { STR: null, DEX: null, CON: null, INT: null, WIS: null, CHA: null },
source: ''
},
pointbuy: {
budgetKey: 'standard',
customBudget: 15,
scores: { STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 10, CHA: 10 }
}
};
// ---------------------------------------------------------------------
// Render Shell
// ---------------------------------------------------------------------
container.innerHTML = `
<div class="asc-container">
<div class="asc-tabs">
<button class="asc-tab-btn active" data-tab="spread">📋 Standard Spread</button>
<button class="asc-tab-btn" data-tab="roller">🎲 Dice Roller</button>
<button class="asc-tab-btn" data-tab="pointbuy">⚖️ Point Buy</button>
</div>
<!-- TAB 1: STANDARD SPREAD -->
<div id="asc-tab-spread" class="asc-tab-content active">
<div class="asc-control-row">
<div>
<label style="font-weight: bold; margin-right: 6px;">Campaign Spread:</label>
<select id="asc-spread-preset" class="asc-input">
<option value="standard" selected>Standard Fantasy (15, 14, 13, 12, 10, 8)</option>
<option value="low">Low Fantasy (14, 12, 10, 10, 8, 8)</option>
<option value="high">High Fantasy (19, 18, 16, 13, 12, 10)</option>
</select>
</div>
<button id="asc-spread-btn-auto" class="asc-btn asc-btn-secondary" title="Assign in standard order">Auto-Assign</button>
<button id="asc-spread-btn-clear" class="asc-btn asc-btn-secondary" title="Clear all assignments">Clear</button>
</div>
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<span style="font-weight: 600; font-size: 0.9em; color: var(--asc-subtext);">Available Scores:</span>
<div id="asc-spread-pool" style="display: flex; gap: 6px; flex-wrap: wrap;"></div>
</div>
<div id="asc-spread-grid" class="asc-attr-grid"></div>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 10px; margin-bottom: 10px;">
<button id="asc-spread-btn-copy-wiki" class="asc-btn asc-btn-copy">Copy WikiText</button>
<button id="asc-spread-btn-copy-text" class="asc-btn asc-btn-secondary">Copy Summary</button>
<span id="asc-spread-copy-status" class="asc-status-text" style="color: var(--asc-success); display: none;">Copied!</span>
</div>
<div id="asc-spread-preview"></div>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; color: var(--asc-link); font-size: 0.88em; font-weight: 500;">View Generated WikiText</summary>
<textarea id="asc-spread-wikitext" class="asc-input" readonly style="width: 100%; height: 85px; margin-top: 5px; font-family: monospace; font-size: 0.85em;"></textarea>
</details>
</div>
<!-- TAB 2: DICE ROLLER -->
<div id="asc-tab-roller" class="asc-tab-content">
<div class="asc-control-row">
<div>
<label style="font-weight: bold; margin-right: 6px;">Fantasy Level:</label>
<select id="asc-roller-level" class="asc-input">
<option value="standard" selected>Standard Fantasy (4d6 drop 1, reroll ≤ 8)</option>
<option value="low">Low Fantasy (3d6, reroll ≤ 7)</option>
<option value="high">High Fantasy (4d6 drop 1, 7 rolls drop lowest)</option>
<option value="epic">Epic Fantasy (5d6 drop 2, 7 rolls drop lowest)</option>
</select>
</div>
<button id="asc-roller-btn-roll" class="asc-btn asc-btn-primary">🎲 Roll Scores</button>
<span id="asc-roller-source-badge" class="asc-badge" style="display: none;"></span>
</div>
<div id="asc-roller-results-bar" style="margin-bottom: 14px; display: none;">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px;">
<span style="font-weight: 600; font-size: 0.9em; color: var(--asc-subtext);">Rolled Scores:</span>
<div id="asc-roller-scores-pool" style="display: flex; gap: 6px; flex-wrap: wrap;"></div>
</div>
<div style="display: flex; gap: 12px; align-items: center; font-size: 0.88em;">
<label style="font-weight: 600; cursor: pointer;">
<input type="radio" name="asc-roller-mode" value="order" checked> Assign in Order (STR → CHA)
</label>
<label style="font-weight: 600; cursor: pointer;">
<input type="radio" name="asc-roller-mode" value="custom"> Custom Assign
</label>
</div>
</div>
<div id="asc-roller-grid" class="asc-attr-grid"></div>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 10px; margin-bottom: 10px;">
<button id="asc-roller-btn-copy-wiki" class="asc-btn asc-btn-copy" style="display: none;">Copy WikiText</button>
<button id="asc-roller-btn-copy-text" class="asc-btn asc-btn-secondary" style="display: none;">Copy Summary</button>
<span id="asc-roller-copy-status" class="asc-status-text" style="color: var(--asc-success); display: none;">Copied!</span>
</div>
<div id="asc-roller-preview"></div>
<details id="asc-roller-details" style="margin-top: 10px; display: none;">
<summary style="cursor: pointer; color: var(--asc-link); font-size: 0.88em; font-weight: 500;">View Generated WikiText</summary>
<textarea id="asc-roller-wikitext" class="asc-input" readonly style="width: 100%; height: 85px; margin-top: 5px; font-family: monospace; font-size: 0.85em;"></textarea>
</details>
</div>
<!-- TAB 3: POINT BUY -->
<div id="asc-tab-pointbuy" class="asc-tab-content">
<div class="asc-control-row">
<div>
<label style="font-weight: bold; margin-right: 6px;">Campaign Type:</label>
<select id="asc-pb-budget-select" class="asc-input">
<option value="low">Low Fantasy (10 Points)</option>
<option value="standard" selected>Standard Fantasy (15 Points)</option>
<option value="high">High Fantasy (20 Points)</option>
<option value="epic">Epic Fantasy (25 Points)</option>
<option value="custom">Custom Points</option>
</select>
</div>
<div id="asc-pb-custom-wrap" style="display: none; align-items: center; gap: 6px;">
<label style="font-weight: bold;">Points:</label>
<input id="asc-pb-custom-points" class="asc-input" type="number" value="15" min="0" max="100" style="width: 65px;">
</div>
<button id="asc-pb-btn-reset" class="asc-btn asc-btn-secondary" title="Reset all abilities to 10">Reset to 10s</button>
</div>
<div class="asc-budget-bar">
<div class="asc-budget-item">
<span style="color: var(--asc-subtext);">Total Budget:</span>
<span id="asc-pb-budget-total" style="font-weight: bold;">15</span>
</div>
<div class="asc-budget-item">
<span style="color: var(--asc-subtext);">Points Spent:</span>
<span id="asc-pb-budget-spent" style="font-weight: bold;">0</span>
</div>
<div class="asc-budget-item">
<span style="color: var(--asc-subtext);">Remaining:</span>
<span id="asc-pb-budget-remaining" class="asc-badge" style="font-size: 0.95em; font-weight: bold;">15</span>
</div>
<div id="asc-pb-budget-message" style="font-size: 0.85em; font-weight: 600;"></div>
</div>
<div id="asc-pb-grid" class="asc-attr-grid"></div>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 10px; margin-bottom: 10px;">
<button id="asc-pb-btn-copy-wiki" class="asc-btn asc-btn-copy">Copy WikiText</button>
<button id="asc-pb-btn-copy-text" class="asc-btn asc-btn-secondary">Copy Summary</button>
<span id="asc-pb-copy-status" class="asc-status-text" style="color: var(--asc-success); display: none;">Copied!</span>
</div>
<div id="asc-pb-preview"></div>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; color: var(--asc-link); font-size: 0.88em; font-weight: 500;">View Ability Score Cost Table</summary>
<div style="margin-top: 8px; overflow-x: auto;">
<table class="asc-table" style="max-width: 450px;">
<tr><th>Score</th><th>Point Cost</th><th>Modifier</th></tr>
<tr><td>7</td><td>-4 (gains 4)</td><td>-2</td></tr>
<tr><td>8</td><td>-2 (gains 2)</td><td>-1</td></tr>
<tr><td>9</td><td>-1 (gains 1)</td><td>-1</td></tr>
<tr><td>10</td><td>0 (base)</td><td>0</td></tr>
<tr><td>11</td><td>1</td><td>0</td></tr>
<tr><td>12</td><td>2</td><td>+1</td></tr>
<tr><td>13</td><td>3</td><td>+1</td></tr>
<tr><td>14</td><td>5</td><td>+2</td></tr>
<tr><td>15</td><td>7</td><td>+2</td></tr>
<tr><td>16</td><td>10</td><td>+3</td></tr>
<tr><td>17</td><td>13</td><td>+3</td></tr>
<tr><td>18</td><td>17</td><td>+4</td></tr>
</table>
</div>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; color: var(--asc-link); font-size: 0.88em; font-weight: 500;">View Generated WikiText</summary>
<textarea id="asc-pb-wikitext" class="asc-input" readonly style="width: 100%; height: 85px; margin-top: 5px; font-family: monospace; font-size: 0.85em;"></textarea>
</details>
</div>
</div>
`;
// ---------------------------------------------------------------------
// Tab Navigation
// ---------------------------------------------------------------------
const tabButtons = container.querySelectorAll('.asc-tab-btn');
tabButtons.forEach(btn => {
btn.addEventListener('click', function () {
const targetTab = this.dataset.tab;
state.activeTab = targetTab;
tabButtons.forEach(b => b.classList.remove('active'));
this.classList.add('active');
container.querySelectorAll('.asc-tab-content').forEach(pane => {
pane.classList.remove('active');
});
const targetPane = document.getElementById(`asc-tab-${targetTab}`);
if (targetPane) targetPane.classList.add('active');
});
});
// ---------------------------------------------------------------------
// Table & Output Builders
// ---------------------------------------------------------------------
function buildWikiText(scoresMap, titleNote = '') {
const lines = ['{| class="wikitable"'];
if (titleNote) lines.push(`|+ ${titleNote}`);
lines.push('! Ability Theme !! Score !! Modifier');
for (const attr of CORE_ATTRIBUTES) {
const score = scoresMap[attr.key];
const scoreStr = (score !== null && score !== undefined) ? String(score) : '—';
const mod = (score !== null && score !== undefined) ? formatModifier(calculateModifier(score)) : '—';
lines.push(`|-\n| ${attr.name} (${attr.key}) || ${scoreStr} || ${mod}`);
}
lines.push('|}');
return lines.join('\n');
}
function buildSummaryText(scoresMap) {
return CORE_ATTRIBUTES.map(attr => {
const score = scoresMap[attr.key];
if (score === null || score === undefined) return `${attr.key}: —`;
const mod = formatModifier(calculateModifier(score));
return `${attr.key}: ${score} (${mod})`;
}).join(' | ');
}
function buildPreviewTable(scoresMap) {
let html = '<table class="asc-table">';
html += '<tr><th>Ability Theme</th><th>Score</th><th>Modifier</th></tr>';
for (const attr of CORE_ATTRIBUTES) {
const score = scoresMap[attr.key];
const scoreStr = (score !== null && score !== undefined) ? `<strong>${score}</strong>` : '<em>Unassigned</em>';
const modStr = (score !== null && score !== undefined) ? formatModifier(calculateModifier(score)) : '—';
html += `<tr><td style="text-align: left; font-weight: 600;">${attr.name} (${attr.key})</td><td>${scoreStr}</td><td>${modStr}</td></tr>`;
}
html += '</table>';
return html;
}
function setupClipboardCopy(buttonId, textGetter, statusId) {
const btn = document.getElementById(buttonId);
const status = document.getElementById(statusId);
if (!btn) return;
btn.addEventListener('click', function () {
const text = textGetter();
navigator.clipboard.writeText(text).then(() => {
if (status) {
status.style.display = 'inline';
setTimeout(() => { status.style.display = 'none'; }, 2000);
}
}).catch(() => {
alert('Could not copy to clipboard. Please select and copy from the text box below.');
});
});
}
// =====================================================================
// TAB 1: Standard Spread Logic
// =====================================================================
function renderSpreadTab() {
const preset = SPREADS[state.spread.presetKey];
const spreadScores = [...preset.scores];
// Render Available Pool
const poolEl = document.getElementById('asc-spread-pool');
poolEl.innerHTML = '';
const assignedVals = Object.values(state.spread.assignments).filter(v => v !== null);
const tempAssigned = [...assignedVals];
spreadScores.forEach((score) => {
const badge = document.createElement('span');
badge.className = 'asc-badge asc-badge-pool';
badge.textContent = score;
const foundIdx = tempAssigned.indexOf(score);
if (foundIdx !== -1) {
badge.classList.add('assigned');
tempAssigned.splice(foundIdx, 1);
}
poolEl.appendChild(badge);
});
// Render Attribute Grid
const gridEl = document.getElementById('asc-spread-grid');
gridEl.innerHTML = '';
CORE_ATTRIBUTES.forEach(attr => {
const currentScore = state.spread.assignments[attr.key];
const card = document.createElement('div');
card.className = 'asc-attr-card';
const info = document.createElement('div');
info.className = 'asc-attr-info';
info.innerHTML = `
<div class="asc-attr-title">${attr.name} (${attr.key})</div>
<div class="asc-attr-desc">${attr.desc}</div>
`;
const controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.gap = '8px';
const select = document.createElement('select');
select.className = 'asc-input';
select.style.minWidth = '75px';
select.innerHTML = '<option value="">--</option>';
spreadScores.forEach((s) => {
const opt = document.createElement('option');
opt.value = String(s);
opt.textContent = String(s);
select.appendChild(opt);
});
if (currentScore !== null && currentScore !== undefined) {
select.value = String(currentScore);
} else {
select.value = '';
}
select.addEventListener('change', function () {
const newScore = this.value ? parseInt(this.value, 10) : null;
if (newScore !== null) {
const currentUsage = Object.values(state.spread.assignments).filter(v => v === newScore).length;
const maxAllowed = spreadScores.filter(v => v === newScore).length;
if (currentUsage >= maxAllowed) {
for (const k of Object.keys(state.spread.assignments)) {
if (k !== attr.key && state.spread.assignments[k] === newScore) {
state.spread.assignments[k] = currentScore;
break;
}
}
}
}
state.spread.assignments[attr.key] = newScore;
renderSpreadTab();
});
const modBadge = document.createElement('span');
modBadge.className = 'asc-badge';
modBadge.style.minWidth = '30px';
modBadge.style.textAlign = 'center';
modBadge.textContent = currentScore !== null ? formatModifier(calculateModifier(currentScore)) : '—';
if (currentScore !== null && calculateModifier(currentScore) > 0) {
modBadge.style.background = 'var(--asc-highlight-bg)';
modBadge.style.color = 'var(--asc-btn-primary)';
}
controls.appendChild(select);
controls.appendChild(modBadge);
card.appendChild(info);
card.appendChild(controls);
gridEl.appendChild(card);
});
const wikiText = buildWikiText(state.spread.assignments, `Table: ${preset.name} Ability Scores`);
document.getElementById('asc-spread-wikitext').value = wikiText;
document.getElementById('asc-spread-preview').innerHTML = buildPreviewTable(state.spread.assignments);
}
document.getElementById('asc-spread-preset').addEventListener('change', function () {
state.spread.presetKey = this.value;
const scores = SPREADS[this.value].scores;
CORE_ATTRIBUTES.forEach((attr, idx) => {
state.spread.assignments[attr.key] = scores[idx] !== undefined ? scores[idx] : null;
});
renderSpreadTab();
});
document.getElementById('asc-spread-btn-auto').addEventListener('click', function () {
const scores = SPREADS[state.spread.presetKey].scores;
CORE_ATTRIBUTES.forEach((attr, idx) => {
state.spread.assignments[attr.key] = scores[idx] !== undefined ? scores[idx] : null;
});
renderSpreadTab();
});
document.getElementById('asc-spread-btn-clear').addEventListener('click', function () {
CORE_ATTRIBUTES.forEach(attr => {
state.spread.assignments[attr.key] = null;
});
renderSpreadTab();
});
setupClipboardCopy('asc-spread-btn-copy-wiki', () => document.getElementById('asc-spread-wikitext').value, 'asc-spread-copy-status');
setupClipboardCopy('asc-spread-btn-copy-text', () => buildSummaryText(state.spread.assignments), 'asc-spread-copy-status');
// =====================================================================
// TAB 2: Dice Roller Logic
// =====================================================================
async function rollAbilityStats() {
const levelKey = document.getElementById('asc-roller-level').value;
state.roller.fantasyLevel = levelKey;
const config = ROLLER_CONFIGS[levelKey];
const rollBtn = document.getElementById('asc-roller-btn-roll');
rollBtn.disabled = true;
rollBtn.textContent = '⏳ Rolling...';
let finalScores = [];
let sourceLabel = '';
try {
const apiBase = (window.FUTUREPATH_API_BASE !== undefined) ? window.FUTUREPATH_API_BASE : '';
const url = `${apiBase}/tasks/roll/character/${config.apiLevel}`;
const response = await fetch(url, {
headers: { 'Accept': 'application/json' }
});
if (response.ok) {
const data = await response.json();
if (data && data.Rolls && Array.isArray(data.Rolls) && data.Rolls.length >= 6) {
finalScores = data.Rolls.map(r => r.Total);
sourceLabel = `API Endpoint (/tasks/roll/character/${config.apiLevel})`;
}
}
} catch (err) {
// Fallback on network/CORS error
}
if (finalScores.length < 6) {
finalScores = rollCharacterLocally(config);
sourceLabel = 'Local FuturePath Engine (Client)';
}
state.roller.rolledScores = finalScores;
state.roller.source = sourceLabel;
if (state.roller.assignmentMode === 'order') {
CORE_ATTRIBUTES.forEach((attr, idx) => {
state.roller.assignments[attr.key] = finalScores[idx] !== undefined ? finalScores[idx] : null;
});
}
rollBtn.disabled = false;
rollBtn.textContent = '🎲 Roll Scores';
renderRollerTab();
}
function renderRollerTab() {
const hasRolls = state.roller.rolledScores && state.roller.rolledScores.length > 0;
const resultsBar = document.getElementById('asc-roller-results-bar');
const sourceBadge = document.getElementById('asc-roller-source-badge');
const copyWikiBtn = document.getElementById('asc-roller-btn-copy-wiki');
const copyTextBtn = document.getElementById('asc-roller-btn-copy-text');
const detailsEl = document.getElementById('asc-roller-details');
if (!hasRolls) {
resultsBar.style.display = 'none';
sourceBadge.style.display = 'none';
copyWikiBtn.style.display = 'none';
copyTextBtn.style.display = 'none';
detailsEl.style.display = 'none';
document.getElementById('asc-roller-grid').innerHTML = `
<div style="grid-column: 1 / -1; padding: 25px; text-align: center; color: var(--asc-subtext); border: 1px dashed var(--asc-card-border); border-radius: 6px;">
Select a Fantasy level above and click <strong>Roll Scores</strong> to generate character stats using the FuturePath rules.
</div>
`;
document.getElementById('asc-roller-preview').innerHTML = '';
return;
}
resultsBar.style.display = 'block';
sourceBadge.style.display = 'inline-block';
sourceBadge.textContent = state.roller.source;
copyWikiBtn.style.display = 'inline-flex';
copyTextBtn.style.display = 'inline-flex';
detailsEl.style.display = 'block';
const poolEl = document.getElementById('asc-roller-scores-pool');
poolEl.innerHTML = '';
const tempAssigned = Object.values(state.roller.assignments).filter(v => v !== null);
state.roller.rolledScores.forEach(score => {
const badge = document.createElement('span');
badge.className = 'asc-badge asc-badge-pool';
badge.textContent = score;
const foundIdx = tempAssigned.indexOf(score);
if (foundIdx !== -1) {
badge.classList.add('assigned');
tempAssigned.splice(foundIdx, 1);
}
poolEl.appendChild(badge);
});
const gridEl = document.getElementById('asc-roller-grid');
gridEl.innerHTML = '';
CORE_ATTRIBUTES.forEach((attr) => {
const currentScore = state.roller.assignments[attr.key];
const card = document.createElement('div');
card.className = 'asc-attr-card';
const info = document.createElement('div');
info.className = 'asc-attr-info';
info.innerHTML = `
<div class="asc-attr-title">${attr.name} (${attr.key})</div>
<div class="asc-attr-desc">${attr.desc}</div>
`;
const controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.gap = '8px';
if (state.roller.assignmentMode === 'order') {
const scoreDisplay = document.createElement('span');
scoreDisplay.style.fontWeight = 'bold';
scoreDisplay.style.fontSize = '1.05em';
scoreDisplay.textContent = currentScore !== null ? String(currentScore) : '—';
controls.appendChild(scoreDisplay);
} else {
const select = document.createElement('select');
select.className = 'asc-input';
select.style.minWidth = '75px';
select.innerHTML = '<option value="">--</option>';
state.roller.rolledScores.forEach(s => {
const opt = document.createElement('option');
opt.value = String(s);
opt.textContent = String(s);
select.appendChild(opt);
});
select.value = currentScore !== null ? String(currentScore) : '';
select.addEventListener('change', function () {
const newScore = this.value ? parseInt(this.value, 10) : null;
if (newScore !== null) {
const currentUsage = Object.values(state.roller.assignments).filter(v => v === newScore).length;
const maxAllowed = state.roller.rolledScores.filter(v => v === newScore).length;
if (currentUsage >= maxAllowed) {
for (const k of Object.keys(state.roller.assignments)) {
if (k !== attr.key && state.roller.assignments[k] === newScore) {
state.roller.assignments[k] = currentScore;
break;
}
}
}
}
state.roller.assignments[attr.key] = newScore;
renderRollerTab();
});
controls.appendChild(select);
}
const modBadge = document.createElement('span');
modBadge.className = 'asc-badge';
modBadge.style.minWidth = '30px';
modBadge.style.textAlign = 'center';
modBadge.textContent = currentScore !== null ? formatModifier(calculateModifier(currentScore)) : '—';
if (currentScore !== null && calculateModifier(currentScore) > 0) {
modBadge.style.background = 'var(--asc-highlight-bg)';
modBadge.style.color = 'var(--asc-btn-primary)';
}
controls.appendChild(modBadge);
card.appendChild(info);
card.appendChild(controls);
gridEl.appendChild(card);
});
const config = ROLLER_CONFIGS[state.roller.fantasyLevel];
const wikiText = buildWikiText(state.roller.assignments, `Table: ${config.name} Rolled Ability Scores`);
document.getElementById('asc-roller-wikitext').value = wikiText;
document.getElementById('asc-roller-preview').innerHTML = buildPreviewTable(state.roller.assignments);
}
document.getElementById('asc-roller-btn-roll').addEventListener('click', rollAbilityStats);
const modeRadios = container.querySelectorAll('input[name="asc-roller-mode"]');
modeRadios.forEach(radio => {
radio.addEventListener('change', function () {
state.roller.assignmentMode = this.value;
if (this.value === 'order' && state.roller.rolledScores.length > 0) {
CORE_ATTRIBUTES.forEach((attr, idx) => {
state.roller.assignments[attr.key] = state.roller.rolledScores[idx] || null;
});
}
renderRollerTab();
});
});
setupClipboardCopy('asc-roller-btn-copy-wiki', () => document.getElementById('asc-roller-wikitext').value, 'asc-roller-copy-status');
setupClipboardCopy('asc-roller-btn-copy-text', () => buildSummaryText(state.roller.assignments), 'asc-roller-copy-status');
// =====================================================================
// TAB 3: Point Buy Logic
// =====================================================================
function getPointBuyBudget() {
if (state.pointbuy.budgetKey === 'custom') {
return parseInt(document.getElementById('asc-pb-custom-points').value, 10) || 0;
}
return POINT_BUY_BUDGETS[state.pointbuy.budgetKey].points;
}
function calculatePointsSpent() {
let total = 0;
for (const attr of CORE_ATTRIBUTES) {
const score = state.pointbuy.scores[attr.key] || 10;
total += (POINT_BUY_COSTS[score] !== undefined ? POINT_BUY_COSTS[score] : 0);
}
return total;
}
function renderPointBuyTab() {
const budgetTotal = getPointBuyBudget();
const spent = calculatePointsSpent();
const remaining = budgetTotal - spent;
document.getElementById('asc-pb-budget-total').textContent = String(budgetTotal);
document.getElementById('asc-pb-budget-spent').textContent = String(spent);
const remBadge = document.getElementById('asc-pb-budget-remaining');
remBadge.textContent = String(remaining);
const msgEl = document.getElementById('asc-pb-budget-message');
if (remaining === 0) {
remBadge.style.background = 'var(--asc-success)';
remBadge.style.color = '#ffffff';
msgEl.style.color = 'var(--asc-success)';
msgEl.textContent = '✓ Points perfectly allocated!';
} else if (remaining > 0) {
remBadge.style.background = 'var(--asc-btn-primary)';
remBadge.style.color = '#ffffff';
msgEl.style.color = 'var(--asc-subtext)';
msgEl.textContent = `${remaining} point${remaining === 1 ? '' : 's'} available to spend`;
} else {
remBadge.style.background = 'var(--asc-error)';
remBadge.style.color = '#ffffff';
msgEl.style.color = 'var(--asc-error)';
msgEl.textContent = `⚠ Over budget by ${Math.abs(remaining)} point${Math.abs(remaining) === 1 ? '' : 's'}!`;
}
const gridEl = document.getElementById('asc-pb-grid');
gridEl.innerHTML = '';
CORE_ATTRIBUTES.forEach(attr => {
const currentScore = state.pointbuy.scores[attr.key];
const currentCost = POINT_BUY_COSTS[currentScore] || 0;
const mod = calculateModifier(currentScore);
const card = document.createElement('div');
card.className = 'asc-attr-card';
const info = document.createElement('div');
info.className = 'asc-attr-info';
info.innerHTML = `
<div class="asc-attr-title">${attr.name} (${attr.key})</div>
<div class="asc-attr-desc">${attr.desc}</div>
`;
const controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.gap = '6px';
const btnMinus = document.createElement('button');
btnMinus.className = 'asc-stepper-btn';
btnMinus.textContent = '−';
btnMinus.title = 'Decrease score';
btnMinus.disabled = (currentScore <= 7);
btnMinus.addEventListener('click', function () {
if (state.pointbuy.scores[attr.key] > 7) {
state.pointbuy.scores[attr.key] -= 1;
renderPointBuyTab();
}
});
const scoreBox = document.createElement('span');
scoreBox.style.display = 'inline-block';
scoreBox.style.minWidth = '28px';
scoreBox.style.textAlign = 'center';
scoreBox.style.fontWeight = 'bold';
scoreBox.style.fontSize = '1.1em';
scoreBox.textContent = String(currentScore);
const btnPlus = document.createElement('button');
btnPlus.className = 'asc-stepper-btn';
btnPlus.textContent = '+';
btnPlus.title = 'Increase score';
btnPlus.disabled = (currentScore >= 18);
btnPlus.addEventListener('click', function () {
if (state.pointbuy.scores[attr.key] < 18) {
state.pointbuy.scores[attr.key] += 1;
renderPointBuyTab();
}
});
const costBadge = document.createElement('span');
costBadge.className = 'asc-badge';
costBadge.style.fontSize = '0.8em';
costBadge.style.minWidth = '52px';
costBadge.style.textAlign = 'center';
costBadge.textContent = `${currentCost >= 0 ? '+' : ''}${currentCost} pts`;
const modBadge = document.createElement('span');
modBadge.className = 'asc-badge';
modBadge.style.minWidth = '28px';
modBadge.style.textAlign = 'center';
modBadge.textContent = formatModifier(mod);
if (mod > 0) {
modBadge.style.background = 'var(--asc-highlight-bg)';
modBadge.style.color = 'var(--asc-btn-primary)';
}
controls.appendChild(btnMinus);
controls.appendChild(scoreBox);
controls.appendChild(btnPlus);
controls.appendChild(costBadge);
controls.appendChild(modBadge);
card.appendChild(info);
card.appendChild(controls);
gridEl.appendChild(card);
});
const campaignName = state.pointbuy.budgetKey === 'custom'
? `Custom (${budgetTotal} pts)`
: POINT_BUY_BUDGETS[state.pointbuy.budgetKey].name;
const wikiText = buildWikiText(state.pointbuy.scores, `Table: Point Buy (${campaignName}) Ability Scores`);
document.getElementById('asc-pb-wikitext').value = wikiText;
document.getElementById('asc-pb-preview').innerHTML = buildPreviewTable(state.pointbuy.scores);
}
document.getElementById('asc-pb-budget-select').addEventListener('change', function () {
state.pointbuy.budgetKey = this.value;
const customWrap = document.getElementById('asc-pb-custom-wrap');
customWrap.style.display = (this.value === 'custom') ? 'inline-flex' : 'none';
renderPointBuyTab();
});
document.getElementById('asc-pb-custom-points').addEventListener('input', renderPointBuyTab);
document.getElementById('asc-pb-btn-reset').addEventListener('click', function () {
CORE_ATTRIBUTES.forEach(attr => {
state.pointbuy.scores[attr.key] = 10;
});
renderPointBuyTab();
});
setupClipboardCopy('asc-pb-btn-copy-wiki', () => document.getElementById('asc-pb-wikitext').value, 'asc-pb-copy-status');
setupClipboardCopy('asc-pb-btn-copy-text', () => buildSummaryText(state.pointbuy.scores), 'asc-pb-copy-status');
// Initial Renders
renderSpreadTab();
renderRollerTab();
renderPointBuyTab();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAbilityScoreWidget);
} else {
initAbilityScoreWidget();
}
})();
/**
* spaceship_damage_calc_wiki_ui.js
*
* MediaWiki Interactive Spaceship Battle Damage Probability Calculator Widget
*
* Calculates d10 Battle Pool probability distributions against Spaceship
* Battle Damage Difficulty Checks (BD-DC: Structure, Shields).
* Supports Ship Weapons re-rolls, Ship Sensor modifiers, Officer ability boosts,
* and Sensor Officer Critical Scan mechanics.
*
* Embed this script into MediaWiki:Common.js or a MediaWiki Gadget.
* To display the widget on any wiki page, add:
* <div id="spaceship-damage-calc-widget"></div>
* (or <div id="bd-dc-calc-widget"></div>)
*/
(function () {
'use strict';
// =========================================================================
// 1. Core Mathematical & Calculation Engine
// =========================================================================
// Precompute Pascal triangle for combinations nCr up to n = 100
const MAX_COMB_N = 100;
const BINOM_TABLE = [];
for (let n = 0; n <= MAX_COMB_N; n++) {
BINOM_TABLE[n] = new Array(n + 1).fill(0);
BINOM_TABLE[n][0] = 1;
BINOM_TABLE[n][n] = 1;
for (let k = 1; k < n; k++) {
BINOM_TABLE[n][k] = BINOM_TABLE[n - 1][k - 1] + BINOM_TABLE[n - 1][k];
}
}
function getCombination(n, k) {
if (k < 0 || k > n) return 0;
if (n <= MAX_COMB_N) return BINOM_TABLE[n][k];
let res = 1;
for (let i = 1; i <= k; i++) {
res = (res * (n - k + i)) / i;
}
return res;
}
/**
* Compute dynamic programming table for Weapons modifier re-rolls.
* State: D[k][r][e]
* k = active successful dice eligible to be re-rolled (0..maxK)
* r = remaining re-rolls allowed (0..R)
* e = additional successes achieved (0..r)
*/
function computeRerollDP(maxK, R, pSuccess) {
const D = [];
for (let k = 0; k <= maxK; k++) {
D[k] = [];
for (let r = 0; r <= R; r++) {
D[k][r] = new Array(r + 1).fill(0.0);
}
}
for (let k = 0; k <= maxK; k++) D[k][0][0] = 1.0;
for (let r = 0; r <= R; r++) D[0][r][0] = 1.0;
for (let r = 1; r <= R; r++) {
for (let k = 1; k <= maxK; k++) {
D[k][r][0] = (1.0 - pSuccess) * D[k - 1][r - 1][0];
for (let e = 1; e <= r; e++) {
const term1 = pSuccess * D[k][r - 1][e - 1];
const term2 = (e <= r - 1) ? (1.0 - pSuccess) * D[k - 1][r - 1][e] : 0.0;
D[k][r][e] = term1 + term2;
}
}
}
return D;
}
/**
* Calculate damage statistics for a given pool size and BD-DC (X, Y).
*/
function calculateDamageStats(N, W, S, sEnabled, WO, woEnabled, SO, soEnabled, X, Y) {
function runSimulation(critScan) {
const sMod = sEnabled ? S : 0;
const R = Math.max(0, W);
// Single die face probabilities
const faceProbs = new Array(11).fill(0);
if (!critScan) {
for (let f = 1; f <= 10; f++) faceProbs[f] = 0.1;
} else {
// Sensor Officer Crit: re-roll any 1s once
faceProbs[1] = 0.01;
for (let f = 2; f <= 10; f++) faceProbs[f] = 0.11;
}
const woBonus = (woEnabled && WO > 0) ? WO : 0;
const soBonus = (soEnabled && SO > 0) ? SO : 0;
const bHigh = Math.max(woBonus, soBonus);
const bLow = Math.min(woBonus, soBonus);
const bothActive = (woBonus > 0 && soBonus > 0);
const singleActive = (woBonus > 0) !== (soBonus > 0);
// Partition single-die faces into 4 mutually exclusive boost categories:
// Cat 0: Natural Success (f + sMod >= Y)
// Cat 1: Boostable by either officer
// Cat 2: Boostable only by officer with higher bonus
// Cat 3: Cannot be boosted to a success
const catProbs = [0.0, 0.0, 0.0, 0.0];
for (let f = 1; f <= 10; f++) {
const p = faceProbs[f];
if (f + sMod >= Y) {
catProbs[0] += p;
} else if (bothActive) {
if (f + sMod + bLow >= Y) {
catProbs[1] += p;
} else if (f + sMod + bHigh >= Y) {
catProbs[2] += p;
} else {
catProbs[3] += p;
}
} else if (singleActive) {
if (f + sMod + bHigh >= Y) {
catProbs[2] += p;
} else {
catProbs[3] += p;
}
} else {
catProbs[3] += p;
}
}
const pRerollSuccess = catProbs[0];
const maxK = Math.max(R, N);
const rerollDP = computeRerollDP(maxK, R, pRerollSuccess);
// Probability mass function of total successful d10s
const finalPmf = {};
for (let c0 = 0; c0 <= N; c0++) {
const p0 = (catProbs[0] > 0 || c0 === 0) ? Math.pow(catProbs[0], c0) : 0;
if (p0 === 0 && c0 > 0) continue;
const comb0 = getCombination(N, c0);
for (let c1 = 0; c1 <= N - c0; c1++) {
const p1 = (catProbs[1] > 0 || c1 === 0) ? Math.pow(catProbs[1], c1) : 0;
if (p1 === 0 && c1 > 0) continue;
const comb1 = getCombination(N - c0, c1);
for (let c2 = 0; c2 <= N - c0 - c1; c2++) {
const c3 = N - c0 - c1 - c2;
const p2 = (catProbs[2] > 0 || c2 === 0) ? Math.pow(catProbs[2], c2) : 0;
if (p2 === 0 && c2 > 0) continue;
const p3 = (catProbs[3] > 0 || c3 === 0) ? Math.pow(catProbs[3], c3) : 0;
if (p3 === 0 && c3 > 0) continue;
const comb2 = getCombination(N - c0 - c1, c2);
const pPart = comb0 * p0 * comb1 * p1 * comb2 * p2 * p3;
if (pPart === 0) continue;
// Optimal officer boost assignment
let boostCnt = 0;
if (bothActive) {
boostCnt = (c2 >= 1) ? (1 + Math.min(1, c1)) : Math.min(2, c1);
} else if (singleActive) {
boostCnt = Math.min(1, c2);
}
const kNat = c0;
const kCapped = Math.min(kNat, maxK);
// Convolve with re-roll extra successes
for (let e = 0; e <= R; e++) {
const pExtra = rerollDP[kCapped][R][e];
if (pExtra === 0) continue;
const totalSucc = kNat + boostCnt + e;
finalPmf[totalSucc] = (finalPmf[totalSucc] || 0.0) + pPart * pExtra;
}
}
}
}
let pSucc = 0.0;
let avgSucc = 0.0;
let pCritHit = 0.0;
for (const [sStr, p] of Object.entries(finalPmf)) {
const sCount = parseInt(sStr, 10);
if (!critScan) {
if (sCount >= X) pSucc += p;
avgSucc += sCount * p;
} else {
// Under SO Crit Scan, successful d10s count twice
const effectiveCount = sCount * 2;
if (effectiveCount >= X) pSucc += p;
// Critical hit happens when exceeding 2x the BD-DC (>= 2X + 1)
if (effectiveCount >= 2 * X + 1) pCritHit += p;
avgSucc += sCount * p;
}
}
return { pSucc, avgSucc, pCritHit };
}
const standard = runSimulation(false);
const critical = runSimulation(true);
return {
successProb: standard.pSucc,
avgSuccesses: standard.avgSucc,
soCritSuccessProb: critical.pSucc,
soCritWoCritHitProb: critical.pCritHit
};
}
function parsePoolSizes(str) {
const tokens = str.split(/[\s,;]+/).map(s => s.trim()).filter(Boolean);
const pools = [];
for (const t of tokens) {
const val = parseInt(t, 10);
if (!isNaN(val) && val >= 1 && val <= 50) {
pools.push(val);
}
}
return pools.length > 0 ? Array.from(new Set(pools)) : [4];
}
function parseBDDCs(str) {
const list = [];
const regex = /(\d+)\s*[,/]\s*(\d+)/g;
let m;
while ((m = regex.exec(str)) !== null) {
const x = parseInt(m[1], 10);
const y = parseInt(m[2], 10);
if (x >= 1 && y >= 1) {
list.push({ x, y, label: `${x},${y}` });
}
}
if (list.length === 0) {
const nums = str.split(/\s+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n));
for (let i = 0; i + 1 < nums.length; i += 2) {
list.push({ x: nums[i], y: nums[i + 1], label: `${nums[i]},${nums[i + 1]}` });
}
}
return list.length > 0 ? list : [{ x: 4, y: 4, label: '4,4' }];
}
// =========================================================================
// 2. Presets & Theme-Aware CSS Styles
// =========================================================================
const PRESETS = {
'default': { pool: '4', bddc: '4,4', weaponsMod: 1, sensorMod: 0, sensorEnabled: true, woBonus: 2, woEnabled: true, soBonus: 2, soEnabled: true },
'standard6': { pool: '6', bddc: '4,4', weaponsMod: 2, sensorMod: 1, sensorEnabled: true, woBonus: 2, woEnabled: true, soBonus: 2, soEnabled: true },
'destroyer': { pool: '6', bddc: '5,6', weaponsMod: 2, sensorMod: 1, sensorEnabled: true, woBonus: 3, woEnabled: true, soBonus: 2, soEnabled: true },
'cruiser': { pool: '8', bddc: '6,6', weaponsMod: 3, sensorMod: 2, sensorEnabled: true, woBonus: 3, woEnabled: true, soBonus: 3, soEnabled: true },
'dreadnought': { pool: '10', bddc: '7,8', weaponsMod: 4, sensorMod: 3, sensorEnabled: true, woBonus: 4, woEnabled: true, soBonus: 4, soEnabled: true }
};
const CSS_STYLES = `
.sdc-container {
--sdc-card-bg: #f8f9fa;
--sdc-card-border: #c8ccd1;
--sdc-text: #202122;
--sdc-subtext: #54595d;
--sdc-input-bg: #ffffff;
--sdc-input-border: #a2a9b1;
--sdc-input-text: #202122;
--sdc-table-hdr-bg: #eaecf0;
--sdc-table-hdr-text: #202122;
--sdc-table-border: #a2a9b1;
--sdc-table-bg: #ffffff;
--sdc-table-alt-bg: #f8f9fa;
--sdc-btn-calc: #3366cc;
--sdc-btn-calc-hover: #2a4b8d;
--sdc-btn-copy: #2ea44f;
--sdc-btn-copy-hover: #22863a;
--sdc-link: #3366cc;
--sdc-error: #d33;
--sdc-success: #2ea44f;
--sdc-crit: #b35900;
--sdc-highlight-bg: #eaf3ff;
background: var(--sdc-card-bg);
border: 1px solid var(--sdc-card-border);
color: var(--sdc-text);
border-radius: 6px;
padding: 16px;
margin: 15px 0;
max-width: 1100px;
width: 100%;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* MediaWiki Vector 2022 Night mode & Dark Mode Gadget/Skin Classes */
html.skin-theme-clientpref-night .sdc-container,
html.dark-mode .sdc-container,
body.skin-night .sdc-container,
body.theme-dark .sdc-container,
html.client-dark-mode .sdc-container {
--sdc-card-bg: #202122;
--sdc-card-border: #54595d;
--sdc-text: #f8f9fa;
--sdc-subtext: #a2a9b1;
--sdc-input-bg: #27292d;
--sdc-input-border: #72777d;
--sdc-input-text: #f8f9fa;
--sdc-table-hdr-bg: #2e3034;
--sdc-table-hdr-text: #ffffff;
--sdc-table-border: #54595d;
--sdc-table-bg: #202122;
--sdc-table-alt-bg: #27292d;
--sdc-btn-calc: #447ff5;
--sdc-btn-calc-hover: #3366cc;
--sdc-btn-copy: #34b359;
--sdc-btn-copy-hover: #2ea44f;
--sdc-link: #6699ff;
--sdc-error: #ff6b6b;
--sdc-success: #34b359;
--sdc-crit: #f59f00;
--sdc-highlight-bg: #2b384e;
}
/* OS automatic Dark mode */
@media (prefers-color-scheme: dark) {
html.skin-theme-clientpref-os .sdc-container,
html:not(.skin-theme-clientpref-day):not(.skin-theme-clientpref-night) .sdc-container {
--sdc-card-bg: #202122;
--sdc-card-border: #54595d;
--sdc-text: #f8f9fa;
--sdc-subtext: #a2a9b1;
--sdc-input-bg: #27292d;
--sdc-input-border: #72777d;
--sdc-input-text: #f8f9fa;
--sdc-table-hdr-bg: #2e3034;
--sdc-table-hdr-text: #ffffff;
--sdc-table-border: #54595d;
--sdc-table-bg: #202122;
--sdc-table-alt-bg: #27292d;
--sdc-btn-calc: #447ff5;
--sdc-btn-calc-hover: #3366cc;
--sdc-btn-copy: #34b359;
--sdc-btn-copy-hover: #2ea44f;
--sdc-link: #6699ff;
--sdc-error: #ff6b6b;
--sdc-success: #34b359;
--sdc-crit: #f59f00;
--sdc-highlight-bg: #2b384e;
}
}
.sdc-input {
background: var(--sdc-input-bg) !important;
border: 1px solid var(--sdc-input-border) !important;
color: var(--sdc-input-text) !important;
border-radius: 4px;
padding: 6px 8px;
font-size: 0.9em;
box-sizing: border-box;
}
.sdc-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.sdc-btn-calc {
background: var(--sdc-btn-calc);
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background 0.15s ease;
}
.sdc-btn-calc:hover {
background: var(--sdc-btn-calc-hover);
}
.sdc-btn-copy {
background: var(--sdc-btn-copy);
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background 0.15s ease;
}
.sdc-btn-copy:hover {
background: var(--sdc-btn-copy-hover);
}
.sdc-table {
width: 100%;
border-collapse: collapse;
text-align: center;
font-size: 0.9em;
margin-top: 12px;
}
.sdc-table th {
background: var(--sdc-table-hdr-bg);
color: var(--sdc-table-hdr-text);
border: 1px solid var(--sdc-table-border);
padding: 8px 10px;
font-weight: 600;
}
.sdc-table td {
background: var(--sdc-table-bg);
color: var(--sdc-text);
border: 1px solid var(--sdc-table-border);
padding: 7px 10px;
}
.sdc-table tr:nth-child(even) td {
background: var(--sdc-table-alt-bg);
}
`;
function injectStyles() {
if (!document.getElementById('sdc-calc-injected-styles')) {
const style = document.createElement('style');
style.id = 'sdc-calc-injected-styles';
style.textContent = CSS_STYLES;
document.head.appendChild(style);
}
}
// =========================================================================
// 3. UI Builder & Event Handling
// =========================================================================
function initSpaceshipDamageCalcWidget() {
const container = document.getElementById('spaceship-damage-calc-widget') ||
document.getElementById('bd-dc-calc-widget') ||
document.getElementById('spaceship-combat-calc-widget') ||
document.querySelector('.spaceship-damage-calc-widget');
if (!container || container.dataset.loaded) return;
container.dataset.loaded = 'true';
injectStyles();
container.innerHTML = `
<div class="sdc-container">
<div style="margin-bottom: 12px; border-bottom: 1px solid var(--sdc-card-border); padding-bottom: 8px;">
<h3 style="margin: 0 0 4px 0; font-size: 1.15em; color: var(--sdc-text);">🚀 Spaceship Combat: Battle Damage Calculator</h3>
<div style="font-size: 0.85em; color: var(--sdc-subtext);">
Calculates d10 Battle Pool probability distributions against Spaceship Battle Damage Difficulty Checks (BD-DC: Structure, Shields).
</div>
</div>
<!-- Input Controls Row 1 -->
<div style="display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; margin-bottom: 14px;">
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">Preset:</label>
<select id="sdc-select-preset" class="sdc-input" style="width: 215px;">
<option value="custom">Custom Configuration</option>
<option value="default" selected>Default (Pool 4 vs BD-DC 4,4)</option>
<option value="standard6">Standard Ship (Pool 6 vs 4,4)</option>
<option value="destroyer">Destroyer vs Shields (Pool 6 vs 5,6)</option>
<option value="cruiser">Cruiser vs Armor (Pool 8 vs 6,6)</option>
<option value="dreadnought">Dreadnought vs Fortress (Pool 10 vs 7,8)</option>
</select>
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">Battle Pool (d10s):</label>
<input id="sdc-input-pool" class="sdc-input" type="text" value="4" style="width: 120px;" placeholder="e.g. 4 or 4, 6">
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">BD-DC (Req, Threshold):</label>
<input id="sdc-input-bddc" class="sdc-input" type="text" value="4,4" style="width: 160px;" placeholder="e.g. 4,4 or 4,4; 5,6">
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">Ship Weapons Mod (Re-rolls):</label>
<input id="sdc-input-weapons-mod" class="sdc-input" type="number" value="1" min="0" max="20" style="width: 80px;">
</div>
</div>
<!-- Input Controls Row 2 -->
<div style="display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; margin-bottom: 14px;">
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">Ship Sensor Mod:</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input id="sdc-input-sensor-mod" class="sdc-input" type="number" value="0" min="-10" max="20" style="width: 70px;">
<label style="font-weight: 500; font-size: 0.88em; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<input id="sdc-check-sensor-mod" type="checkbox" checked> Add to d10s
</label>
</div>
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">Weapons Officer Mod Bonus:</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input id="sdc-input-wo-bonus" class="sdc-input" type="number" value="2" min="0" max="20" style="width: 70px;">
<label style="font-weight: 500; font-size: 0.88em; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<input id="sdc-check-wo-bonus" type="checkbox" checked> Include Boost
</label>
</div>
</div>
<div>
<label style="display: block; font-weight: bold; margin-bottom: 4px; font-size: 0.88em;">Sensor Officer Mod Bonus:</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input id="sdc-input-so-bonus" class="sdc-input" type="number" value="2" min="0" max="20" style="width: 70px;">
<label style="font-weight: 500; font-size: 0.88em; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<input id="sdc-check-so-bonus" type="checkbox" checked> Include Boost
</label>
</div>
</div>
<div style="display: flex; align-items: center; padding-bottom: 6px;">
<label style="font-weight: bold; font-size: 0.88em; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<input id="sdc-check-row-count" type="checkbox" checked> Row #
</label>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="sdc-btn-calc" class="sdc-btn-calc">Calculate</button>
<button id="sdc-btn-copy" class="sdc-btn-copy">Copy WikiText</button>
<span id="sdc-copy-status" style="color: var(--sdc-success); font-weight: bold; font-size: 0.9em; display: none;">Copied!</span>
</div>
</div>
<!-- HTML Live Preview Table -->
<div id="sdc-preview-container" style="overflow-x: auto; margin-top: 10px;"></div>
<!-- Collapsible WikiText Output -->
<details style="margin-top: 14px;">
<summary style="cursor: pointer; color: var(--sdc-link); font-size: 0.9em; font-weight: 500;">View Generated WikiText</summary>
<textarea id="sdc-wikitext-output" class="sdc-input" readonly style="width: 100%; height: 95px; margin-top: 6px; font-family: monospace; font-size: 0.85em;"></textarea>
</details>
</div>
`;
function render() {
try {
const poolStr = document.getElementById('sdc-input-pool').value;
const bddcStr = document.getElementById('sdc-input-bddc').value;
const weaponsMod = parseInt(document.getElementById('sdc-input-weapons-mod').value, 10) || 0;
const sensorMod = parseInt(document.getElementById('sdc-input-sensor-mod').value, 10) || 0;
const sensorEnabled = document.getElementById('sdc-check-sensor-mod').checked;
const woBonus = parseInt(document.getElementById('sdc-input-wo-bonus').value, 10) || 0;
const woEnabled = document.getElementById('sdc-check-wo-bonus').checked;
const soBonus = parseInt(document.getElementById('sdc-input-so-bonus').value, 10) || 0;
const soEnabled = document.getElementById('sdc-check-so-bonus').checked;
const showRowCount = document.getElementById('sdc-check-row-count').checked;
const pools = parsePoolSizes(poolStr);
const bddcs = parseBDDCs(bddcStr);
const rows = [];
for (const pool of pools) {
for (const bddc of bddcs) {
const stats = calculateDamageStats(
pool,
weaponsMod,
sensorMod,
sensorEnabled,
woBonus,
woEnabled,
soBonus,
soEnabled,
bddc.x,
bddc.y
);
rows.push({
pool,
bddc,
stats
});
}
}
// Generate WikiText
const wikiHeaders = [];
if (showRowCount) wikiHeaders.push('#');
wikiHeaders.push('Battle Pool');
wikiHeaders.push('BD-DC');
wikiHeaders.push('Success %');
wikiHeaders.push('Avg. Successful d10s');
wikiHeaders.push('SO Crit: Normal Success %');
wikiHeaders.push('SO Crit: WO Critical Hit %');
const wikiHeader = '{| class="wikitable"\n! ' + wikiHeaders.join(' || ');
const wikiRows = rows.map((r, i) => {
const cells = [];
if (showRowCount) cells.push(String(i + 1));
cells.push(`${r.pool} d10`);
cells.push(r.bddc.label);
cells.push(`${(r.stats.successProb * 100).toFixed(2)}%`);
cells.push(r.stats.avgSuccesses.toFixed(2));
cells.push(`${(r.stats.soCritSuccessProb * 100).toFixed(2)}%`);
cells.push(`${(r.stats.soCritWoCritHitProb * 100).toFixed(2)}%`);
return '|-\n| ' + cells.join(' || ');
}).join('\n');
const fullWikiText = `${wikiHeader}\n${wikiRows}\n|}`;
document.getElementById('sdc-wikitext-output').value = fullWikiText;
// Generate Live HTML Preview Table
let html = '<table class="sdc-table">';
html += '<thead><tr>';
if (showRowCount) html += '<th style="width: 40px;">#</th>';
html += '<th>Battle Pool</th>';
html += '<th>BD-DC</th>';
html += '<th title="Column 1: The % chance of a success against the BD-DC">Success %</th>';
html += '<th title="Column 2: The average number of successfully rolled d10s">Avg. Successful d10s</th>';
html += '<th title="Column 3: The % chance of a normal success when the Sensor Officer gets a Critical success on a Scan">SO Crit: Normal Success %</th>';
html += '<th title="Column 4: The % chance of the Weapon\'s Officer getting a Critical hit against the BD-DC assuming the Sensor Officer gets a Critical success on a Scan">SO Crit: WO Critical Hit %</th>';
html += '</tr></thead><tbody>';
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
html += '<tr>';
if (showRowCount) {
html += `<td style="color: var(--sdc-subtext); font-size: 0.85em;">${i + 1}</td>`;
}
html += `<td style="font-weight: bold;">${r.pool} d10</td>`;
html += `<td style="font-weight: bold; color: var(--sdc-link);">${r.bddc.label}</td>`;
html += `<td style="font-weight: 600;">${(r.stats.successProb * 100).toFixed(2)}%</td>`;
html += `<td>${r.stats.avgSuccesses.toFixed(2)}</td>`;
html += `<td style="font-weight: 600; color: var(--sdc-success);">${(r.stats.soCritSuccessProb * 100).toFixed(2)}%</td>`;
html += `<td style="font-weight: 600; color: var(--sdc-crit);">${(r.stats.soCritWoCritHitProb * 100).toFixed(2)}%</td>`;
html += '</tr>';
}
html += '</tbody></table>';
document.getElementById('sdc-preview-container').innerHTML = html;
} catch (err) {
document.getElementById('sdc-preview-container').innerHTML =
`<div style="color: var(--sdc-error); font-weight: bold; padding: 10px;">Error: ${err.message}</div>`;
}
}
// Preset Dropdown Handler
document.getElementById('sdc-select-preset').addEventListener('change', function () {
const val = this.value;
if (val !== 'custom' && PRESETS[val]) {
const p = PRESETS[val];
document.getElementById('sdc-input-pool').value = p.pool;
document.getElementById('sdc-input-bddc').value = p.bddc;
document.getElementById('sdc-input-weapons-mod').value = p.weaponsMod;
document.getElementById('sdc-input-sensor-mod').value = p.sensorMod;
document.getElementById('sdc-check-sensor-mod').checked = p.sensorEnabled;
document.getElementById('sdc-input-wo-bonus').value = p.woBonus;
document.getElementById('sdc-check-wo-bonus').checked = p.woEnabled;
document.getElementById('sdc-input-so-bonus').value = p.soBonus;
document.getElementById('sdc-check-so-bonus').checked = p.soEnabled;
render();
}
});
// Detect manual changes to switch Preset to 'custom'
const customInputs = [
'sdc-input-pool', 'sdc-input-bddc', 'sdc-input-weapons-mod',
'sdc-input-sensor-mod', 'sdc-input-wo-bonus', 'sdc-input-so-bonus'
];
customInputs.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', function () {
document.getElementById('sdc-select-preset').value = 'custom';
render();
});
}
});
const customChecks = ['sdc-check-sensor-mod', 'sdc-check-wo-bonus', 'sdc-check-so-bonus', 'sdc-check-row-count'];
customChecks.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', function () {
if (id !== 'sdc-check-row-count') {
document.getElementById('sdc-select-preset').value = 'custom';
}
render();
});
}
});
document.getElementById('sdc-btn-calc').addEventListener('click', render);
document.getElementById('sdc-btn-copy').addEventListener('click', function () {
const textarea = document.getElementById('sdc-wikitext-output');
textarea.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textarea.value);
} else {
document.execCommand('copy');
}
const status = document.getElementById('sdc-copy-status');
status.style.display = 'inline';
setTimeout(() => { status.style.display = 'none'; }, 2000);
});
// Trigger initial calculation
render();
}
// Initialize when DOM / MediaWiki is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSpaceshipDamageCalcWidget);
} else {
initSpaceshipDamageCalcWidget();
}
})();