MediaWiki:Gadget-NWOBuilder.js: Difference between revisions
From No Way Out Wiki
No edit summary |
No edit summary |
||
| (9 intermediate revisions by the same user not shown) | |||
| Line 2: | Line 2: | ||
(function (mw, $) { | (function (mw, $) { | ||
'use strict'; | 'use strict'; | ||
function shouldRun($root) { | function shouldRun($root) { | ||
| Line 29: | Line 28: | ||
'</div>' + | '</div>' + | ||
'<p class="kudos">Rules referenced from the NWO <em>Dice Guide</em>. ' + | '<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> | '<a href="/wiki/Dice_Guide" style="color:var(--accent)">See the Dice Guide</a>.</p>' + | ||
'</div>' | '</div>' | ||
); | ); | ||
/ | /* ========================= | ||
DATA (from your spec) | |||
========================= */ | |||
var BACKGROUNDS = { | var BACKGROUNDS = { | ||
Scavenger: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [ | Scavenger: { | ||
traitPoints: 8, base: { STR: 8, FIT: 8 }, | |||
freeTraits: [ | |||
Thinker: { traitPoints: 10, base: { STR: 8, FIT: 8 }, freeTraits: [ | { name: 'Outdoorsman', effects: {} }, | ||
{ name: 'Keen Hearing', effects: { Perception: 2 } } | |||
] | |||
Stalwart: { traitPoints: 8, base: { STR: 10, FIT: 10 }, freeTraits: [ | }, | ||
Thinker: { | |||
traitPoints: 10, base: { STR: 8, FIT: 8 }, | |||
Labourer: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [ | 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 = [ | var POSITIVE_TRAITS = [ | ||
{ name: 'Speed Demon', cost: 1, effects: {} }, | { name: 'Speed Demon', cost: 1, effects: {} }, | ||
{ name: 'Low Thirst', cost: 1, effects: {} }, | { name: 'Low Thirst', cost: 1, effects: {} }, | ||
{ name: 'Herbalist', cost: 1, effects: {} }, | { name: 'Herbalist', cost: 1, effects: {} }, | ||
{ name: 'Iron Gut', cost: 1, effects: { Robustness: 1 } }, | { name: 'Iron Gut', cost: 1, effects: { Robustness: 1 } }, | ||
{ name: 'Cats Eyes', cost: 2, effects: { Perception: 2 } }, | { name: 'Cats Eyes', cost: 2, effects: { Perception: 2 } }, | ||
{ name: 'Adrenaline Junkie', cost: 2, effects: { Initiative: 2 } }, | { name: 'Adrenaline Junkie', cost: 2, effects: { Initiative: 2 } }, | ||
{ name: 'Graceful', cost: 2, effects: { Hiding: 2 } }, | { name: 'Graceful', cost: 2, effects: { Hiding: 2 } }, | ||
{ name: 'Brave', cost: 2, effects: { Resolve: 2 } }, | { name: 'Brave', cost: 2, effects: { Resolve: 2 } }, | ||
{ name: 'Inconspicuous', cost: 2, effects: { Hiding: 2 } }, | { name: 'Inconspicuous', cost: 2, effects: { Hiding: 2 } }, | ||
{ name: 'Fast Healer', cost: 2, effects: { Robustness: 1 } }, | { name: 'Fast Healer', cost: 2, effects: { Robustness: 1 } }, | ||
{ name: ' | { name: 'Resilient', cost: 2, effects: {} }, | ||
{ name: ' | { name: 'Fit', cost: 3, effects: {} }, | ||
{ name: ' | { name: 'Strong', cost: 3, effects: {} }, | ||
{ name: ' | { name: 'Eagle Eyed', cost: 3, effects: { 'Ranged Attack': 1, Perception: 2 } }, | ||
{ name: ' | { name: 'Desensitized', cost: 4, effects: { Resolve: 4 } } | ||
]; | ]; | ||
var NEGATIVE_TRAITS = [ | var NEGATIVE_TRAITS = [ | ||
{ name: 'Prone to Illness', cost: 1, effects: { Robustness: -1 } }, | { name: 'Prone to Illness', cost: 1, effects: { Robustness: -1 } }, | ||
{ name: 'Smoker', cost: 1, effects: { Robustness: -1 } }, | { name: 'Smoker', cost: 1, effects: { Robustness: -1 } }, | ||
{ name: 'Slow Driver', cost: 1, effects: {} }, | { name: 'Slow Driver', cost: 1, effects: {} }, | ||
{ name: 'Slow Learner', cost: 1, effects: {} }, | { name: 'Slow Learner', cost: 1, effects: {} }, | ||
{ name: 'Weak Stomach', cost: 1, effects: { Robustness: -1 } }, | { name: 'Weak Stomach', cost: 1, effects: { Robustness: -1 } }, | ||
{ name: 'Fear of Blood', cost: 2, effects: { Resolve: -2 } }, | { name: 'Fear of Blood', cost: 2, effects: { Resolve: -2 } }, | ||
{ name: 'Agoraphobic', cost: 2, effects: { Resolve: -2 } }, | { name: 'Agoraphobic', cost: 2, effects: { Resolve: -2 } }, | ||
{ name: 'Claustrophobic', cost: 2, effects: { Resolve: -2 } }, | { name: 'All Thumbs', cost: 2, effects: {} }, | ||
{ name: 'Cowardly', cost: 2, effects: { Resolve: -2 } }, | { name: 'Claustrophobic', cost: 2, effects: { Resolve: -2 } }, | ||
{ name: 'Pacifist', cost: 2, effects: { Initiative: -2 } }, | { name: 'Cowardly', cost: 2, effects: { Resolve: -2 } }, | ||
{ name: 'Short Sighted', cost: 2, effects: { 'Ranged Attack': -1, Perception: -1 } }, | { name: 'Pacifist', cost: 2, effects: { Initiative: -2 } }, | ||
{ name: 'Thin Skinned', cost: 2, effects: { Robustness: -2 } }, | { name: 'Short Sighted', cost: 2, effects: { 'Ranged Attack': -1, Perception: -1 } }, | ||
{ name: 'Conspicuous', cost: 2, effects: { Hiding: -2 } }, | { name: 'Thin Skinned', cost: 2, effects: { Robustness: -2 } }, | ||
{ name: 'Clumsy', cost: 2, effects: { Hiding: -2 } }, | { name: 'Conspicuous', cost: 2, effects: { Hiding: -2 } }, | ||
{ name: 'Hard of Hearing', cost: 2, effects: { Perception: -2 } }, | { name: 'Clumsy', cost: 2, effects: { Hiding: -2 } }, | ||
{ name: ' | { name: 'Hard of Hearing', cost: 2, effects: { Perception: -2 } }, | ||
{ name: ' | { name: 'Disorganized', cost: 3, effects: {} }, | ||
{ name: ' | { name: 'Weak', cost: 3, effects: {} }, | ||
{ name: ' | { name: 'Unfit', cost: 3, effects: {} }, | ||
{ name: ' | { name: 'Illiterate', cost: 4, effects: {} }, | ||
{ name: ' | { name: 'Deaf', cost: 4, effects: { Perception: -4 } }, | ||
{ name: ' | { name: 'Very Weak', cost: 5, effects: {} }, | ||
{ name: 'Very Unfit', cost: 5, effects: {} } | |||
]; | ]; | ||
var SKILL_TIERS = { | var SKILL_TIERS = { | ||
Amateur: { cost: 1, start: 6, cap: 6, limitPerChar: Infinity }, | Amateur: { cost: 1, start: 6, cap: 6, limitPerChar: Infinity }, | ||
Experienced: { cost: 2, start: 8, cap: 8, limitPerChar: 2 }, | Experienced: { cost: 2, start: 8, cap: 8, limitPerChar: 2 }, | ||
Expert: { cost: 3, start: 10, cap: 10, limitPerChar: 1 } | Expert: { cost: 3, start: 10, cap: 10, limitPerChar: 1 } | ||
}; | }; | ||
var SKILL_GROUPS = [ | var SKILL_GROUPS = [ | ||
{ key: 'First Aid', label: ' | { key: 'Carpenter', label: 'Carpenter', list: ['Carpentry'] }, | ||
{ key: ' | { key: 'Farmer', label: 'Farmer', list: ['Farming'] }, | ||
{ key: ' | { key: 'Doctor', label: 'Doctor', list: ['First Aid'] }, | ||
{ key: ' | { key: 'Engineer', label: 'Engineer', list: ['Mechanics', 'Electronics'] }, | ||
{ key: 'Sneaking', label: ' | { key: 'Metalworker', label: 'Metalworker', list: ['Metalworking'] }, | ||
{ key: 'Housekeeper', label: 'Housekeeper', list: ['Cooking', 'Tailoring'] }, | |||
{ key: 'Shooter', label: 'Shooter', list: ['Aiming', 'Reloading'] }, | |||
{ key: 'OutdoorsmanS', label: 'Outdoorsman', list: ['Fishing', 'Foraging', 'Trapping'] }, | |||
{ key: 'Athlete', label: 'Athlete', list: ['Nimble', 'Sprinting'] }, | |||
{ key: 'Rogue', label: 'Rogue', list: ['Sneaking', 'Lightfooted'] }, | |||
{ key: 'FighterSurv', label: 'Fighter (Survival)', list: ['Axe', 'Spear', 'Maintenance'] }, | |||
{ key: 'FighterBlunt', label: 'Fighter (Blunt)', list: ['Long Blunt', 'Short Blunt', 'Maintenance'] }, | |||
{ key: 'FighterBlade', label: 'Fighter (Blade)', list: ['Long Blade', 'Short Blade', 'Maintenance'] } | |||
]; | ]; | ||
/* ========================= | |||
Engine / Calculations | |||
========================= */ | |||
var CHECKS = [ | var CHECKS = [ | ||
'Melee Attack','Melee Defence','Ranged Attack',' | 'Strength','Fitness','Perception','Resolve','Initiative','Robustness', | ||
' | 'Melee Attack','Melee Defence','Ranged Attack', | ||
'Nimble','Hiding',' | 'Carpentry','Farming','First Aid','Mechanics','Electronics','Metalworking', | ||
'Cooking','Tailoring','Aiming','Reloading','Fishing','Foraging','Trapping', | |||
'Nimble','Sprinting','Sneaking','Lightfooted','Hiding','Axe','Spear', | |||
'Maintenance','Long Blunt','Short Blunt','Long Blade','Short Blade' | |||
]; | ]; | ||
var state = { background: 'Scavenger', pos: new Set(), neg: new Set(), skills: {} }; | var state = { background: 'Scavenger', pos: new Set(), neg: new Set(), skills: {} }; | ||
function ensureMetric(breakdown, key) { | function ensureMetric(breakdown, key) { | ||
if (!breakdown[key]) breakdown[key] = { base: 0, traits: 0, skills: 0, derived: 0, total: 0 }; | if (!breakdown[key]) breakdown[key] = { base: 0, traits: 0, skills: 0, derived: 0, total: 0 }; | ||
} | } | ||
function | function getSkillModByLevel(level) { | ||
var | return Math.floor((level || 0) / 2); | ||
return | } | ||
function getTierStart(tierName) { | |||
var def = SKILL_TIERS[tierName]; | |||
return def ? (def.start || 0) : 0; | |||
} | } | ||
function calcMaxHP(fit) { | function calcMaxHP(fit) { | ||
if (fit >= 10) return 6; | if (fit >= 10) return 6; | ||
if (fit >= 8) return 5; | if (fit >= 8) return 5; | ||
if (fit >= 6) return 4; | if (fit >= 6) return 4; | ||
if (fit >= 4) return 3; | if (fit >= 4) return 3; | ||
return 2; | return 2; | ||
} | |||
function currentSkillLevels() { | |||
var out = Object.create(null); | |||
var i, group, tier, lv, s, name; | |||
for (i = 0; i < SKILL_GROUPS.length; i++) { | |||
group = SKILL_GROUPS[i]; | |||
tier = state.skills[group.key]; | |||
if (!tier) continue; | |||
lv = getTierStart(tier); | |||
for (s = 0; s < group.list.length; s++) { | |||
name = group.list[s]; | |||
if (!out[name]) out[name] = 0; | |||
out[name] += lv; // ADD levels if multiple groups touch same skill | |||
} | |||
} | |||
return out; | |||
} | } | ||
| Line 150: | Line 198: | ||
var bg = BACKGROUNDS[state.background]; | var bg = BACKGROUNDS[state.background]; | ||
breakdown.Strength.base = bg.base.STR; | breakdown.Strength.base = bg.base.STR; | ||
breakdown.Fitness.base = bg.base.FIT; | breakdown.Fitness.base = bg.base.FIT; | ||
breakdown.Perception.base = | breakdown.Perception.base = 0; | ||
breakdown.Resolve.base = | breakdown.Resolve.base = 0; | ||
// Positive traits | // Positive traits | ||
| Line 159: | Line 207: | ||
var pt = POSITIVE_TRAITS[i]; | var pt = POSITIVE_TRAITS[i]; | ||
if (state.pos.has(pt.name)) { | if (state.pos.has(pt.name)) { | ||
for (var k1 in pt.effects) | for (var k1 in pt.effects) if (pt.effects.hasOwnProperty(k1)) { | ||
ensureMetric(breakdown, k1); | |||
breakdown[k1].traits += pt.effects[k1]; | |||
} | } | ||
} | } | ||
| Line 171: | Line 217: | ||
var nt = NEGATIVE_TRAITS[i]; | var nt = NEGATIVE_TRAITS[i]; | ||
if (state.neg.has(nt.name)) { | if (state.neg.has(nt.name)) { | ||
for (var k2 in nt.effects) | for (var k2 in nt.effects) if (nt.effects.hasOwnProperty(k2)) { | ||
ensureMetric(breakdown, k2); | |||
breakdown[k2].traits += nt.effects[k2]; | |||
} | } | ||
} | } | ||
| Line 182: | Line 226: | ||
for (i = 0; i < bg.freeTraits.length; i++) { | for (i = 0; i < bg.freeTraits.length; i++) { | ||
var ft = bg.freeTraits[i]; | var ft = bg.freeTraits[i]; | ||
for (var k3 in ft.effects) | for (var k3 in ft.effects) if (ft.effects.hasOwnProperty(k3)) { | ||
ensureMetric(breakdown, k3); | |||
breakdown[k3].traits += ft.effects[k3]; | |||
} | } | ||
} | } | ||
// Skill levels → roll mods | |||
var lvMap = currentSkillLevels(); | |||
Object.keys(lvMap).forEach(function (skillName) { | |||
var level = lvMap[skillName]; | |||
var mod = getSkillModByLevel(level); | |||
// Aiming → Ranged Attack | |||
if (skillName === 'Aiming') { | |||
ensureMetric(breakdown, 'Ranged Attack'); breakdown['Ranged Attack'].skills += mod; | |||
} | |||
// Nimble → Initiative (and show Nimble too) | |||
else if (skillName === 'Nimble') { | |||
ensureMetric(breakdown, 'Initiative'); breakdown.Initiative.skills += mod; | |||
ensureMetric(breakdown, 'Nimble'); breakdown.Nimble.skills += mod; | |||
} | |||
// Sneaking → Hiding | |||
else if (skillName === 'Sneaking') { | |||
ensureMetric(breakdown, 'Hiding'); breakdown.Hiding.skills += mod; | |||
} | |||
// Reloading / First Aid map 1:1 | |||
else if (skillName === 'Reloading' || skillName === 'First Aid') { | |||
ensureMetric(breakdown, skillName); breakdown[skillName].skills += mod; | |||
} | |||
// Other skills (Carpentry, etc.) don’t affect a roll directly | |||
else { | |||
ensureMetric(breakdown, skillName); | |||
} | |||
}); | |||
breakdown.Strength.total = breakdown.Strength.base + breakdown.Strength.traits; | breakdown.Strength.total = breakdown.Strength.base + breakdown.Strength.traits; | ||
var str = breakdown.Strength.total; | var str = breakdown.Strength.total; | ||
if (str <= 4) { breakdown['Melee Attack'].derived += -1; breakdown['Melee Defence'].derived += -1; } | if (str <= 4) { breakdown['Melee Attack'].derived += -1; breakdown['Melee Defence'].derived += -1; } | ||
else if (str <= 6) { /* no mod */ } | else if (str <= 6) { /* no mod */ } | ||
else if (str <= 8) { breakdown['Melee Attack'].derived += 1; breakdown['Melee Defence'].derived += 1; } | 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; } | else if (str >= 10) { breakdown['Melee Attack'].derived += 2; breakdown['Melee Defence'].derived += 2; } | ||
breakdown.Fitness.total = breakdown.Fitness.base + breakdown.Fitness.traits; | breakdown.Fitness.total = breakdown.Fitness.base + breakdown.Fitness.traits; | ||
var fitMod = Math.max(Math.floor((breakdown.Fitness.total - 6) / 2), 0); | var fitMod = Math.max(Math.floor((breakdown.Fitness.total - 6) / 2), 0); | ||
breakdown.Robustness.derived += fitMod; | breakdown.Robustness.derived += fitMod; | ||
for (i = 0; i < CHECKS.length; i++) { | for (i = 0; i < CHECKS.length; i++) { | ||
var key = CHECKS[i]; | var key = CHECKS[i]; | ||
| Line 234: | Line 287: | ||
function spend() { | function spend() { | ||
var pts = BACKGROUNDS[state.background].traitPoints | var pts = BACKGROUNDS[state.background].traitPoints, i, tier; | ||
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 < POSITIVE_TRAITS.length; i++) | 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)) { | |||
tier = state.skills[key]; | |||
for (i = 0; i < NEGATIVE_TRAITS.length; i++) | pts -= SKILL_TIERS[tier].cost; | ||
for (var key in state.skills) | |||
} | } | ||
return pts; | return pts; | ||
| Line 252: | Line 298: | ||
function skillTierCounts() { | function skillTierCounts() { | ||
var exp = 0, ex = 0; | var exp = 0, ex = 0, tier, key; | ||
for ( | for (key in state.skills) if (state.skills.hasOwnProperty(key)) { | ||
tier = state.skills[key]; | |||
if (tier === 'Experienced') exp++; | |||
if (tier === 'Expert') ex++; | |||
} | } | ||
return { exp: exp, ex: ex }; | return { exp: exp, ex: ex }; | ||
} | } | ||
/ | /* ========================= | ||
Rendering | |||
========================= */ | |||
function renderBackground() { | function renderBackground() { | ||
var bg = BACKGROUNDS[state.background]; | var bg = BACKGROUNDS[state.background], i; | ||
var opts = '' | |||
var opts = '', keys = Object.keys(BACKGROUNDS); | |||
for ( | for (i = 0; i < keys.length; i++) { | ||
var k = keys[i]; | var k = keys[i]; | ||
opts += '<option value="' + k + '"' + (state.background === k ? ' selected' : '') + '>' + k + '</option>'; | opts += '<option value="' + k + '"' + (state.background === k ? ' selected' : '') + '>' + k + '</option>'; | ||
| Line 278: | Line 325: | ||
} | } | ||
var used = (bg.traitPoints - spend()); | |||
var html = | var html = | ||
'<label>Background</label>' + | '<label>Background</label>' + | ||
| Line 287: | Line 335: | ||
freebies + | freebies + | ||
'</div>' + | '</div>' + | ||
'<div class="muted section">Background | '<div class="muted section">Background gives Trait Points, base STR/FIT, and two thematic traits.</div>' + | ||
'<div class="totals section">' + | '<div class="totals section">' + | ||
'<span class="pill">Trait Points Used: <b>' + | '<span class="pill">Trait Points Used: <b>' + used + '</b></span>' + | ||
'<span class="pill ' + (spend() < 0 ? 'warning' : 'success') + '">Remaining: <b>' + spend() + '</b></span>' + | '<span class="pill ' + (spend() < 0 ? 'warning' : 'success') + '">Remaining: <b>' + spend() + '</b></span>' + | ||
'</div>'; | '</div>'; | ||
$('#bg-area').html(html); | $('#bg-area').html(html); | ||
$('#backgroundSel').on('change', function (e) { | $('#backgroundSel').off('change input').on('change input', function (e) { | ||
state.background = e.target.value; | state.background = e.target.value; | ||
renderAll(); | renderAll(); | ||
| Line 301: | Line 349: | ||
function renderTraitsAndSkills() { | function renderTraitsAndSkills() { | ||
var prevPosOpen = $('#posDetails').prop('open'); | |||
var prevNegOpen = $('#negDetails').prop('open'); | |||
if (typeof prevPosOpen === 'undefined') prevPosOpen = true; | |||
if (typeof prevNegOpen === 'undefined') prevNegOpen = true; | |||
var counts = skillTierCounts(); | var counts = skillTierCounts(); | ||
var html = | var html = | ||
'<details | '<details id="posDetails">' + | ||
'<summary><strong>Positive Traits</strong> <span class="small">(costs subtract from your points)</span></summary>' + | '<summary><strong>Positive Traits</strong> <span class="small">(costs subtract from your points)</span></summary>' + | ||
'<div class="list" id="positives"></div>' + | '<div class="list" id="positives"></div>' + | ||
'</details>' + | '</details>' + | ||
'<details | '<details id="negDetails" style="margin-top:12px">' + | ||
'<summary><strong>Negative Traits</strong> <span class="small">(refund points)</span></summary>' + | '<summary><strong>Negative Traits</strong> <span class="small">(refund points)</span></summary>' + | ||
'<div class="list" id="negatives"></div>' + | '<div class="list" id="negatives"></div>' + | ||
| Line 314: | Line 367: | ||
'<div class="section" style="margin-top:12px">' + | '<div class="section" style="margin-top:12px">' + | ||
'<strong>Skill Traits</strong>' + | '<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). | '<div class="small">Amateur (1 pt, L6 cap), Experienced (2 pts, L8 cap, <b>max 2</b>), Expert (3 pts, L10 cap, <b>max 1</b>). One tier per skill group.</div>' + | ||
'<div id="skills"></div>' + | '<div id="skills"></div>' + | ||
'<div class="totals section">' + | '<div class="totals section">' + | ||
| Line 323: | Line 376: | ||
$('#traits-area').html(html); | $('#traits-area').html(html); | ||
$('#posDetails').prop('open', !!prevPosOpen); | |||
$('#negDetails').prop('open', !!prevNegOpen); | |||
// Positives | // Positives | ||
var posHtml = ''; | var posHtml = '', i, k, v; | ||
for ( | for (i = 0; i < POSITIVE_TRAITS.length; i++) { | ||
var t = POSITIVE_TRAITS[i]; | var t = POSITIVE_TRAITS[i]; | ||
var id = 'pos_' + t.name.replace(/\s+/g, '_'); | var id = 'pos_' + t.name.replace(/\s+/g, '_'); | ||
var checked = state.pos.has(t.name) ? 'checked' : ''; | var checked = state.pos.has(t.name) ? 'checked' : ''; | ||
var mods = ''; | var mods = ''; | ||
for ( | for (k in t.effects) if (t.effects.hasOwnProperty(k)) { | ||
v = t.effects[k]; | |||
mods += '<span class="tag">' + k + ': ' + (v > 0 ? '+' : '') + v + '</span>'; | |||
} | } | ||
posHtml += '<label class="checkbox"><input type="checkbox" id="' + id + '" ' + checked + '/> ' + | 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>'; | '<div><b>' + t.name + '</b> <span class="muted">(cost ' + t.cost + ')</span>' + | ||
'<div class="flex">' + mods + '</div></div></label>'; | |||
} | } | ||
$('#positives').html(posHtml); | $('#positives').html(posHtml); | ||
for (i = 0; i < POSITIVE_TRAITS.length; i++) | for (i = 0; i < POSITIVE_TRAITS.length; i++) (function (t) { | ||
$('#pos_' + t.name.replace(/\s+/g, '_')).off('change').on('change', function (e) { | |||
if (e.target.checked) state.pos.add(t.name); else state.pos.delete(t.name); | |||
renderAll(); | |||
}); | |||
})(POSITIVE_TRAITS[i]); | |||
// Negatives | // Negatives | ||
| Line 358: | Line 409: | ||
var nchecked = state.neg.has(nt.name) ? 'checked' : ''; | var nchecked = state.neg.has(nt.name) ? 'checked' : ''; | ||
var nmods = ''; | var nmods = ''; | ||
for ( | for (k in nt.effects) if (nt.effects.hasOwnProperty(k)) { | ||
v = nt.effects[k]; | |||
nmods += '<span class="tag">' + k + ': ' + (v > 0 ? '+' : '') + v + '</span>'; | |||
} | } | ||
negHtml += '<label class="checkbox"><input type="checkbox" id="' + nid + '" ' + nchecked + '/> ' + | 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>'; | '<div><b>' + nt.name + '</b> <span class="muted">(refund ' + nt.cost + ')</span>' + | ||
'<div class="flex">' + nmods + '</div></div></label>'; | |||
} | } | ||
$('#negatives').html(negHtml); | $('#negatives').html(negHtml); | ||
for (i = 0; i < NEGATIVE_TRAITS.length; i++) | for (i = 0; i < NEGATIVE_TRAITS.length; i++) (function (t) { | ||
$('#neg_' + t.name.replace(/\s+/g, '_')).off('change').on('change', function (e) { | |||
if (e.target.checked) state.neg.add(t.name); else state.neg.delete(t.name); | |||
renderAll(); | |||
}); | |||
})(NEGATIVE_TRAITS[i]); | |||
// Skills | // Skills | ||
| Line 390: | Line 437: | ||
if (tier === '') { | if (tier === '') { | ||
opts += '<option value=""' + (val === '' ? ' selected' : '') + '>None</option>'; | 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 { | } 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">' + | var shownStart = val ? getTierStart(val) : 0; | ||
var shownMod = val ? getSkillModByLevel(shownStart) : 0; | |||
skillsHtml += | |||
'<div class="row" style="align-items:center;margin:6px 0">' + | |||
'<div><b>' + s.label + '</b> <span class="small muted">(' + s.list.join(', ') + ')</span></div>' + | |||
'<div><select id="skill_' + s.key + '">' + opts + '</select></div>' + | |||
'<div class="small" style="grid-column:1 / -1; color:var(--muted)">' + | |||
'Level: <b>' + shownStart + '</b> • Modifier: <b>' + shownMod + '</b>' + | |||
'</div>' + | |||
'</div>'; | |||
} | } | ||
$('#skills').html(skillsHtml); | $('#skills').html(skillsHtml); | ||
for (i = 0; i < SKILL_GROUPS.length; i++) | for (i = 0; i < SKILL_GROUPS.length; i++) (function (key) { | ||
$('#skill_' + key).off('change').on('change', function (e) { | |||
var v = e.target.value || null; | |||
if (v) state.skills[key] = v; else delete state.skills[key]; | |||
renderAll(); | |||
}); | |||
})(SKILL_GROUPS[i].key); | |||
} | } | ||
/* ---------- Summary & Roller ---------- */ | |||
var ROLL_CHECKS = [ | |||
'Melee Attack','Melee Defence','Ranged Attack','Reloading', | |||
'Perception','Resolve','Initiative','Robustness', | |||
'First Aid','Nimble','Hiding' | |||
]; | |||
function renderSummary() { | function renderSummary() { | ||
var b = sumEffects(); | var b = sumEffects(); | ||
var str = (b.Strength && b.Strength.total) || 0; | |||
var fit = (b.Fitness && b.Fitness.total) || 0; | |||
var maxHP = (b.MaxHP && b.MaxHP.value) || 0; | |||
var levels = currentSkillLevels(); | |||
var names = Object.keys(levels).sort(); | |||
var skillRows = ''; | |||
for (var i = 0; i < names.length; i++) { | |||
var nm = names[i], lv = levels[nm]; | |||
if (lv > 0) skillRows += '<tr><td>' + nm + '</td><td><b>' + lv + '</b></td></tr>'; | |||
} | |||
if (!skillRows) skillRows = '<tr><td colspan="2" class="muted">No skills selected yet.</td></tr>'; | |||
var | var rollRows = ''; | ||
for ( | for (i = 0; i < ROLL_CHECKS.length; i++) { | ||
var | var chk = ROLL_CHECKS[i]; | ||
var tot = (b[chk] && (b[chk].total || 0)) || 0; | |||
if (tot !== 0) rollRows += '<tr><td>' + chk + '</td><td><b>' + (tot > 0 ? '+' : '') + tot + '</b></td></tr>'; | |||
} | } | ||
if (!rollRows) rollRows = '<tr><td colspan="2" class="muted">No roll modifiers.</td></tr>'; | |||
var html = | |||
'<div class="totals" style="margin-bottom:8px">' + | |||
'<span class="pill">Strength: <b>' + str + '</b></span>' + | |||
'<span class="pill">Fitness: <b>' + fit + '</b></span>' + | |||
'<span class="pill">Max HP: <b>' + maxHP + '</b></span>' + | |||
'</div>' + | |||
'<table class="stat-table"><thead><tr><th>Skills with Levels</th><th>Level</th></tr></thead><tbody>' + | |||
skillRows + | |||
'</tbody></table>' + | |||
'<table class="stat-table" style="margin-top:12px"><thead><tr><th>Roll Modifiers</th><th>Mod</th></tr></thead><tbody>' + | |||
rollRows + | |||
'</tbody></table>'; | |||
$('#summary-area').html(html); | $('#summary-area').html(html); | ||
} | } | ||
function renderRoller() { | function renderRoller() { | ||
var | var preferred = ['Melee Attack','Melee Defence','Ranged Attack','Perception','Resolve','Initiative','Robustness']; | ||
var inPref = {}; | |||
for ( | var i, optHtml = ''; | ||
for (i = 0; i < preferred.length; i++) inPref[preferred[i]] = true; | |||
for (i = 0; i < preferred.length; i++) optHtml += '<option>' + preferred[i] + '</option>'; | |||
for (i = 0; i < CHECKS.length; i++) if (!inPref[CHECKS[i]]) optHtml += '<option>' + CHECKS[i] + '</option>'; | |||
var html = | var html = | ||
'<label>Roll Test Type</label>' + | '<label>Roll Test Type</label>' + | ||
'<select id="rollType">' + | '<select id="rollType">' + optHtml + '</select>' + | ||
'<div class="rollbox">' + | '<div class="rollbox">' + | ||
'<button id="rollBtn">Roll 2d6</button>' + | '<button id="rollBtn">Roll 2d6</button>' + | ||
| Line 471: | Line 541: | ||
$('#roller-area').html(html); | $('#roller-area').html(html); | ||
$('#rollBtn').on('click', function () { | $('#rollBtn').off('click').on('click', function () { | ||
var name = $('#rollType').val(); | var name = $('#rollType').val(); | ||
var a = 1 + Math.floor(Math.random() * 6); | var a = 1 + Math.floor(Math.random() * 6); | ||
var | var b2 = 1 + Math.floor(Math.random() * 6); | ||
var bd = sumEffects(); | var bd = sumEffects(); | ||
var | var row = bd[name] || { base:0, traits:0, skills:0, derived:0, total:0 }; | ||
$('#d1').text(a); $('#d2').text( | var mod = row.total || 0; | ||
$('#total').text('Total ' + (a + | $('#d1').text(a); $('#d2').text(b2); | ||
$('#breakdown').html('Base: ' + ( | $('#total').text('Total ' + (a + b2 + mod) + ' (2d6=' + (a + b2) + ' ' + (mod >= 0 ? '+' : '') + mod + ')'); | ||
$('#breakdown').html('Base: ' + (row.base||0) + ' + Traits: ' + (row.traits||0) + ' + Skills: ' + (row.skills||0) + ' + Derived: ' + (row.derived||0) + ' = <b>' + mod + '</b>'); | |||
}); | }); | ||
} | } | ||
| Line 492: | Line 563: | ||
renderAll(); | renderAll(); | ||
} | } | ||
$(mount); | $(mount); | ||
Latest revision as of 18:17, 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>.</p>' +
'</div>'
);
/* =========================
DATA (from your spec)
========================= */
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: 'Resilient', cost: 2, effects: {} },
{ name: 'Fit', cost: 3, effects: {} },
{ name: 'Strong', cost: 3, effects: {} },
{ name: 'Eagle Eyed', cost: 3, effects: { 'Ranged Attack': 1, Perception: 2 } },
{ name: 'Desensitized', cost: 4, effects: { Resolve: 4 } }
];
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: 'All Thumbs', cost: 2, effects: {} },
{ 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: 'Disorganized', cost: 3, effects: {} },
{ name: 'Weak', cost: 3, effects: {} },
{ name: 'Unfit', cost: 3, effects: {} },
{ name: 'Illiterate', cost: 4, effects: {} },
{ name: 'Deaf', cost: 4, effects: { Perception: -4 } },
{ name: 'Very Weak', cost: 5, effects: {} },
{ name: 'Very Unfit', cost: 5, effects: {} }
];
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: 'Carpenter', label: 'Carpenter', list: ['Carpentry'] },
{ key: 'Farmer', label: 'Farmer', list: ['Farming'] },
{ key: 'Doctor', label: 'Doctor', list: ['First Aid'] },
{ key: 'Engineer', label: 'Engineer', list: ['Mechanics', 'Electronics'] },
{ key: 'Metalworker', label: 'Metalworker', list: ['Metalworking'] },
{ key: 'Housekeeper', label: 'Housekeeper', list: ['Cooking', 'Tailoring'] },
{ key: 'Shooter', label: 'Shooter', list: ['Aiming', 'Reloading'] },
{ key: 'OutdoorsmanS', label: 'Outdoorsman', list: ['Fishing', 'Foraging', 'Trapping'] },
{ key: 'Athlete', label: 'Athlete', list: ['Nimble', 'Sprinting'] },
{ key: 'Rogue', label: 'Rogue', list: ['Sneaking', 'Lightfooted'] },
{ key: 'FighterSurv', label: 'Fighter (Survival)', list: ['Axe', 'Spear', 'Maintenance'] },
{ key: 'FighterBlunt', label: 'Fighter (Blunt)', list: ['Long Blunt', 'Short Blunt', 'Maintenance'] },
{ key: 'FighterBlade', label: 'Fighter (Blade)', list: ['Long Blade', 'Short Blade', 'Maintenance'] }
];
/* =========================
Engine / Calculations
========================= */
var CHECKS = [
'Strength','Fitness','Perception','Resolve','Initiative','Robustness',
'Melee Attack','Melee Defence','Ranged Attack',
'Carpentry','Farming','First Aid','Mechanics','Electronics','Metalworking',
'Cooking','Tailoring','Aiming','Reloading','Fishing','Foraging','Trapping',
'Nimble','Sprinting','Sneaking','Lightfooted','Hiding','Axe','Spear',
'Maintenance','Long Blunt','Short Blunt','Long Blade','Short Blade'
];
var state = { background: 'Scavenger', pos: new Set(), neg: new Set(), skills: {} };
function ensureMetric(breakdown, key) {
if (!breakdown[key]) breakdown[key] = { base: 0, traits: 0, skills: 0, derived: 0, total: 0 };
}
function getSkillModByLevel(level) {
return Math.floor((level || 0) / 2);
}
function getTierStart(tierName) {
var def = SKILL_TIERS[tierName];
return def ? (def.start || 0) : 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 currentSkillLevels() {
var out = Object.create(null);
var i, group, tier, lv, s, name;
for (i = 0; i < SKILL_GROUPS.length; i++) {
group = SKILL_GROUPS[i];
tier = state.skills[group.key];
if (!tier) continue;
lv = getTierStart(tier);
for (s = 0; s < group.list.length; s++) {
name = group.list[s];
if (!out[name]) out[name] = 0;
out[name] += lv; // ADD levels if multiple groups touch same skill
}
}
return out;
}
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 = 0;
breakdown.Resolve.base = 0;
// 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];
}
}
// Skill levels → roll mods
var lvMap = currentSkillLevels();
Object.keys(lvMap).forEach(function (skillName) {
var level = lvMap[skillName];
var mod = getSkillModByLevel(level);
// Aiming → Ranged Attack
if (skillName === 'Aiming') {
ensureMetric(breakdown, 'Ranged Attack'); breakdown['Ranged Attack'].skills += mod;
}
// Nimble → Initiative (and show Nimble too)
else if (skillName === 'Nimble') {
ensureMetric(breakdown, 'Initiative'); breakdown.Initiative.skills += mod;
ensureMetric(breakdown, 'Nimble'); breakdown.Nimble.skills += mod;
}
// Sneaking → Hiding
else if (skillName === 'Sneaking') {
ensureMetric(breakdown, 'Hiding'); breakdown.Hiding.skills += mod;
}
// Reloading / First Aid map 1:1
else if (skillName === 'Reloading' || skillName === 'First Aid') {
ensureMetric(breakdown, skillName); breakdown[skillName].skills += mod;
}
// Other skills (Carpentry, etc.) don’t affect a roll directly
else {
ensureMetric(breakdown, skillName);
}
});
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; }
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;
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, i, tier;
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)) {
tier = state.skills[key];
pts -= SKILL_TIERS[tier].cost;
}
return pts;
}
function skillTierCounts() {
var exp = 0, ex = 0, tier, key;
for (key in state.skills) if (state.skills.hasOwnProperty(key)) {
tier = state.skills[key];
if (tier === 'Experienced') exp++;
if (tier === 'Expert') ex++;
}
return { exp: exp, ex: ex };
}
/* =========================
Rendering
========================= */
function renderBackground() {
var bg = BACKGROUNDS[state.background], i;
var opts = '', keys = Object.keys(BACKGROUNDS);
for (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 used = (bg.traitPoints - spend());
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 gives Trait Points, base STR/FIT, and two thematic traits.</div>' +
'<div class="totals section">' +
'<span class="pill">Trait Points Used: <b>' + used + '</b></span>' +
'<span class="pill ' + (spend() < 0 ? 'warning' : 'success') + '">Remaining: <b>' + spend() + '</b></span>' +
'</div>';
$('#bg-area').html(html);
$('#backgroundSel').off('change input').on('change input', function (e) {
state.background = e.target.value;
renderAll();
});
}
function renderTraitsAndSkills() {
var prevPosOpen = $('#posDetails').prop('open');
var prevNegOpen = $('#negDetails').prop('open');
if (typeof prevPosOpen === 'undefined') prevPosOpen = true;
if (typeof prevNegOpen === 'undefined') prevNegOpen = true;
var counts = skillTierCounts();
var html =
'<details id="posDetails">' +
'<summary><strong>Positive Traits</strong> <span class="small">(costs subtract from your points)</span></summary>' +
'<div class="list" id="positives"></div>' +
'</details>' +
'<details id="negDetails" 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, <b>max 2</b>), Expert (3 pts, L10 cap, <b>max 1</b>). One tier per skill group.</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);
$('#posDetails').prop('open', !!prevPosOpen);
$('#negDetails').prop('open', !!prevNegOpen);
// Positives
var posHtml = '', i, k, v;
for (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 (k in t.effects) if (t.effects.hasOwnProperty(k)) {
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) {
$('#pos_' + t.name.replace(/\s+/g, '_')).off('change').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 (k in nt.effects) if (nt.effects.hasOwnProperty(k)) {
v = nt.effects[k];
nmods += '<span class="tag">' + k + ': ' + (v > 0 ? '+' : '') + v + '</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) {
$('#neg_' + t.name.replace(/\s+/g, '_')).off('change').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>';
}
}
var shownStart = val ? getTierStart(val) : 0;
var shownMod = val ? getSkillModByLevel(shownStart) : 0;
skillsHtml +=
'<div class="row" style="align-items:center;margin:6px 0">' +
'<div><b>' + s.label + '</b> <span class="small muted">(' + s.list.join(', ') + ')</span></div>' +
'<div><select id="skill_' + s.key + '">' + opts + '</select></div>' +
'<div class="small" style="grid-column:1 / -1; color:var(--muted)">' +
'Level: <b>' + shownStart + '</b> • Modifier: <b>' + shownMod + '</b>' +
'</div>' +
'</div>';
}
$('#skills').html(skillsHtml);
for (i = 0; i < SKILL_GROUPS.length; i++) (function (key) {
$('#skill_' + key).off('change').on('change', function (e) {
var v = e.target.value || null;
if (v) state.skills[key] = v; else delete state.skills[key];
renderAll();
});
})(SKILL_GROUPS[i].key);
}
/* ---------- Summary & Roller ---------- */
var ROLL_CHECKS = [
'Melee Attack','Melee Defence','Ranged Attack','Reloading',
'Perception','Resolve','Initiative','Robustness',
'First Aid','Nimble','Hiding'
];
function renderSummary() {
var b = sumEffects();
var str = (b.Strength && b.Strength.total) || 0;
var fit = (b.Fitness && b.Fitness.total) || 0;
var maxHP = (b.MaxHP && b.MaxHP.value) || 0;
var levels = currentSkillLevels();
var names = Object.keys(levels).sort();
var skillRows = '';
for (var i = 0; i < names.length; i++) {
var nm = names[i], lv = levels[nm];
if (lv > 0) skillRows += '<tr><td>' + nm + '</td><td><b>' + lv + '</b></td></tr>';
}
if (!skillRows) skillRows = '<tr><td colspan="2" class="muted">No skills selected yet.</td></tr>';
var rollRows = '';
for (i = 0; i < ROLL_CHECKS.length; i++) {
var chk = ROLL_CHECKS[i];
var tot = (b[chk] && (b[chk].total || 0)) || 0;
if (tot !== 0) rollRows += '<tr><td>' + chk + '</td><td><b>' + (tot > 0 ? '+' : '') + tot + '</b></td></tr>';
}
if (!rollRows) rollRows = '<tr><td colspan="2" class="muted">No roll modifiers.</td></tr>';
var html =
'<div class="totals" style="margin-bottom:8px">' +
'<span class="pill">Strength: <b>' + str + '</b></span>' +
'<span class="pill">Fitness: <b>' + fit + '</b></span>' +
'<span class="pill">Max HP: <b>' + maxHP + '</b></span>' +
'</div>' +
'<table class="stat-table"><thead><tr><th>Skills with Levels</th><th>Level</th></tr></thead><tbody>' +
skillRows +
'</tbody></table>' +
'<table class="stat-table" style="margin-top:12px"><thead><tr><th>Roll Modifiers</th><th>Mod</th></tr></thead><tbody>' +
rollRows +
'</tbody></table>';
$('#summary-area').html(html);
}
function renderRoller() {
var preferred = ['Melee Attack','Melee Defence','Ranged Attack','Perception','Resolve','Initiative','Robustness'];
var inPref = {};
var i, optHtml = '';
for (i = 0; i < preferred.length; i++) inPref[preferred[i]] = true;
for (i = 0; i < preferred.length; i++) optHtml += '<option>' + preferred[i] + '</option>';
for (i = 0; i < CHECKS.length; i++) if (!inPref[CHECKS[i]]) optHtml += '<option>' + CHECKS[i] + '</option>';
var html =
'<label>Roll Test Type</label>' +
'<select id="rollType">' + optHtml + '</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').off('click').on('click', function () {
var name = $('#rollType').val();
var a = 1 + Math.floor(Math.random() * 6);
var b2 = 1 + Math.floor(Math.random() * 6);
var bd = sumEffects();
var row = bd[name] || { base:0, traits:0, skills:0, derived:0, total:0 };
var mod = row.total || 0;
$('#d1').text(a); $('#d2').text(b2);
$('#total').text('Total ' + (a + b2 + mod) + ' (2d6=' + (a + b2) + ' ' + (mod >= 0 ? '+' : '') + mod + ')');
$('#breakdown').html('Base: ' + (row.base||0) + ' + Traits: ' + (row.traits||0) + ' + Skills: ' + (row.skills||0) + ' + Derived: ' + (row.derived||0) + ' = <b>' + mod + '</b>');
});
}
function renderAll() {
renderBackground();
renderTraitsAndSkills();
renderSummary();
renderRoller();
}
renderAll();
}
$(mount);
mw.hook('wikipage.content').add(function () { mount(); });
})(mediaWiki, jQuery);
