Tip of the Day
Crafting outcome will help raise the chances for better quality items seen at the bottom of recipes in the crafting interface.

MediaWiki:Gadget-DryCalc.js: Difference between revisions

From Walkscape Walkthrough
mNo edit summary
mNo edit summary
 
(14 intermediate revisions by the same user not shown)
Line 2: Line 2:
(function () {
(function () {
   'use strict';
   'use strict';
  // -----------------------------
  // Utilities
  // -----------------------------


   function clampNumber(n, min, max) {
   function clampNumber(n, min, max) {
Line 25: Line 21:
   function formatInt(x) {
   function formatInt(x) {
     if (!isFinite(x)) return '—';
     if (!isFinite(x)) return '—';
     return String(Math.round(x));
     return Math.round(x).toLocaleString(mw.config.get('wgUserLanguage') || 'en-US');
   }
   }


   function calcBinomialSingle(p, t) {
   function calc(p, t) {
    // For independent trials with success prob p:
    // P(0) = (1-p)^t, P(>=1) = 1 - P(0), E[drops]=t*p
     var q = 1 - p;
     var q = 1 - p;
     var p0 = Math.pow(q, t);
     var p0 = Math.pow(q, t);
Line 39: Line 33:


   function trialsForConfidence(p, c) {
   function trialsForConfidence(p, c) {
    // Smallest integer t such that 1 - (1-p)^t >= c
     if (!(p > 0 && p < 1)) return NaN;
     if (!(p > 0 && p < 1)) return NaN;
     if (!(c > 0 && c < 1)) return NaN;
     if (!(c > 0 && c < 1)) return NaN;
     var t = Math.log(1 - c) / Math.log(1 - p);
     var t = Math.log(1 - c) / Math.log(1 - p);
     return Math.ceil(t);
     return Math.ceil(t);
  }
 
  function parseWikitext(wikitext) {
    var api = new mw.Api();
    return api.post({
      action: 'parse',
      format: 'json',
      contentmodel: 'wikitext',
      prop: 'text',
      text: wikitext
    }).then(function (data) {
      return (data && data.parse && data.parse.text && data.parse.text['*']) ? data.parse.text['*'] : '';
    }, function () {
      return '';
    });
  }
  // -----------------------------
  // Flavour text by dryness (%)
  // -----------------------------
  var FLAVOUR_TEXTS = [
    { min: -1,  max: 1,    lines: [
      "You're dancing with the Syrenthians.",
      "You are extremely not dry."
    ]},
    { min: 1,    max: 10,  lines: [
      "Your spoon is too big!",
      "🥄 🥄 🥄",
      "if you have a drop by now..."
    ]},
    { min: 10,  max: 20,  lines: [
      "Stay hydrated, you’ve a long way to go."
    ]},
    { min: 20,  max: 30,  lines: [
      "🥄 Spooned 🥄",
      "Just kidding. No drops."
    ]},
    { min: 30,  max: 40,  lines: [
      "Your friends would be jealous.",
      "…If you had any drops."
    ]},
    { min: 40,  max: 49,  lines: [
      "You're quite the lucker, aren't you?",
      "Or not, since you got no drops."
    ]},
    { min: 49,  max: 51,  lines: [
      "A perfect mix of dry and undry, as all things should be."
    ]},
    { min: 51,  max: 61,  lines: [
      "Nothing interesting happens.",
      "Over half of players would have had a drop by now."
    ]},
    { min: 61,  max: 65,  lines: [
      "An unenlightened being would say:",
      "“1 in X means I should have it by now.”"
    ]},
    { min: 65,  max: 73,  lines: [
      "Nothing interesting happens.",
      "Still no drops."
    ]},
    { min: 73,  max: 74,  lines: [
      "😂😂😂"
    ]},
    { min: 74,  max: 85,  lines: [
      "Oof."
    ]},
    { min: 85,  max: 90,  lines: [
      "A national emergency has been declared about an ongoing drought."
    ]},
    { min: 90,  max: 95,  lines: [
      "Right. Time to post on Discord."
    ]},
    { min: 95,  max: 99,  lines: [
      "You after being this dry:",
      "💀 💀 💀"
    ]},
    { min: 99,  max: 99.5, lines: [
      "You rn: [[File:Unidentified remains.svg|32px]]"
    ]},
    { min: 99.5, max: 99.9, lines: [
      "Malik, foraging on the glacier.",
      "Agile, scrambling along the ridge.",
      "You, doing whatever you're doing."
    ]},
    { min: 99.9, max: 1000, lines: [
      "Did you forget to start the activity?"
    ]}
  ];
 
  function getFlavourText(dryPercent) {
    for (var i = 0; i < FLAVOUR_TEXTS.length; i++) {
      var f = FLAVOUR_TEXTS[i];
      if (dryPercent >= f.min && dryPercent < f.max) {
        return f.lines;
      }
    }
    return null;
   }
   }


   function normalize(mode, wearInput, oneInX, percent, count) {
   function normalize(mode, wearInput, oneInX, percent, count) {
    // Returns: { p, t, unit, perDrop, modeLabel, warnings[] }
     var warnings = [];
     var warnings = [];
    var t = clampNumber(count, 0, 1e18);


    var t = clampNumber(count, 0, 1e18);
     if (!(t >= 0)) {
     if (!(t >= 0)) {
       return {
       return { p: NaN, t: NaN, unit: '—', perDrop: NaN, warnings: ['Please enter a valid number.'] };
        p: NaN, t: NaN, unit: '—', perDrop: NaN,
        modeLabel: '—',
        warnings: ['Please enter a valid count.']
      };
     }
     }


     if (mode === 'wear') {
     if (mode === 'wear') {
       var wear = clampNumber(wearInput, 1, 1e18);
       var wear = clampNumber(wearInput, 1, 1e18);
       if (!(wear >= 1)) {
       if (!(wear >= 1)) return { p: NaN, t: NaN, unit: 'steps', perDrop: NaN, warnings: ['Enter a valid WEAR (steps per drop).'] };
        return {
       return { p: 1 / wear, t: t, unit: 'steps', perDrop: wear, warnings: warnings };
          p: NaN, t: NaN, unit: 'steps', perDrop: NaN,
          modeLabel: 'WEAR (steps)',
          warnings: ['Please enter a valid WEAR value (steps per drop).']
        };
      }
       return {
        p: 1 / wear,
        t: t,
        unit: 'steps',
        perDrop: wear,
        modeLabel: 'WEAR (steps)',
        warnings: warnings
      };
     }
     }


     if (mode === 'actions') {
     if (mode === 'actions') {
       var x = clampNumber(oneInX, 1, 1e18);
       var x = clampNumber(oneInX, 1, 1e18);
       if (!(x >= 1)) {
       if (!(x >= 1)) return { p: NaN, t: NaN, unit: 'actions', perDrop: NaN, warnings: ['Enter a valid “1 in X” value.'] };
        return {
       return { p: 1 / x, t: t, unit: 'actions', perDrop: x, warnings: warnings };
          p: NaN, t: NaN, unit: 'actions', perDrop: NaN,
          modeLabel: 'Actions (1 in X)',
          warnings: ['Please enter a valid “1 in X” value.']
        };
      }
       return {
        p: 1 / x,
        t: t,
        unit: 'actions',
        perDrop: x,
        modeLabel: 'Actions (1 in X)',
        warnings: warnings
      };
     }
     }


     if (mode === 'percent') {
     if (mode === 'percent') {
       var pct = clampNumber(percent, 0, 100);
       var pct = clampNumber(percent, 0, 100);
       if (!(pct > 0 && pct <= 100)) {
       if (!(pct > 0 && pct <= 100)) return { p: NaN, t: NaN, unit: 'actions', perDrop: NaN, warnings: ['Enter a valid percent (0 < % ≤ 100).'] };
        return {
          p: NaN, t: NaN, unit: 'actions', perDrop: NaN,
          modeLabel: 'Percent (%)',
          warnings: ['Please enter a valid percent (0 < % ≤ 100).']
        };
      }
       var p = pct / 100;
       var p = pct / 100;
       return {
       return { p: p, t: t, unit: 'actions', perDrop: 1 / p, warnings: warnings };
        p: p,
        t: t,
        unit: 'actions',
        perDrop: 1 / p,
        modeLabel: 'Percent (%)',
        warnings: warnings
      };
     }
     }


     return {
     return { p: NaN, t: NaN, unit: '—', perDrop: NaN, warnings: ['Unknown mode.'] };
      p: NaN, t: NaN, unit: '—', perDrop: NaN,
      modeLabel: '—',
      warnings: ['Unknown input mode.']
    };
   }
   }
  // -----------------------------
  // UI
  // -----------------------------


   var CONF_LEVELS = [
   var CONF_LEVELS = [
Line 135: Line 173:
   ];
   ];


   function buildUI($host) {
   function buildCalculator($host) {
    // Optional prefills via data-*
     var $card = $('<div>').addClass('ws-dropcalc-card');
     var defaultMode = String($host.data('mode') || 'wear').toLowerCase();
    if (['wear', 'actions', 'percent'].indexOf(defaultMode) === -1) defaultMode = 'wear';


    var preWear = $host.data('wear');
    var preOneIn = $host.data('onein');
    var prePercent = $host.data('percent');
    var preCount = $host.data('count');
    // Container
    var $card = $('<div>').addClass('ws-dropcalc-card');
     var $title = $('<div>').addClass('ws-dropcalc-title').text('Drop Chance Calculator');
     var $title = $('<div>').addClass('ws-dropcalc-title').text('Drop Chance Calculator');


     // Mode (radio)
     // Mode radios
     var groupName = 'ws-dropcalc-mode-' + Math.random().toString(36).slice(2);
     var groupName = 'ws-dropcalc-mode-' + Math.random().toString(36).slice(2);


     function makeRadio(id, value, label) {
     function makeRadio(value, label) {
       var $wrap = $('<label>').addClass('ws-dropcalc-radio');
      var id = groupName + '-' + value;
       var $input = $('<input>').attr({ type: 'radio', name: groupName, id: id, value: value });
       var $label = $('<label>').addClass('ws-dropcalc-radio').attr('for', id);
       var $input = $('<input>').attr({ type: 'radio', id: id, name: groupName, value: value });
       var $text = $('<span>').text(label);
       var $text = $('<span>').text(label);
       $wrap.append($input, $text);
       $label.append($input, $text);
       return $wrap;
       return $label;
     }
     }


Line 163: Line 193:
     var $modeLabel = $('<div>').addClass('ws-dropcalc-label').text('Input mode');
     var $modeLabel = $('<div>').addClass('ws-dropcalc-label').text('Input mode');


     var $radios = $('<div>').addClass('ws-dropcalc-radio-wrap');
     var $radioWrap = $('<div>').addClass('ws-dropcalc-radio-wrap').append(
    var $rWear = makeRadio(groupName + '-wear', 'wear', 'WEAR (steps per item)');
      makeRadio('wear', 'WEAR (steps per item)'),
    var $rAct = makeRadio(groupName + '-actions', 'actions', 'Actions (1 in X)');
      makeRadio('actions', 'Actions (1 in X)'),
    var $rPct = makeRadio(groupName + '-percent', 'percent', 'Percent (%)');
      makeRadio('percent', 'Percent (%)')
    );


    $radios.append($rWear, $rAct, $rPct);
     $modeRow.append($modeLabel, $radioWrap);
     $modeRow.append($modeLabel, $radios);


     // Inputs
     // Inputs
     var $wearRow = $('<div>').addClass('ws-dropcalc-row');
     var $wearRow = $('<div>').addClass('ws-dropcalc-row');
     var $wearLabel = $('<label>').addClass('ws-dropcalc-label').text('WEAR (steps per drop)');
     var $wearLabel = $('<label>').addClass('ws-dropcalc-label').text('WEAR (steps per drop)');
     var $wear = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '1', step: '1' });
     var $wear = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '1', step: '1' }).val('0');
     $wearRow.append($wearLabel, $wear);
     $wearRow.append($wearLabel, $wear);


     var $oneInRow = $('<div>').addClass('ws-dropcalc-row');
     var $oneInRow = $('<div>').addClass('ws-dropcalc-row');
     var $oneInLabel = $('<label>').addClass('ws-dropcalc-label').text('Rate (1 in X)');
     var $oneInLabel = $('<label>').addClass('ws-dropcalc-label').text('Rate (1 in X)');
     var $oneIn = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '1', step: '1' });
     var $oneIn = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '1', step: '1' }).val('0');
     $oneInRow.append($oneInLabel, $oneIn);
     $oneInRow.append($oneInLabel, $oneIn);


     var $pctRow = $('<div>').addClass('ws-dropcalc-row');
     var $pctRow = $('<div>').addClass('ws-dropcalc-row');
     var $pctLabel = $('<label>').addClass('ws-dropcalc-label').text('Chance (%)');
     var $pctLabel = $('<label>').addClass('ws-dropcalc-label').text('Chance (%)');
     var $pct = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', max: '100', step: '0.001' });
     var $pct = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', max: '100', step: '0.001' }).val('0.0');
     var $pctHint = $('<div>').addClass('ws-dropcalc-hint').text('Example: 0.102 means 0.102%');
     var $pctHint = $('<div>').addClass('ws-dropcalc-hint').text('Example: 0.102 means 0.102%');
     var $pctWrap = $('<div>').addClass('ws-dropcalc-col').append($pct, $pctHint);
     var $pctCol = $('<div>').addClass('ws-dropcalc-col').append($pct, $pctHint);
     $pctRow.append($pctLabel, $pctWrap);
     $pctRow.append($pctLabel, $pctCol);


     var $countRow = $('<div>').addClass('ws-dropcalc-row');
     var $countRow = $('<div>').addClass('ws-dropcalc-row');
     var $countLabel = $('<label>').addClass('ws-dropcalc-label'); // text set dynamically
     var $countLabel = $('<label>').addClass('ws-dropcalc-label');
     var $count = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', step: '1' });
     var $count = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', step: '1' }).val('0');
     $countRow.append($countLabel, $count);
     $countRow.append($countLabel, $count);


     // Output
     // Output
     var $out = $('<div>').addClass('ws-dropcalc-out');
     var $out = $('<div>').addClass('ws-dropcalc-out');
// Flavour render state (prevents API spam + out-of-order updates)
var lastFlavourKey = null;
var flavourRenderToken = 0;


     // Prefill values (reasonable defaults if none supplied)
     // Default mode
    $wear.val(isFinite(preWear) ? preWear : 62745);
     $card.find('input[name="' + groupName + '"][value="wear"]').prop('checked', true);
    $oneIn.val(isFinite(preOneIn) ? preOneIn : 980);
    $pct.val(isFinite(prePercent) ? prePercent : 0.102);
    $count.val(isFinite(preCount) ? preCount : 0);
 
    // Select default mode
     $card.find('input[name="' + groupName + '"][value="' + defaultMode + '"]').prop('checked', true);


     function getMode() {
     function getMode() {
Line 220: Line 248:
       setVisibility(mode);
       setVisibility(mode);


      var countVal = parseFloat($count.val());
       var unit = (mode === 'wear') ? 'steps' : 'actions';
       var unit = (mode === 'wear') ? 'steps' : 'actions';
       $countLabel.text('Completed (' + unit + ')');
       $countLabel.text('Completed (' + unit + ')');
Line 229: Line 256:
         parseFloat($oneIn.val()),
         parseFloat($oneIn.val()),
         parseFloat($pct.val()),
         parseFloat($pct.val()),
         countVal
         parseFloat($count.val())
       );
       );


       $out.empty();
       $out.empty();


      // Warnings
       if (norm.warnings && norm.warnings.length) {
       if (norm.warnings && norm.warnings.length) {
         norm.warnings.forEach(function (w) {
         norm.warnings.forEach(function (w) {
Line 241: Line 267:
       }
       }


      // If invalid, do not compute further
       if (!(norm.p > 0 && norm.p < 1) || !(norm.t >= 0)) return;
       if (!(norm.p > 0 && norm.p < 1) || !(norm.t >= 0)) return;


       var r = calcBinomialSingle(norm.p, norm.t);
       var r = calc(norm.p, norm.t);


       var expectedPerDropLabel = (mode === 'wear')
       var expectedLabel;
        ? 'WEAR (steps per drop)'
      var expectedValue;
        : 'Expected actions per drop';


       var expectedPerDropValue = (mode === 'percent')
       if (mode === 'wear') {
         ? formatNumber(norm.perDrop, 2)
        expectedLabel = 'WEAR (steps per drop)';
         : formatInt(norm.perDrop);
        expectedValue = formatInt(norm.perDrop) + ' ' + unit;
      } else if (mode === 'actions') {
         expectedLabel = 'Expected actions per drop';
        expectedValue = formatInt(norm.perDrop) + ' ' + unit;
      } else {
         expectedLabel = 'Expected actions per drop';
        expectedValue = formatNumber(norm.perDrop, 2) + ' ' + unit;
      }


       var multiple = norm.t / norm.perDrop;
       var multiple = norm.t / norm.perDrop;
Line 258: Line 289:
       $out.append(
       $out.append(
         $('<div>').addClass('ws-dropcalc-metric')
         $('<div>').addClass('ws-dropcalc-metric')
           .append($('<div>').addClass('ws-dropcalc-k').text(expectedPerDropLabel))
           .append($('<div>').addClass('ws-dropcalc-k').text(expectedLabel))
           .append($('<div>').addClass('ws-dropcalc-v').text(expectedPerDropValue + ' ' + unit)),
           .append($('<div>').addClass('ws-dropcalc-v').text(expectedValue)),


         $('<div>').addClass('ws-dropcalc-metric')
         $('<div>').addClass('ws-dropcalc-metric')
Line 285: Line 316:
         $milestones.append(
         $milestones.append(
           $('<div>').addClass('ws-dropcalc-chip')
           $('<div>').addClass('ws-dropcalc-chip')
             .text(it.label + ': ' + (isFinite(needed) ? needed : '—') + ' ' + unit)
             .text(it.label + ': ' + (isFinite(needed) ? formatInt(needed) : '—') + ' ' + unit)
         );
         );
       });
       });
       $out.append($milestones);
       $out.append($milestones);
 
     
      // Optional informational line for percent mode
  // ----- Flavour text (parsed wikitext) -----
      if (mode === 'percent') {
  // NOTE: This uses OSRS-style metric: "chance you'd have had ≥1 drop by now"
        $out.append(
  var wouldHaveDropPercent = r.pge1 * 100;
          $('<div>').addClass('ws-dropcalc-note')
  var lines = getFlavourText(wouldHaveDropPercent);
            .text('Percent mode assumes the chance is per action.')
 
        );
  // Create a stable key so we only parse when message changes
      }
  var flavourKey = (lines && lines.length) ? lines.join('\n') : '';
  var $flavour = $('<div>').addClass('ws-dropcalc-flavour');
  $out.append($flavour);
 
  if (!flavourKey) return;
 
  // If message hasn't changed, render as plain text quickly (no API call)
  if (flavourKey === lastFlavourKey) {
    lines.forEach(function (line) {
      // Keep last rendered HTML; if you want plain text fallback:
      $flavour.append($('<div>').text(line));
    });
    return;
  }
  lastFlavourKey = flavourKey;
 
  // Async parse (guard against stale renders)
  var myToken = ++flavourRenderToken;
 
  // Parse each line and append HTML in order
  (function parseNext(i) {
    if (i >= lines.length) return;
 
    parseWikitext(lines[i]).then(function (html) {
      if (myToken !== flavourRenderToken) return; // stale render, ignore
      $flavour.append($('<div>').html(html));
      parseNext(i + 1);
    });
  })(0);
     }
     }


     $card.on('change input', 'input', render);
     $card.on('change input', 'input', render);


    // Layout
     $card.append(
     $card.append(
       $title,
       $title,
Line 311: Line 370:
       $out
       $out
     );
     );
    // Initial visibility + compute
    setVisibility('wear');
    render();


     $host.empty().append($card);
     $host.empty().append($card);
    render();
   }
   }


   function init($content) {
   function init($content) {
    // Dedicated page target (preferred)
    var $page = $content.find('.ws-dropcalc-page').first();
    if ($page.length) {
      buildCalculator($page);
      return;
    }
    // Fallback: allow embedding elsewhere
     $content.find('.ws-dropcalc').each(function () {
     $content.find('.ws-dropcalc').each(function () {
       buildUI($(this));
       buildCalculator($(this));
     });
     });
   }
   }

Latest revision as of 10:58, 29 December 2025

/* global mw, $ */
(function () {
  'use strict';

  function clampNumber(n, min, max) {
    if (!isFinite(n)) return NaN;
    return Math.min(Math.max(n, min), max);
  }

  function formatPercent(x) {
    if (!isFinite(x)) return '—';
    return (x * 100).toFixed(2) + '%';
  }

  function formatNumber(x, decimals) {
    if (!isFinite(x)) return '—';
    var d = (typeof decimals === 'number') ? decimals : 2;
    return x.toFixed(d);
  }

  function formatInt(x) {
    if (!isFinite(x)) return '—';
    return Math.round(x).toLocaleString(mw.config.get('wgUserLanguage') || 'en-US');
  }

  function calc(p, t) {
    var q = 1 - p;
    var p0 = Math.pow(q, t);
    var pge1 = 1 - p0;
    var mu = t * p;
    return { p0: p0, pge1: pge1, mu: mu };
  }

  function trialsForConfidence(p, c) {
    if (!(p > 0 && p < 1)) return NaN;
    if (!(c > 0 && c < 1)) return NaN;
    var t = Math.log(1 - c) / Math.log(1 - p);
    return Math.ceil(t);
  }
  
  function parseWikitext(wikitext) {
    var api = new mw.Api();
    return api.post({
      action: 'parse',
      format: 'json',
      contentmodel: 'wikitext',
      prop: 'text',
      text: wikitext
    }).then(function (data) {
      return (data && data.parse && data.parse.text && data.parse.text['*']) ? data.parse.text['*'] : '';
    }, function () {
      return '';
    });
  }

  // -----------------------------
  // Flavour text by dryness (%)
  // -----------------------------
  var FLAVOUR_TEXTS = [
    { min: -1,   max: 1,    lines: [
      "You're dancing with the Syrenthians.",
      "You are extremely not dry."
    ]},
    { min: 1,    max: 10,   lines: [
      "Your spoon is too big!",
      "🥄 🥄 🥄",
      "if you have a drop by now..."
    ]},
    { min: 10,   max: 20,   lines: [
      "Stay hydrated, you’ve a long way to go."
    ]},
    { min: 20,   max: 30,   lines: [
      "🥄 Spooned 🥄",
      "Just kidding. No drops."
    ]},
    { min: 30,   max: 40,   lines: [
      "Your friends would be jealous.",
      "…If you had any drops."
    ]},
    { min: 40,   max: 49,   lines: [
      "You're quite the lucker, aren't you?",
      "Or not, since you got no drops."
    ]},
    { min: 49,   max: 51,   lines: [
      "A perfect mix of dry and undry, as all things should be."
    ]},
    { min: 51,   max: 61,   lines: [
      "Nothing interesting happens.",
      "Over half of players would have had a drop by now."
    ]},
    { min: 61,   max: 65,   lines: [
      "An unenlightened being would say:",
      "“1 in X means I should have it by now.”"
    ]},
    { min: 65,   max: 73,   lines: [
      "Nothing interesting happens.",
      "Still no drops."
    ]},
    { min: 73,   max: 74,   lines: [
      "😂😂😂"
    ]},
    { min: 74,   max: 85,   lines: [
      "Oof."
    ]},
    { min: 85,   max: 90,   lines: [
      "A national emergency has been declared about an ongoing drought."
    ]},
    { min: 90,   max: 95,   lines: [
      "Right. Time to post on Discord."
    ]},
    { min: 95,   max: 99,   lines: [
      "You after being this dry:",
      "💀 💀 💀"
    ]},
    { min: 99,   max: 99.5, lines: [
      "You rn: [[File:Unidentified remains.svg|32px]]"
    ]},
    { min: 99.5, max: 99.9, lines: [
      "Malik, foraging on the glacier.",
      "Agile, scrambling along the ridge.",
      "You, doing whatever you're doing."
    ]},
    { min: 99.9, max: 1000, lines: [
      "Did you forget to start the activity?"
    ]}
  ];
  
  function getFlavourText(dryPercent) {
    for (var i = 0; i < FLAVOUR_TEXTS.length; i++) {
      var f = FLAVOUR_TEXTS[i];
      if (dryPercent >= f.min && dryPercent < f.max) {
        return f.lines;
      }
    }
    return null;
  }

  function normalize(mode, wearInput, oneInX, percent, count) {
    var warnings = [];
    var t = clampNumber(count, 0, 1e18);

    if (!(t >= 0)) {
      return { p: NaN, t: NaN, unit: '—', perDrop: NaN, warnings: ['Please enter a valid number.'] };
    }

    if (mode === 'wear') {
      var wear = clampNumber(wearInput, 1, 1e18);
      if (!(wear >= 1)) return { p: NaN, t: NaN, unit: 'steps', perDrop: NaN, warnings: ['Enter a valid WEAR (steps per drop).'] };
      return { p: 1 / wear, t: t, unit: 'steps', perDrop: wear, warnings: warnings };
    }

    if (mode === 'actions') {
      var x = clampNumber(oneInX, 1, 1e18);
      if (!(x >= 1)) return { p: NaN, t: NaN, unit: 'actions', perDrop: NaN, warnings: ['Enter a valid “1 in X” value.'] };
      return { p: 1 / x, t: t, unit: 'actions', perDrop: x, warnings: warnings };
    }

    if (mode === 'percent') {
      var pct = clampNumber(percent, 0, 100);
      if (!(pct > 0 && pct <= 100)) return { p: NaN, t: NaN, unit: 'actions', perDrop: NaN, warnings: ['Enter a valid percent (0 < % ≤ 100).'] };
      var p = pct / 100;
      return { p: p, t: t, unit: 'actions', perDrop: 1 / p, warnings: warnings };
    }

    return { p: NaN, t: NaN, unit: '—', perDrop: NaN, warnings: ['Unknown mode.'] };
  }

  var CONF_LEVELS = [
    { c: 0.50, label: '50%' },
    { c: 0.90, label: '90%' },
    { c: 0.95, label: '95%' },
    { c: 0.99, label: '99%' }
  ];

  function buildCalculator($host) {
    var $card = $('<div>').addClass('ws-dropcalc-card');

    var $title = $('<div>').addClass('ws-dropcalc-title').text('Drop Chance Calculator');

    // Mode radios
    var groupName = 'ws-dropcalc-mode-' + Math.random().toString(36).slice(2);

    function makeRadio(value, label) {
      var id = groupName + '-' + value;
      var $label = $('<label>').addClass('ws-dropcalc-radio').attr('for', id);
      var $input = $('<input>').attr({ type: 'radio', id: id, name: groupName, value: value });
      var $text = $('<span>').text(label);
      $label.append($input, $text);
      return $label;
    }

    var $modeRow = $('<div>').addClass('ws-dropcalc-row');
    var $modeLabel = $('<div>').addClass('ws-dropcalc-label').text('Input mode');

    var $radioWrap = $('<div>').addClass('ws-dropcalc-radio-wrap').append(
      makeRadio('wear', 'WEAR (steps per item)'),
      makeRadio('actions', 'Actions (1 in X)'),
      makeRadio('percent', 'Percent (%)')
    );

    $modeRow.append($modeLabel, $radioWrap);

    // Inputs
    var $wearRow = $('<div>').addClass('ws-dropcalc-row');
    var $wearLabel = $('<label>').addClass('ws-dropcalc-label').text('WEAR (steps per drop)');
    var $wear = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '1', step: '1' }).val('0');
    $wearRow.append($wearLabel, $wear);

    var $oneInRow = $('<div>').addClass('ws-dropcalc-row');
    var $oneInLabel = $('<label>').addClass('ws-dropcalc-label').text('Rate (1 in X)');
    var $oneIn = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '1', step: '1' }).val('0');
    $oneInRow.append($oneInLabel, $oneIn);

    var $pctRow = $('<div>').addClass('ws-dropcalc-row');
    var $pctLabel = $('<label>').addClass('ws-dropcalc-label').text('Chance (%)');
    var $pct = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', max: '100', step: '0.001' }).val('0.0');
    var $pctHint = $('<div>').addClass('ws-dropcalc-hint').text('Example: 0.102 means 0.102%');
    var $pctCol = $('<div>').addClass('ws-dropcalc-col').append($pct, $pctHint);
    $pctRow.append($pctLabel, $pctCol);

    var $countRow = $('<div>').addClass('ws-dropcalc-row');
    var $countLabel = $('<label>').addClass('ws-dropcalc-label');
    var $count = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', step: '1' }).val('0');
    $countRow.append($countLabel, $count);

    // Output
    var $out = $('<div>').addClass('ws-dropcalc-out');
	
	// Flavour render state (prevents API spam + out-of-order updates)
	var lastFlavourKey = null;
	var flavourRenderToken = 0;

    // Default mode
    $card.find('input[name="' + groupName + '"][value="wear"]').prop('checked', true);

    function getMode() {
      return $card.find('input[name="' + groupName + '"]:checked').val() || 'wear';
    }

    function setVisibility(mode) {
      $wearRow.toggle(mode === 'wear');
      $oneInRow.toggle(mode === 'actions');
      $pctRow.toggle(mode === 'percent');
    }

    function render() {
      var mode = getMode();
      setVisibility(mode);

      var unit = (mode === 'wear') ? 'steps' : 'actions';
      $countLabel.text('Completed (' + unit + ')');

      var norm = normalize(
        mode,
        parseFloat($wear.val()),
        parseFloat($oneIn.val()),
        parseFloat($pct.val()),
        parseFloat($count.val())
      );

      $out.empty();

      if (norm.warnings && norm.warnings.length) {
        norm.warnings.forEach(function (w) {
          $out.append($('<div>').addClass('ws-dropcalc-warn').text(w));
        });
      }

      if (!(norm.p > 0 && norm.p < 1) || !(norm.t >= 0)) return;

      var r = calc(norm.p, norm.t);

      var expectedLabel;
      var expectedValue;

      if (mode === 'wear') {
        expectedLabel = 'WEAR (steps per drop)';
        expectedValue = formatInt(norm.perDrop) + ' ' + unit;
      } else if (mode === 'actions') {
        expectedLabel = 'Expected actions per drop';
        expectedValue = formatInt(norm.perDrop) + ' ' + unit;
      } else {
        expectedLabel = 'Expected actions per drop';
        expectedValue = formatNumber(norm.perDrop, 2) + ' ' + unit;
      }

      var multiple = norm.t / norm.perDrop;

      $out.append(
        $('<div>').addClass('ws-dropcalc-metric')
          .append($('<div>').addClass('ws-dropcalc-k').text(expectedLabel))
          .append($('<div>').addClass('ws-dropcalc-v').text(expectedValue)),

        $('<div>').addClass('ws-dropcalc-metric')
          .append($('<div>').addClass('ws-dropcalc-k').text('You are at'))
          .append($('<div>').addClass('ws-dropcalc-v').text(formatNumber(multiple, 2) + '× expected')),

        $('<div>').addClass('ws-dropcalc-metric')
          .append($('<div>').addClass('ws-dropcalc-k').text('Expected drops so far'))
          .append($('<div>').addClass('ws-dropcalc-v').text(formatNumber(r.mu, 2))),

        $('<div>').addClass('ws-dropcalc-metric')
          .append($('<div>').addClass('ws-dropcalc-k').text('Chance of at least 1 drop by now'))
          .append($('<div>').addClass('ws-dropcalc-v').text(formatPercent(r.pge1))),

        $('<div>').addClass('ws-dropcalc-metric')
          .append($('<div>').addClass('ws-dropcalc-k').text('Chance you would be dry (0 drops)'))
          .append($('<div>').addClass('ws-dropcalc-v').text(formatPercent(r.p0))),

        $('<div>').addClass('ws-dropcalc-subtitle').text('Milestones (chance of ≥1 drop)')
      );

      var $milestones = $('<div>').addClass('ws-dropcalc-milestones');
      CONF_LEVELS.forEach(function (it) {
        var needed = trialsForConfidence(norm.p, it.c);
        $milestones.append(
          $('<div>').addClass('ws-dropcalc-chip')
            .text(it.label + ': ' + (isFinite(needed) ? formatInt(needed) : '—') + ' ' + unit)
        );
      });
      $out.append($milestones);
      
	  // ----- Flavour text (parsed wikitext) -----
	  // NOTE: This uses OSRS-style metric: "chance you'd have had ≥1 drop by now"
	  var wouldHaveDropPercent = r.pge1 * 100;
	  var lines = getFlavourText(wouldHaveDropPercent);
	  
	  // Create a stable key so we only parse when message changes
	  var flavourKey = (lines && lines.length) ? lines.join('\n') : '';
	  var $flavour = $('<div>').addClass('ws-dropcalc-flavour');
	  $out.append($flavour);
	  
	  if (!flavourKey) return;
	  
	  // If message hasn't changed, render as plain text quickly (no API call)
	  if (flavourKey === lastFlavourKey) {
	    lines.forEach(function (line) {
	      // Keep last rendered HTML; if you want plain text fallback:
	      $flavour.append($('<div>').text(line));
	    });
	    return;
	  }
	  lastFlavourKey = flavourKey;
	  
	  // Async parse (guard against stale renders)
	  var myToken = ++flavourRenderToken;
	  
	  // Parse each line and append HTML in order
	  (function parseNext(i) {
	    if (i >= lines.length) return;
	  
	    parseWikitext(lines[i]).then(function (html) {
	      if (myToken !== flavourRenderToken) return; // stale render, ignore
	      $flavour.append($('<div>').html(html));
	      parseNext(i + 1);
	    });
	  })(0);
    }

    $card.on('change input', 'input', render);

    // Layout
    $card.append(
      $title,
      $modeRow,
      $wearRow,
      $oneInRow,
      $pctRow,
      $countRow,
      $out
    );

    // Initial visibility + compute
    setVisibility('wear');
    render();

    $host.empty().append($card);
  }

  function init($content) {
    // Dedicated page target (preferred)
    var $page = $content.find('.ws-dropcalc-page').first();
    if ($page.length) {
      buildCalculator($page);
      return;
    }

    // Fallback: allow embedding elsewhere
    $content.find('.ws-dropcalc').each(function () {
      buildCalculator($(this));
    });
  }

  mw.hook('wikipage.content').add(init);
})();