MediaWiki:Gadget-NWOBuilder.js: Difference between revisions

From No Way Out Wiki
Created page with "// NWO Character Builder gadget – mounts into <div id="nwo-builder"> (function (mw, $) { 'use strict'; // Only run on the page that contains the mount (fast bail on others) function shouldRun($root) { if ($root && $root.length) return true; // Optional page-name guard (rename to your page title with underscores): // return mw.config.get('wgPageName') === 'NWO_Character_Builder'; return false; } function mount() { var $root = $('#nwo-buil..."
 
No edit summary
Line 36: Line 36:
     );
     );


     // ====== Your app logic (unchanged, just without <script> tags) ======
     // ====== Data ======
     const BACKGROUNDS = {
     var BACKGROUNDS = {
       Scavenger: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [
       Scavenger: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [
         { name: "Outdoorsman", effects: {} }, { name: "Keen Hearing", effects: { Perception: +2 } }
         { name: 'Outdoorsman', effects: {} }, { name: 'Keen Hearing', effects: { Perception: 2 } }
       ]},
       ]},
       Thinker: { traitPoints: 10, base: { STR: 8, FIT: 8 }, freeTraits: [
       Thinker: { traitPoints: 10, base: { STR: 8, FIT: 8 }, freeTraits: [
         { name: "Dextrous", effects: {} }, { name: "Fast Learner", effects: {} }
         { name: 'Dextrous', effects: {} }, { name: 'Fast Learner', effects: {} }
       ]},
       ]},
       Stalwart: { traitPoints: 8, base: { STR: 10, FIT: 10 }, freeTraits: [
       Stalwart: { traitPoints: 8, base: { STR: 10, FIT: 10 }, freeTraits: [
         { name: "Fast Coagulation", effects: {} }, { name: "Thick Skinned", effects: { Robustness: +2 } }
         { name: 'Fast Coagulation', effects: {} }, { name: 'Thick Skinned', effects: { Robustness: 2 } }
       ]},
       ]},
       Labourer: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [
       Labourer: { traitPoints: 8, base: { STR: 8, FIT: 8 }, freeTraits: [
         { name: "Heavy Lifter", effects: { Robustness: +1 } }, { name: "Organized", effects: {} }
         { name: 'Heavy Lifter', effects: { Robustness: 1 } }, { name: 'Organized', effects: {} }
       ]}
       ]}
     };
     };


     const 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: "Eagle Eyed", cost: 3, effects: { "Ranged Attack": +1, Perception: +2 } },
       { name: 'Eagle Eyed', cost: 3, effects: { 'Ranged Attack': 1, Perception: 2 } },
       { name: "Desensitized", cost: 4, effects: { Resolve: +4 } },
       { name: 'Desensitized', cost: 4, effects: { Resolve: 4 } },
       { name: "Resilient", cost: 2, effects: { Robustness: +1 } },
       { name: 'Resilient', cost: 2, effects: { Robustness: 1 } },
       { name: "Fit", cost: 3, effects: { Fitness: +2 } },
       { name: 'Fit', cost: 3, effects: { Fitness: 2 } },
       { name: "Strong", cost: 3, effects: { Strength: +2 } }
       { name: 'Strong', cost: 3, effects: { Strength: 2 } }
     ];
     ];


     const 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: 'Claustrophobic', cost: 2, effects: { Resolve: -2 } },
       { name: "Cowardly", cost: 2, effects: { Resolve: -2 } },
       { name: 'Cowardly', cost: 2, effects: { Resolve: -2 } },
       { name: "Pacifist", cost: 2, effects: { Initiative: -2 } },
       { name: 'Pacifist', cost: 2, effects: { Initiative: -2 } },
       { name: "Short Sighted", cost: 2, effects: { "Ranged Attack": -1, Perception: -1 } },
       { name: 'Short Sighted', cost: 2, effects: { 'Ranged Attack': -1, Perception: -1 } },
       { name: "Thin Skinned", cost: 2, effects: { Robustness: -2 } },
       { name: 'Thin Skinned', cost: 2, effects: { Robustness: -2 } },
       { name: "Conspicuous", cost: 2, effects: { Hiding: -2 } },
       { name: 'Conspicuous', cost: 2, effects: { Hiding: -2 } },
       { name: "Clumsy", cost: 2, effects: { Hiding: -2 } },
       { name: 'Clumsy', cost: 2, effects: { Hiding: -2 } },
       { name: "Hard of Hearing", cost: 2, effects: { Perception: -2 } },
       { name: 'Hard of Hearing', cost: 2, effects: { Perception: -2 } },
       { name: "Deaf", cost: 4, effects: { Perception: -4 } },
       { name: 'Deaf', cost: 4, effects: { Perception: -4 } },
       { name: "All Thumbs", cost: 2, effects: {} },
       { name: 'All Thumbs', cost: 2, effects: {} },
       { name: "Disorganized", cost: 3, effects: {} },
       { name: 'Disorganized', cost: 3, effects: {} },
       { name: "Weak", cost: 3, effects: {} },
       { name: 'Weak', cost: 3, effects: {} },
       { name: "Unfit", cost: 3, effects: {} },
       { name: 'Unfit', cost: 3, effects: {} },
       { name: "Illiterate", cost: 4, effects: {} },
       { name: 'Illiterate', cost: 4, effects: {} },
       { name: "Slow Healer", cost: 1, effects: { Robustness: -1 } }
       { name: 'Slow Healer', cost: 1, effects: { Robustness: -1 } }
     ];
     ];


     const 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 },
Line 101: Line 101:
     };
     };


     const SKILL_GROUPS = [
     var SKILL_GROUPS = [
       { key: "First Aid", label: "First Aid", list: ["First Aid"] },
       { key: 'First Aid', label: 'First Aid', list: ['First Aid'] },
       { key: "Aiming", label: "Aiming", list: ["Aiming"] },
       { key: 'Aiming', label: 'Aiming', list: ['Aiming'] },
       { key: "Reloading", label: "Reloading", list: ["Reloading"] },
       { key: 'Reloading', label: 'Reloading', list: ['Reloading'] },
       { key: "Nimble", label: "Nimble", list: ["Nimble"] },
       { key: 'Nimble', label: 'Nimble', list: ['Nimble'] },
       { key: "Sneaking", label: "Sneaking", list: ["Sneaking"] }
       { key: 'Sneaking', label: 'Sneaking', list: ['Sneaking'] }
     ];
     ];


     const SKILL_TO_CHECK = {
     var SKILL_TO_CHECK = {
       Aiming: "Ranged Attack",
       Aiming: 'Ranged Attack',
       Reloading: "Reloading",
       Reloading: 'Reloading',
       Sneaking: "Hiding",
       Sneaking: 'Hiding',
       Nimble: "Initiative",
       Nimble: 'Initiative',
       FirstAid: "First Aid"
       FirstAid: 'First Aid'
     };
     };


     const CHECKS = [
     var CHECKS = [
       "Melee Attack","Melee Defence","Ranged Attack","Reloading",
       'Melee Attack','Melee Defence','Ranged Attack','Reloading',
       "Perception","Resolve","Initiative","Robustness","First Aid",
       'Perception','Resolve','Initiative','Robustness','First Aid',
       "Nimble","Hiding","Strength","Fitness"
       'Nimble','Hiding','Strength','Fitness'
     ];
     ];


     const state = { background: "Scavenger", pos: new Set(), neg: new Set(), skills: {} };
     // ====== State ======
    var state = { background: 'Scavenger', pos: new Set(), neg: new Set(), skills: {} };


     function getSkillMod(groupKey){ const tier = state.skills[groupKey]; return tier ? Math.floor(SKILL_TIERS[tier].start/2) : 0; }
    // ====== 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 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(){
     function sumEffects() {
       const breakdown = {}; CHECKS.forEach(c => breakdown[c] = { base:0, traits:0, skills:0, derived:0, total:0 });
       var breakdown = {};
      const bg = BACKGROUNDS[state.background];
       var i;
       breakdown.Strength.base = bg.base.STR; breakdown.Fitness.base = bg.base.FIT;
      breakdown.Perception.base = 2; breakdown.Resolve.base = 3;


       POSITIVE_TRAITS.forEach(t=>{ if(state.pos.has(t.name)){ for (const [k,v] of Object.entries(t.effects)) breakdown[k].traits += v; }});
       for (i = 0; i < CHECKS.length; i++) {
      NEGATIVE_TRAITS.forEach(t=>{ if(state.neg.has(t.name)){ for (const [k,v] of Object.entries(t.effects)) breakdown[k].traits += v; }});
        breakdown[CHECKS[i]] = { base: 0, traits: 0, skills: 0, derived: 0, total: 0 };
       bg.freeTraits.forEach(t=>{ for (const [k,v] of Object.entries(t.effects)) breakdown[k].traits += v; });
       }


       SKILL_GROUPS.forEach(group=>{
       var bg = BACKGROUNDS[state.background];
         const mod = getSkillMod(group.key);
      breakdown.Strength.base = bg.base.STR;
         group.list.forEach(skill=>{
      breakdown.Fitness.base = bg.base.FIT;
           const check = SKILL_TO_CHECK[skill] || skill;
      breakdown.Perception.base = 2;
           if (group.key==="Nimble"){ breakdown.Initiative.skills += mod; breakdown.Nimble.skills += mod; }
      breakdown.Resolve.base = 3;
           else if (group.key==="Sneaking"){ breakdown.Hiding.skills += mod; }
 
           else if (breakdown[check]!==undefined){ breakdown[check].skills += mod; }
      // 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;
       breakdown.Strength.total = breakdown.Strength.base + breakdown.Strength.traits;
       const 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; }


      // Robustness from Fitness
       breakdown.Fitness.total = breakdown.Fitness.base + breakdown.Fitness.traits;
       breakdown.Fitness.total = breakdown.Fitness.base + breakdown.Fitness.traits;
       const 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;


       CHECKS.forEach(check=>{
       // Totals
         breakdown[check].total = (breakdown[check].base||0) + (breakdown[check].traits||0) + (breakdown[check].skills||0) + (breakdown[check].derived||0);
      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) };
       breakdown.MaxHP = { value: calcMaxHP(breakdown.Fitness.total) };
       return breakdown;
       return breakdown;
     }
     }


     function spend(){
     function spend() {
       let pts = BACKGROUNDS[state.background].traitPoints;
       var pts = BACKGROUNDS[state.background].traitPoints;
       POSITIVE_TRAITS.forEach(t=>{ if(state.pos.has(t.name)) pts -= t.cost; });
       var i;
       NEGATIVE_TRAITS.forEach(t=>{ if(state.neg.has(t.name)) pts += t.cost; });
      for (i = 0; i < POSITIVE_TRAITS.length; i++) {
       for (const tier of Object.values(state.skills)) pts -= SKILL_TIERS[tier].cost;
        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;
       return pts;
     }
     }


     function skillTierCounts(){
     function skillTierCounts() {
       let exp=0, ex=0;
       var exp = 0, ex = 0;
       for (const tier of Object.values(state.skills)){ if(tier==="Experienced") exp++; if(tier==="Expert") ex++; }
       for (var key in state.skills) {
       return { exp, ex };
        if (state.skills.hasOwnProperty(key)) {
          var tier = state.skills[key];
          if (tier === 'Experienced') exp++;
          if (tier === 'Expert') ex++;
        }
      }
       return { exp: exp, ex: ex };
     }
     }


     function renderBackground(){
    // ====== Renderers ======
       const bg = BACKGROUNDS[state.background];
     function renderBackground() {
       const html =
       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>' +
         '<label>Background</label>' +
         '<select id="backgroundSel">' +
         '<select id="backgroundSel">' + opts + '</select>' +
          Object.keys(BACKGROUNDS).map(k=>`<option value="${k}"${state.background===k?' selected':''}>${k}</option>`).join('') +
         '<div class="totals section">' +
        '</select>' +
           '<span class="pill">Trait Points: <b>' + bg.traitPoints + '</b></span>' +
         `<div class="totals section">
           '<span class="pill">Base STR: <b>' + bg.base.STR + '</b></span>' +
           <span class="pill">Trait Points: <b>${bg.traitPoints}</b></span>
           '<span class="pill">Base FIT: <b>' + bg.base.FIT + '</b></span>' +
           <span class="pill">Base STR: <b>${bg.base.STR}</b></span>
           freebies +
           <span class="pill">Base FIT: <b>${bg.base.FIT}</b></span>
         '</div>' +
           ${bg.freeTraits.map(t=>`<span class="pill">${t.name}</span>`).join('')}
         '<div class="muted section">Background grants trait points, base STR/FIT, and unique thematic traits (applied automatically where relevant).</div>' +
         </div>
         '<div class="totals section">' +
         <div class="muted section">Background grants trait points, base STR/FIT, and unique thematic traits (applied automatically where relevant).</div>
           '<span class="pill">Trait Points Used: <b>' + (bg.traitPoints - spend()) + '</b></span>' +
         <div class="totals section">
           '<span class="pill ' + (spend() < 0 ? 'warning' : 'success') + '">Remaining: <b>' + spend() + '</b></span>' +
           <span class="pill">Trait Points Used: <b>${bg.traitPoints-spend()}</b></span>
         '</div>';
           <span class="pill ${spend()<0?'warning':'success'}">Remaining: <b>${spend()}</b></span>
 
         </div>`;
       $('#bg-area').html(html);
       $('#bg-area').html(html);
       $('#backgroundSel').on('change', e=>{ state.background = e.target.value; renderAll(); });
       $('#backgroundSel').on('change', function (e) {
        state.background = e.target.value;
        renderAll();
      });
     }
     }


     function renderTraitsAndSkills(){
     function renderTraitsAndSkills() {
       const html =
       var counts = skillTierCounts();
         `<details open>
 
           <summary><strong>Positive Traits</strong> <span class="small">(costs subtract from your points)</span></summary>
      var html =
           <div class="list" id="positives"></div>
         '<details open>' +
         </details>
           '<summary><strong>Positive Traits</strong> <span class="small">(costs subtract from your points)</span></summary>' +
         <details open style="margin-top:12px">
           '<div class="list" id="positives"></div>' +
           <summary><strong>Negative Traits</strong> <span class="small">(refund points)</span></summary>
         '</details>' +
           <div class="list" id="negatives"></div>
         '<details open style="margin-top:12px">' +
         </details>
           '<summary><strong>Negative Traits</strong> <span class="small">(refund points)</span></summary>' +
         <div class="section" style="margin-top:12px">
           '<div class="list" id="negatives"></div>' +
           <strong>Skill Traits</strong>
         '</details>' +
           <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 class="section" style="margin-top:12px">' +
           <div id="skills"></div>
           '<strong>Skill Traits</strong>' +
           <div class="totals section">
           '<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>' +
             <span class="pill">Experienced: <b>${skillTierCounts().exp}/2</b></span>
           '<div id="skills"></div>' +
             <span class="pill">Expert: <b>${skillTierCounts().ex}/1</b></span>
           '<div class="totals section">' +
           </div>
             '<span class="pill">Experienced: <b>' + counts.exp + '/2</b></span>' +
         </div>`;
             '<span class="pill">Expert: <b>' + counts.ex + '/1</b></span>' +
           '</div>' +
         '</div>';
 
       $('#traits-area').html(html);
       $('#traits-area').html(html);


       $('#positives').html(POSITIVE_TRAITS.map(t=>{
       // Positives
         const id = `pos_${t.name.replace(/\s+/g,'_')}`;
      var posHtml = '';
         const checked = state.pos.has(t.name) ? 'checked' : '';
      for (var i = 0; i < POSITIVE_TRAITS.length; i++) {
         const mods = Object.entries(t.effects).map(([k,v])=>`<span class="tag">${k}: ${v>0?'+':''}${v}</span>`).join('');
        var t = POSITIVE_TRAITS[i];
         return `<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>`;
         var id = 'pos_' + t.name.replace(/\s+/g, '_');
       }).join(''));
         var checked = state.pos.has(t.name) ? 'checked' : '';
       POSITIVE_TRAITS.forEach(t=>{
         var mods = '';
         $(`#pos_${t.name.replace(/\s+/g,'_')}`).on('change', e=>{
        for (var k in t.effects) {
          e.target.checked ? state.pos.add(t.name) : state.pos.delete(t.name);
          if (t.effects.hasOwnProperty(k)) {
          renderAll();
            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]);
       }


       $('#negatives').html(NEGATIVE_TRAITS.map(t=>{
       // Skills
         const id = `neg_${t.name.replace(/\s+/g,'_')}`;
      var skillsHtml = '';
         const checked = state.neg.has(t.name) ? 'checked' : '';
      for (i = 0; i < SKILL_GROUPS.length; i++) {
         const mods = Object.entries(t.effects).map(([k,v])=>`<span class="tag">${k}: ${v>0?'+':''}${v}</span>`).join('');
        var s = SKILL_GROUPS[i];
        return `<label class="checkbox"><input type="checkbox" id="${id}" ${checked}/> <div><b>${t.name}</b> <span class="muted">(refund ${t.cost})</span><div class="flex">${mods}</div></div></label>`;
         var val = state.skills[s.key] || '';
      }).join(''));
         var scounts = skillTierCounts();
      NEGATIVE_TRAITS.forEach(t=>{
        var opts = '';
        $(`#neg_${t.name.replace(/\s+/g,'_')}`).on('change', e=>{
        var tiers = ['', 'Amateur', 'Experienced', 'Expert'];
          e.target.checked ? state.neg.add(t.name) : state.neg.delete(t.name);
         for (var ti = 0; ti < tiers.length; ti++) {
           renderAll();
          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>';
            }
           }
         }


      $('#skills').html(SKILL_GROUPS.map(s=>{
         skillsHtml += '<div class="row" style="align-items:center;margin:6px 0">' +
         const val = state.skills[s.key] || '';
           '<div><b>' + s.label + '</b></div>' +
        const counts = skillTierCounts();
           '<div><select id="skill_' + s.key + '">' + opts + '</select></div>' +
        const opts = ['','Amateur','Experienced','Expert'].map(tier=>{
           '<div class="small" style="grid-column:1 / -1; color:var(--muted)">' +
          if(tier==='Experienced' && counts.exp>=2 && val!==tier) return `<option disabled>Experienced (max 2)</option>`;
          'Level: <b>' + (val ? SKILL_TIERS[val].start : 0) + '</b> • Modifier: <b>' + (val ? Math.floor(SKILL_TIERS[val].start / 2) : 0) + '</b>' +
          if(tier==='Expert' && counts.ex>=1 && val!==tier) return `<option disabled>Expert (max 1)</option>`;
          '</div>' +
          const lab = tier?`${tier} — cost ${SKILL_TIERS[tier]?.cost||''}, start L${SKILL_TIERS[tier]?.start||''}, cap ${SKILL_TIERS[tier]?.cap||''}`:'None';
         '</div>';
          return `<option ${val===tier?'selected':''} value="${tier}">${lab}</option>`;
       }
        }).join('');
      $('#skills').html(skillsHtml);
        return `<div class="row" style="align-items:center;margin:6px 0">
       for (i = 0; i < SKILL_GROUPS.length; i++) {
           <div><b>${s.label}</b></div>
        (function (s) {
           <div><select id="skill_${s.key}">${opts}</select></div>
          $('#skill_' + s.key).on('change', function (e) {
           <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>
            var v = e.target.value || null;
         </div>`;
            if (v) { state.skills[s.key] = v; } else { delete state.skills[s.key]; }
       }).join(''));
            renderAll();
       SKILL_GROUPS.forEach(s=>{
          });
        $(`#skill_${s.key}`).on('change', e=>{
         })(SKILL_GROUPS[i]);
          const v = e.target.value || null;
       }
          if(v) state.skills[s.key]=v; else delete state.skills[s.key];
          renderAll();
         });
       });
     }
     }


     function renderSummary(){
     function renderSummary() {
       const b = sumEffects();
       var b = sumEffects();
       let html = `<div class="note">Breakdown: Base + Traits + Skills + Derived (if any) = Total. Skill modifiers are computed as <b>(Skill Level ÷ 2)</b>. <br>
       var html = '' +
         <b>Fitness</b> increases Max HP. <b>Strength</b> modifies Melee Attack & Defence per Dice Guide.<br>
        '<div class="note">Breakdown: Base + Traits + Skills + Derived (if any) = Total. ' +
         Background freebies and vanilla traits also add flat bonuses as listed.</div>
        'Skill modifiers are computed as <b>(Skill Level ÷ 2)</b>. <br>' +
         <table class="stat-table breakdown-table"><thead>
         '<b>Fitness</b> increases Max HP. <b>Strength</b> modifies Melee Attack & Defence per Dice Guide.<br>' +
           <tr><th>Stat/Check</th><th>Base</th><th>Traits</</th><th>Skills</th><th>Derived</th><th>Total</th></tr>
         'Background freebies and vanilla traits also add flat bonuses as listed.</div>' +
         </thead><tbody>`;
         '<table class="stat-table breakdown-table"><thead>' +
       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>`;
           '<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>`;
            '<th>Stat/Check</th><th>Base</th><th>Traits</th><th>Skills</th><th>Derived</th><th>Total</th>' +
       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>`;
          '</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>`;
         '</thead><tbody>';
       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>`;
       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>';
       ["Ranged Attack","Reloading","Initiative","Robustness","First Aid","Nimble","Hiding"].forEach(check=>{
       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>${check}</td><td>${b[check].base||0}</td><td>${b[check].traits||0}</td><td>${b[check].skills||0}</td><td>${b[check].derived||0}</td><td>${b[check].total||0}</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 += `</tbody></table>`;
       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);
       $('#summary-area').html(html);
     }
     }


     function renderRoller(){
     function renderRoller() {
       const html =
       var options = '';
        `<label>Roll Test Type</label>
      var rollList = ['Melee Attack','Melee Defence','Ranged Attack','Reloading','Perception','Resolve','Initiative','Robustness','First Aid','Nimble','Hiding'];
        <select id="rollType">${["Melee Attack","Melee Defence","Ranged Attack","Reloading","Perception","Resolve","Initiative","Robustness","First Aid","Nimble","Hiding"].map(k=>`<option>${k}</option>`).join('')}</select>
      for (var i = 0; i < rollList.length; i++) options += '<option>' + rollList[i] + '</option>';
        <div class="rollbox">
 
          <button id="rollBtn">Roll 2d6</button>
      var html =
          <div class="dice"><div class="die" id="d1">–</div><div class="die" id="d2">–</div></div>
        '<label>Roll Test Type</label>' +
          <div>
        '<select id="rollType">' + options + '</select>' +
            <div class="total" id="total">Total —</div>
        '<div class="rollbox">' +
            <div class="small" id="breakdown">Modifier breakdown will appear here.</div>
          '<button id="rollBtn">Roll 2d6</button>' +
          </div>
          '<div class="dice"><div class="die" id="d1">–</div><div class="die" id="d2">–</div></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);
       $('#roller-area').html(html);
       $('#rollBtn').on('click', ()=>{
       $('#rollBtn').on('click', function () {
         const name = $('#rollType').val();
         var name = $('#rollType').val();
         const a = 1+Math.floor(Math.random()*6), b = 1+Math.floor(Math.random()*6);
         var a = 1 + Math.floor(Math.random() * 6);
         const bd = sumEffects(); const mod = bd[name]?.total || 0;
        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);
         $('#d1').text(a); $('#d2').text(b);
         $('#total').text(`Total ${a+b+mod} (2d6=${a+b} ${mod>=0?'+':''}${mod})`);
         $('#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>`);
         $('#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(); }
     function renderAll() {
      renderBackground();
      renderTraitsAndSkills();
      renderSummary();
      renderRoller();
    }
 
     renderAll();
     renderAll();
   }
   }
Line 324: Line 498:
   // Run on load and when content is re-rendered (VE/Ajax)
   // Run on load and when content is re-rendered (VE/Ajax)
   $(mount);
   $(mount);
   mw.hook('wikipage.content').add(function(){ mount(); });
   mw.hook('wikipage.content').add(function () { mount(); });


})(mediaWiki, jQuery);
})(mediaWiki, jQuery);

Revision as of 14:55, 26 September 2025

// NWO Character Builder gadget – mounts into <div id="nwo-builder">
(function (mw, $) {
  'use strict';

  // Only run on the page that contains the mount (fast bail on others)
  function shouldRun($root) {
    if ($root && $root.length) return true;
    // Optional page-name guard (rename to your page title with underscores):
    // return mw.config.get('wgPageName') === 'NWO_Character_Builder';
    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);

    // --- Minimal HTML skeleton (no <head>/<body> needed) ---
    $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();
  }

  // Run on load and when content is re-rendered (VE/Ajax)
  $(mount);
  mw.hook('wikipage.content').add(function () { mount(); });

})(mediaWiki, jQuery);