MediaWiki:Gadget-NWOBuilder.js: Difference between revisions
From No Way Out Wiki
No edit summary |
No edit summary |
||
| Line 3: | Line 3: | ||
'use strict'; | 'use strict'; | ||
function shouldRun($root) { | function shouldRun($root) { | ||
if ($root && $root.length) return true; | if ($root && $root.length) return true; | ||
return false; | return false; | ||
} | } | ||
| Line 18: | Line 16: | ||
$root.addClass('nwo-builder').data('mounted', true); | $root.addClass('nwo-builder').data('mounted', true); | ||
$root.html( | $root.html( | ||
'<div class="wrap">' + | '<div class="wrap">' + | ||
| Line 496: | Line 493: | ||
} | } | ||
$(mount); | $(mount); | ||
mw.hook('wikipage.content').add(function () { mount(); }); | mw.hook('wikipage.content').add(function () { mount(); }); | ||
})(mediaWiki, jQuery); | })(mediaWiki, jQuery); | ||
Revision as of 15:00, 26 September 2025
// NWO Character Builder gadget – mounts into <div id="nwo-builder">
(function (mw, $) {
'use strict';
function shouldRun($root) {
if ($root && $root.length) return true;
return false;
}
function mount() {
var $root = $('#nwo-builder').first();
if (!$root.length || $root.data('mounted')) return;
if (!shouldRun($root)) return;
$root.addClass('nwo-builder').data('mounted', true);
$root.html(
'<div class="wrap">' +
'<h1>NWO Character Builder (2d6)</h1>' +
'<p class="muted">Pick a Background, add Traits & Skill Traits, then test 2d6 rolls with the live modifier. Built for the No Way Out dice rules.</p>' +
'<div class="grid">' +
'<div class="card"><div class="hd">1) Background, Trait Points & Selections</div><div class="bd" id="bg-area"></div></div>' +
'<div class="card"><div class="hd">2) 2d6 Test Roller</div><div class="bd" id="roller-area"></div></div>' +
'</div>' +
'<div class="grid" style="margin-top:12px">' +
'<div class="card"><div class="hd">3) Traits & Skills</div><div class="bd" id="traits-area"></div></div>' +
'<div class="card"><div class="hd">4) Build Summary & Modifiers</div><div class="bd" id="summary-area"></div></div>' +
'</div>' +
'<p class="kudos">Rules referenced from the NWO <em>Dice Guide</em>. ' +
'<a href="/wiki/Dice_Guide" style="color:var(--accent)">See the Dice Guide</a> for background traits, vanilla trait effects, and skill trait tiers.</p>' +
'</div>'
);
// ====== Data ======
var BACKGROUNDS = {
Scavenger: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [
{ name: 'Outdoorsman', effects: {} }, { name: 'Keen Hearing', effects: { Perception: 2 } }
]},
Thinker: { traitPoints: 10, base: { STR: 8, FIT: 8 }, freeTraits: [
{ name: 'Dextrous', effects: {} }, { name: 'Fast Learner', effects: {} }
]},
Stalwart: { traitPoints: 8, base: { STR: 10, FIT: 10 }, freeTraits: [
{ name: 'Fast Coagulation', effects: {} }, { name: 'Thick Skinned', effects: { Robustness: 2 } }
]},
Labourer: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [
{ name: 'Heavy Lifter', effects: { Robustness: 1 } }, { name: 'Organized', effects: {} }
]}
};
var POSITIVE_TRAITS = [
{ name: 'Speed Demon', cost: 1, effects: {} },
{ name: 'Low Thirst', cost: 1, effects: {} },
{ name: 'Herbalist', cost: 1, effects: {} },
{ name: 'Iron Gut', cost: 1, effects: { Robustness: 1 } },
{ name: 'Cats Eyes', cost: 2, effects: { Perception: 2 } },
{ name: 'Adrenaline Junkie', cost: 2, effects: { Initiative: 2 } },
{ name: 'Graceful', cost: 2, effects: { Hiding: 2 } },
{ name: 'Brave', cost: 2, effects: { Resolve: 2 } },
{ name: 'Inconspicuous', cost: 2, effects: { Hiding: 2 } },
{ name: 'Fast Healer', cost: 2, effects: { Robustness: 1 } },
{ name: 'Eagle Eyed', cost: 3, effects: { 'Ranged Attack': 1, Perception: 2 } },
{ name: 'Desensitized', cost: 4, effects: { Resolve: 4 } },
{ name: 'Resilient', cost: 2, effects: { Robustness: 1 } },
{ name: 'Fit', cost: 3, effects: { Fitness: 2 } },
{ name: 'Strong', cost: 3, effects: { Strength: 2 } }
];
var NEGATIVE_TRAITS = [
{ name: 'Prone to Illness', cost: 1, effects: { Robustness: -1 } },
{ name: 'Smoker', cost: 1, effects: { Robustness: -1 } },
{ name: 'Slow Driver', cost: 1, effects: {} },
{ name: 'Slow Learner', cost: 1, effects: {} },
{ name: 'Weak Stomach', cost: 1, effects: { Robustness: -1 } },
{ name: 'Fear of Blood', cost: 2, effects: { Resolve: -2 } },
{ name: 'Agoraphobic', cost: 2, effects: { Resolve: -2 } },
{ name: 'Claustrophobic', cost: 2, effects: { Resolve: -2 } },
{ name: 'Cowardly', cost: 2, effects: { Resolve: -2 } },
{ name: 'Pacifist', cost: 2, effects: { Initiative: -2 } },
{ name: 'Short Sighted', cost: 2, effects: { 'Ranged Attack': -1, Perception: -1 } },
{ name: 'Thin Skinned', cost: 2, effects: { Robustness: -2 } },
{ name: 'Conspicuous', cost: 2, effects: { Hiding: -2 } },
{ name: 'Clumsy', cost: 2, effects: { Hiding: -2 } },
{ name: 'Hard of Hearing', cost: 2, effects: { Perception: -2 } },
{ name: 'Deaf', cost: 4, effects: { Perception: -4 } },
{ name: 'All Thumbs', cost: 2, effects: {} },
{ name: 'Disorganized', cost: 3, effects: {} },
{ name: 'Weak', cost: 3, effects: {} },
{ name: 'Unfit', cost: 3, effects: {} },
{ name: 'Illiterate', cost: 4, effects: {} },
{ name: 'Slow Healer', cost: 1, effects: { Robustness: -1 } }
];
var SKILL_TIERS = {
Amateur: { cost: 1, start: 6, cap: 6, limitPerChar: Infinity },
Experienced: { cost: 2, start: 8, cap: 8, limitPerChar: 2 },
Expert: { cost: 3, start: 10, cap: 10, limitPerChar: 1 }
};
var SKILL_GROUPS = [
{ key: 'First Aid', label: 'First Aid', list: ['First Aid'] },
{ key: 'Aiming', label: 'Aiming', list: ['Aiming'] },
{ key: 'Reloading', label: 'Reloading', list: ['Reloading'] },
{ key: 'Nimble', label: 'Nimble', list: ['Nimble'] },
{ key: 'Sneaking', label: 'Sneaking', list: ['Sneaking'] }
];
var SKILL_TO_CHECK = {
Aiming: 'Ranged Attack',
Reloading: 'Reloading',
Sneaking: 'Hiding',
Nimble: 'Initiative',
FirstAid: 'First Aid'
};
var CHECKS = [
'Melee Attack','Melee Defence','Ranged Attack','Reloading',
'Perception','Resolve','Initiative','Robustness','First Aid',
'Nimble','Hiding','Strength','Fitness'
];
// ====== State ======
var state = { background: 'Scavenger', pos: new Set(), neg: new Set(), skills: {} };
// ====== Helpers ======
function ensureMetric(breakdown, key) {
if (!breakdown[key]) breakdown[key] = { base: 0, traits: 0, skills: 0, derived: 0, total: 0 };
}
function getSkillMod(groupKey) {
var tier = state.skills[groupKey];
return tier ? Math.floor(SKILL_TIERS[tier].start / 2) : 0;
}
function calcMaxHP(fit) {
if (fit >= 10) return 6;
if (fit >= 8) return 5;
if (fit >= 6) return 4;
if (fit >= 4) return 3;
return 2;
}
function sumEffects() {
var breakdown = {};
var i;
for (i = 0; i < CHECKS.length; i++) {
breakdown[CHECKS[i]] = { base: 0, traits: 0, skills: 0, derived: 0, total: 0 };
}
var bg = BACKGROUNDS[state.background];
breakdown.Strength.base = bg.base.STR;
breakdown.Fitness.base = bg.base.FIT;
breakdown.Perception.base = 2;
breakdown.Resolve.base = 3;
// Positive traits
for (i = 0; i < POSITIVE_TRAITS.length; i++) {
var pt = POSITIVE_TRAITS[i];
if (state.pos.has(pt.name)) {
for (var k1 in pt.effects) {
if (pt.effects.hasOwnProperty(k1)) {
ensureMetric(breakdown, k1);
breakdown[k1].traits += pt.effects[k1];
}
}
}
}
// Negative traits
for (i = 0; i < NEGATIVE_TRAITS.length; i++) {
var nt = NEGATIVE_TRAITS[i];
if (state.neg.has(nt.name)) {
for (var k2 in nt.effects) {
if (nt.effects.hasOwnProperty(k2)) {
ensureMetric(breakdown, k2);
breakdown[k2].traits += nt.effects[k2];
}
}
}
}
// Background freebies
for (i = 0; i < bg.freeTraits.length; i++) {
var ft = bg.freeTraits[i];
for (var k3 in ft.effects) {
if (ft.effects.hasOwnProperty(k3)) {
ensureMetric(breakdown, k3);
breakdown[k3].traits += ft.effects[k3];
}
}
}
// Skills
for (i = 0; i < SKILL_GROUPS.length; i++) {
var group = SKILL_GROUPS[i];
var mod = getSkillMod(group.key);
var j;
for (j = 0; j < group.list.length; j++) {
var skill = group.list[j];
var check = SKILL_TO_CHECK[skill] || skill;
if (group.key === 'Nimble') {
breakdown.Initiative.skills += mod;
breakdown.Nimble.skills += mod;
} else if (group.key === 'Sneaking') {
breakdown.Hiding.skills += mod;
} else if (typeof breakdown[check] !== 'undefined') {
breakdown[check].skills += mod;
}
}
}
// Derived from Strength
breakdown.Strength.total = breakdown.Strength.base + breakdown.Strength.traits;
var str = breakdown.Strength.total;
if (str <= 4) { breakdown['Melee Attack'].derived += -1; breakdown['Melee Defence'].derived += -1; }
else if (str <= 6) { /* no mod */ }
else if (str <= 8) { breakdown['Melee Attack'].derived += 1; breakdown['Melee Defence'].derived += 1; }
else if (str >= 10) { breakdown['Melee Attack'].derived += 2; breakdown['Melee Defence'].derived += 2; }
// Robustness from Fitness
breakdown.Fitness.total = breakdown.Fitness.base + breakdown.Fitness.traits;
var fitMod = Math.max(Math.floor((breakdown.Fitness.total - 6) / 2), 0);
breakdown.Robustness.derived += fitMod;
// Totals
for (i = 0; i < CHECKS.length; i++) {
var key = CHECKS[i];
var m = breakdown[key];
m.total = (m.base || 0) + (m.traits || 0) + (m.skills || 0) + (m.derived || 0);
}
breakdown.MaxHP = { value: calcMaxHP(breakdown.Fitness.total) };
return breakdown;
}
function spend() {
var pts = BACKGROUNDS[state.background].traitPoints;
var i;
for (i = 0; i < POSITIVE_TRAITS.length; i++) {
if (state.pos.has(POSITIVE_TRAITS[i].name)) pts -= POSITIVE_TRAITS[i].cost;
}
for (i = 0; i < NEGATIVE_TRAITS.length; i++) {
if (state.neg.has(NEGATIVE_TRAITS[i].name)) pts += NEGATIVE_TRAITS[i].cost;
}
for (var key in state.skills) {
if (state.skills.hasOwnProperty(key)) {
var tier = state.skills[key];
pts -= SKILL_TIERS[tier].cost;
}
}
return pts;
}
function skillTierCounts() {
var exp = 0, ex = 0;
for (var key in state.skills) {
if (state.skills.hasOwnProperty(key)) {
var tier = state.skills[key];
if (tier === 'Experienced') exp++;
if (tier === 'Expert') ex++;
}
}
return { exp: exp, ex: ex };
}
// ====== Renderers ======
function renderBackground() {
var bg = BACKGROUNDS[state.background];
var opts = '';
var keys = Object.keys(BACKGROUNDS);
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
opts += '<option value="' + k + '"' + (state.background === k ? ' selected' : '') + '>' + k + '</option>';
}
var freebies = '';
for (i = 0; i < bg.freeTraits.length; i++) {
freebies += '<span class="pill">' + bg.freeTraits[i].name + '</span>';
}
var html =
'<label>Background</label>' +
'<select id="backgroundSel">' + opts + '</select>' +
'<div class="totals section">' +
'<span class="pill">Trait Points: <b>' + bg.traitPoints + '</b></span>' +
'<span class="pill">Base STR: <b>' + bg.base.STR + '</b></span>' +
'<span class="pill">Base FIT: <b>' + bg.base.FIT + '</b></span>' +
freebies +
'</div>' +
'<div class="muted section">Background grants trait points, base STR/FIT, and unique thematic traits (applied automatically where relevant).</div>' +
'<div class="totals section">' +
'<span class="pill">Trait Points Used: <b>' + (bg.traitPoints - spend()) + '</b></span>' +
'<span class="pill ' + (spend() < 0 ? 'warning' : 'success') + '">Remaining: <b>' + spend() + '</b></span>' +
'</div>';
$('#bg-area').html(html);
$('#backgroundSel').on('change', function (e) {
state.background = e.target.value;
renderAll();
});
}
function renderTraitsAndSkills() {
var counts = skillTierCounts();
var html =
'<details open>' +
'<summary><strong>Positive Traits</strong> <span class="small">(costs subtract from your points)</span></summary>' +
'<div class="list" id="positives"></div>' +
'</details>' +
'<details open style="margin-top:12px">' +
'<summary><strong>Negative Traits</strong> <span class="small">(refund points)</span></summary>' +
'<div class="list" id="negatives"></div>' +
'</details>' +
'<div class="section" style="margin-top:12px">' +
'<strong>Skill Traits</strong>' +
'<div class="small">Amateur (1 pt, L6 cap), Experienced (2 pts, L8 cap, max 2), Expert (3 pts, L10 cap, max 1). Skill modifier = ⌊level ÷ 2⌋.</div>' +
'<div id="skills"></div>' +
'<div class="totals section">' +
'<span class="pill">Experienced: <b>' + counts.exp + '/2</b></span>' +
'<span class="pill">Expert: <b>' + counts.ex + '/1</b></span>' +
'</div>' +
'</div>';
$('#traits-area').html(html);
// Positives
var posHtml = '';
for (var i = 0; i < POSITIVE_TRAITS.length; i++) {
var t = POSITIVE_TRAITS[i];
var id = 'pos_' + t.name.replace(/\s+/g, '_');
var checked = state.pos.has(t.name) ? 'checked' : '';
var mods = '';
for (var k in t.effects) {
if (t.effects.hasOwnProperty(k)) {
var v = t.effects[k];
mods += '<span class="tag">' + k + ': ' + (v > 0 ? '+' : '') + v + '</span>';
}
}
posHtml += '<label class="checkbox"><input type="checkbox" id="' + id + '" ' + checked + '/> ' +
'<div><b>' + t.name + '</b> <span class="muted">(cost ' + t.cost + ')</span><div class="flex">' + mods + '</div></div></label>';
}
$('#positives').html(posHtml);
for (i = 0; i < POSITIVE_TRAITS.length; i++) {
(function (t) {
var id = '#pos_' + t.name.replace(/\s+/g, '_');
$(id).on('change', function (e) {
if (e.target.checked) state.pos.add(t.name); else state.pos.delete(t.name);
renderAll();
});
})(POSITIVE_TRAITS[i]);
}
// Negatives
var negHtml = '';
for (i = 0; i < NEGATIVE_TRAITS.length; i++) {
var nt = NEGATIVE_TRAITS[i];
var nid = 'neg_' + nt.name.replace(/\s+/g, '_');
var nchecked = state.neg.has(nt.name) ? 'checked' : '';
var nmods = '';
for (var nk in nt.effects) {
if (nt.effects.hasOwnProperty(nk)) {
var nv = nt.effects[nk];
nmods += '<span class="tag">' + nk + ': ' + (nv > 0 ? '+' : '') + nv + '</span>';
}
}
negHtml += '<label class="checkbox"><input type="checkbox" id="' + nid + '" ' + nchecked + '/> ' +
'<div><b>' + nt.name + '</b> <span class="muted">(refund ' + nt.cost + ')</span><div class="flex">' + nmods + '</div></div></label>';
}
$('#negatives').html(negHtml);
for (i = 0; i < NEGATIVE_TRAITS.length; i++) {
(function (t) {
var id2 = '#neg_' + t.name.replace(/\s+/g, '_');
$(id2).on('change', function (e) {
if (e.target.checked) state.neg.add(t.name); else state.neg.delete(t.name);
renderAll();
});
})(NEGATIVE_TRAITS[i]);
}
// Skills
var skillsHtml = '';
for (i = 0; i < SKILL_GROUPS.length; i++) {
var s = SKILL_GROUPS[i];
var val = state.skills[s.key] || '';
var scounts = skillTierCounts();
var opts = '';
var tiers = ['', 'Amateur', 'Experienced', 'Expert'];
for (var ti = 0; ti < tiers.length; ti++) {
var tier = tiers[ti];
if (tier === '') {
opts += '<option value=""' + (val === '' ? ' selected' : '') + '>None</option>';
} else {
if (tier === 'Experienced' && scounts.exp >= 2 && val !== tier) {
opts += '<option disabled>Experienced (max 2)</option>';
} else if (tier === 'Expert' && scounts.ex >= 1 && val !== tier) {
opts += '<option disabled>Expert (max 1)</option>';
} else {
var tdata = SKILL_TIERS[tier];
var lab = tier + ' — cost ' + tdata.cost + ', start L' + tdata.start + ', cap ' + tdata.cap;
opts += '<option ' + (val === tier ? 'selected' : '') + ' value="' + tier + '">' + lab + '</option>';
}
}
}
skillsHtml += '<div class="row" style="align-items:center;margin:6px 0">' +
'<div><b>' + s.label + '</b></div>' +
'<div><select id="skill_' + s.key + '">' + opts + '</select></div>' +
'<div class="small" style="grid-column:1 / -1; color:var(--muted)">' +
'Level: <b>' + (val ? SKILL_TIERS[val].start : 0) + '</b> • Modifier: <b>' + (val ? Math.floor(SKILL_TIERS[val].start / 2) : 0) + '</b>' +
'</div>' +
'</div>';
}
$('#skills').html(skillsHtml);
for (i = 0; i < SKILL_GROUPS.length; i++) {
(function (s) {
$('#skill_' + s.key).on('change', function (e) {
var v = e.target.value || null;
if (v) { state.skills[s.key] = v; } else { delete state.skills[s.key]; }
renderAll();
});
})(SKILL_GROUPS[i]);
}
}
function renderSummary() {
var b = sumEffects();
var html = '' +
'<div class="note">Breakdown: Base + Traits + Skills + Derived (if any) = Total. ' +
'Skill modifiers are computed as <b>(Skill Level ÷ 2)</b>. <br>' +
'<b>Fitness</b> increases Max HP. <b>Strength</b> modifies Melee Attack & Defence per Dice Guide.<br>' +
'Background freebies and vanilla traits also add flat bonuses as listed.</div>' +
'<table class="stat-table breakdown-table"><thead>' +
'<tr>' +
'<th>Stat/Check</th><th>Base</th><th>Traits</th><th>Skills</th><th>Derived</th><th>Total</th>' +
'</tr>' +
'</thead><tbody>';
html += '<tr><td>Strength</td><td>' + (b.Strength.base || 0) + '</td><td>' + (b.Strength.traits || 0) + '</td><td>' + (b.Strength.skills || 0) + '</td><td>' + (b.Strength.derived || 0) + '</td><td>' + (b.Strength.total || 0) + '</td></tr>';
html += '<tr><td>Fitness</td><td>' + (b.Fitness.base || 0) + '</td><td>' + (b.Fitness.traits || 0) + '</td><td>' + (b.Fitness.skills || 0) + '</td><td>' + (b.Fitness.derived || 0) + '</td><td>' + (b.Fitness.total || 0) + ' (Max HP: ' + b.MaxHP.value + ')</td></tr>';
html += '<tr><td>Melee Attack</td><td>' + (b['Melee Attack'].base || 0) + '</td><td>' + (b['Melee Attack'].traits || 0) + '</td><td>' + (b['Melee Attack'].skills || 0) + '</td><td>' + (b['Melee Attack'].derived || 0) + '</td><td>' + (b['Melee Attack'].total || 0) + '</td></tr>';
html += '<tr><td>Melee Defence</td><td>' + (b['Melee Defence'].base || 0) + '</td><td>' + (b['Melee Defence'].traits || 0) + '</td><td>' + (b['Melee Defence'].skills || 0) + '</td><td>' + (b['Melee Defence'].derived || 0) + '</td><td>' + (b['Melee Defence'].total || 0) + '</td></tr>';
html += '<tr><td>Perception</td><td>' + (b.Perception.base || 0) + '</td><td>' + (b.Perception.traits || 0) + '</td><td>' + (b.Perception.skills || 0) + '</td><td>' + (b.Perception.derived || 0) + '</td><td>' + (b.Perception.total || 0) + '</td></tr>';
html += '<tr><td>Resolve</td><td>' + (b.Resolve.base || 0) + '</td><td>' + (b.Resolve.traits || 0) + '</td><td>' + (b.Resolve.skills || 0) + '</td><td>' + (b.Resolve.derived || 0) + '</td><td>' + (b.Resolve.total || 0) + '</td></tr>';
var rest = ['Ranged Attack','Reloading','Initiative','Robustness','First Aid','Nimble','Hiding'];
for (var i = 0; i < rest.length; i++) {
var ck = rest[i];
html += '<tr><td>' + ck + '</td><td>' + (b[ck].base || 0) + '</td><td>' + (b[ck].traits || 0) + '</td><td>' + (b[ck].skills || 0) + '</td><td>' + (b[ck].derived || 0) + '</td><td>' + (b[ck].total || 0) + '</td></tr>';
}
html += '</tbody></table>';
$('#summary-area').html(html);
}
function renderRoller() {
var options = '';
var rollList = ['Melee Attack','Melee Defence','Ranged Attack','Reloading','Perception','Resolve','Initiative','Robustness','First Aid','Nimble','Hiding'];
for (var i = 0; i < rollList.length; i++) options += '<option>' + rollList[i] + '</option>';
var html =
'<label>Roll Test Type</label>' +
'<select id="rollType">' + options + '</select>' +
'<div class="rollbox">' +
'<button id="rollBtn">Roll 2d6</button>' +
'<div class="dice"><div class="die" id="d1">–</div><div class="die" id="d2">–</div></div>' +
'<div>' +
'<div class="total" id="total">Total —</div>' +
'<div class="small" id="breakdown">Modifier breakdown will appear here.</div>' +
'</div>' +
'</div>';
$('#roller-area').html(html);
$('#rollBtn').on('click', function () {
var name = $('#rollType').val();
var a = 1 + Math.floor(Math.random() * 6);
var b = 1 + Math.floor(Math.random() * 6);
var bd = sumEffects();
var mod = (bd[name] && typeof bd[name].total !== 'undefined') ? bd[name].total : 0;
$('#d1').text(a); $('#d2').text(b);
$('#total').text('Total ' + (a + b + mod) + ' (2d6=' + (a + b) + ' ' + (mod >= 0 ? '+' : '') + mod + ')');
$('#breakdown').html('Base: ' + (bd[name].base || 0) + ' + Traits: ' + (bd[name].traits || 0) + ' + Skills: ' + (bd[name].skills || 0) + ' + Derived: ' + (bd[name].derived || 0) + ' = <b>' + mod + '</b>');
});
}
function renderAll() {
renderBackground();
renderTraitsAndSkills();
renderSummary();
renderRoller();
}
renderAll();
}
$(mount);
mw.hook('wikipage.content').add(function () { mount(); });
})(mediaWiki, jQuery);
