MediaWiki:Gadget-NWOBuilder.js

From No Way Out Wiki
Revision as of 14:43, 26 September 2025 by User (talk | contribs) (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...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// 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>'
    );

    // ====== Your app logic (unchanged, just without <script> tags) ======
    const 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: {} }
      ]}
    };

    const 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 } }
    ];

    const 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 } }
    ];

    const 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 }
    };

    const 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"] }
    ];

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

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

    const 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; }

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

      SKILL_GROUPS.forEach(group=>{
        const mod = getSkillMod(group.key);
        group.list.forEach(skill=>{
          const 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 (breakdown[check]!==undefined){ breakdown[check].skills += mod; }
        });
      });

      breakdown.Strength.total = breakdown.Strength.base + breakdown.Strength.traits;
      const 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;
      const fitMod = Math.max(Math.floor((breakdown.Fitness.total - 6)/2), 0);
      breakdown.Robustness.derived += fitMod;

      CHECKS.forEach(check=>{
        breakdown[check].total = (breakdown[check].base||0) + (breakdown[check].traits||0) + (breakdown[check].skills||0) + (breakdown[check].derived||0);
      });
      breakdown.MaxHP = { value: calcMaxHP(breakdown.Fitness.total) };
      return breakdown;
    }

    function spend(){
      let pts = BACKGROUNDS[state.background].traitPoints;
      POSITIVE_TRAITS.forEach(t=>{ if(state.pos.has(t.name)) pts -= t.cost; });
      NEGATIVE_TRAITS.forEach(t=>{ if(state.neg.has(t.name)) pts += t.cost; });
      for (const tier of Object.values(state.skills)) pts -= SKILL_TIERS[tier].cost;
      return pts;
    }

    function skillTierCounts(){
      let exp=0, ex=0;
      for (const tier of Object.values(state.skills)){ if(tier==="Experienced") exp++; if(tier==="Expert") ex++; }
      return { exp, ex };
    }

    function renderBackground(){
      const bg = BACKGROUNDS[state.background];
      const html =
        '<label>Background</label>' +
        '<select id="backgroundSel">' +
          Object.keys(BACKGROUNDS).map(k=>`<option value="${k}"${state.background===k?' selected':''}>${k}</option>`).join('') +
        '</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>
          ${bg.freeTraits.map(t=>`<span class="pill">${t.name}</span>`).join('')}
        </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', e=>{ state.background = e.target.value; renderAll(); });
    }

    function renderTraitsAndSkills(){
      const 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>${skillTierCounts().exp}/2</b></span>
            <span class="pill">Expert: <b>${skillTierCounts().ex}/1</b></span>
          </div>
        </div>`;
      $('#traits-area').html(html);

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

      $('#negatives').html(NEGATIVE_TRAITS.map(t=>{
        const id = `neg_${t.name.replace(/\s+/g,'_')}`;
        const checked = state.neg.has(t.name) ? 'checked' : '';
        const mods = Object.entries(t.effects).map(([k,v])=>`<span class="tag">${k}: ${v>0?'+':''}${v}</span>`).join('');
        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>`;
      }).join(''));
      NEGATIVE_TRAITS.forEach(t=>{
        $(`#neg_${t.name.replace(/\s+/g,'_')}`).on('change', e=>{
          e.target.checked ? state.neg.add(t.name) : state.neg.delete(t.name);
          renderAll();
        });
      });

      $('#skills').html(SKILL_GROUPS.map(s=>{
        const val = state.skills[s.key] || '';
        const counts = skillTierCounts();
        const opts = ['','Amateur','Experienced','Expert'].map(tier=>{
          if(tier==='Experienced' && counts.exp>=2 && val!==tier) return `<option disabled>Experienced (max 2)</option>`;
          if(tier==='Expert' && counts.ex>=1 && val!==tier) return `<option disabled>Expert (max 1)</option>`;
          const lab = tier?`${tier} — cost ${SKILL_TIERS[tier]?.cost||''}, start L${SKILL_TIERS[tier]?.start||''}, cap ${SKILL_TIERS[tier]?.cap||''}`:'None';
          return `<option ${val===tier?'selected':''} value="${tier}">${lab}</option>`;
        }).join('');
        return `<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>`;
      }).join(''));
      SKILL_GROUPS.forEach(s=>{
        $(`#skill_${s.key}`).on('change', e=>{
          const v = e.target.value || null;
          if(v) state.skills[s.key]=v; else delete state.skills[s.key];
          renderAll();
        });
      });
    }

    function renderSummary(){
      const 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>
        <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>`;
      ["Ranged Attack","Reloading","Initiative","Robustness","First Aid","Nimble","Hiding"].forEach(check=>{
        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 += `</tbody></table>`;
      $('#summary-area').html(html);
    }

    function renderRoller(){
      const html =
        `<label>Roll Test Type</label>
         <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>
         <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', ()=>{
        const name = $('#rollType').val();
        const a = 1+Math.floor(Math.random()*6), b = 1+Math.floor(Math.random()*6);
        const bd = sumEffects(); const mod = 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);