Tip of the Day
Long pressing an item opens multi-selection options in the inventory.

MediaWiki:Gadget-DryCalc.js: Difference between revisions

From Walkscape Walkthrough
mNo edit summary
mNo edit summary
Line 2: Line 2:
(function () {
(function () {
   'use strict';
   'use strict';
  // -----------------------------
  // Utilities
  // -----------------------------


   function clampNumber(n, min, max) {
   function clampNumber(n, min, max) {
Line 19: Line 23:
   }
   }


   function calc(p, t) {
   function formatInt(x) {
    if (!isFinite(x)) return '—';
    return String(Math.round(x));
  }
 
  function calcBinomialSingle(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 28: Line 39:


   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;
Line 34: Line 46:
   }
   }


   // Convert user-selected input mode -> effective per-step probability.
   function normalize(mode, wearInput, oneInX, percent, count) {
  // Returns: { pStep, wear, baseStepsPerDrop, warnings: [] }
    // Returns: { p, t, unit, perDrop, modeLabel, warnings[] }
  function normalizeToPerStep(mode, wearInput, oneInX, percent, baseStepsPerAction) {
     var warnings = [];
     var warnings = [];
    var t = clampNumber(count, 0, 1e18);
    if (!(t >= 0)) {
      return {
        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)) return { pStep: NaN, wear: NaN, baseStepsPerDrop: NaN, warnings: ['Invalid WEAR.'] };
       if (!(wear >= 1)) {
       return { pStep: 1 / wear, wear: wear, baseStepsPerDrop: NaN, warnings: warnings };
        return {
          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
      };
     }
     }


     // Actions / Percent are per-action odds; to express as per-step, we need base steps per action.
     if (mode === 'actions') {
    var sBase = clampNumber(baseStepsPerAction, 1, 1e18);
      var x = clampNumber(oneInX, 1, 1e18);
    if (!(sBase >= 1)) {
      if (!(x >= 1)) {
      warnings.push('Base steps per action is required to convert action odds into steps.');
        return {
       return { pStep: NaN, wear: NaN, baseStepsPerDrop: NaN, 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
      };
     }
     }


     var pAction;
     if (mode === 'percent') {
    if (mode === 'actions') {
      var x = clampNumber(oneInX, 1, 1e18);
      if (!(x >= 1)) return { pStep: NaN, wear: NaN, baseStepsPerDrop: NaN, warnings: ['Invalid “1 in X”.'] };
      pAction = 1 / x;
    } else if (mode === 'percent') {
       var pct = clampNumber(percent, 0, 100);
       var pct = clampNumber(percent, 0, 100);
       if (!(pct > 0 && pct <= 100)) return { pStep: NaN, wear: NaN, baseStepsPerDrop: NaN, warnings: ['Invalid percent.'] };
       if (!(pct > 0 && pct <= 100)) {
       pAction = pct / 100;
        return {
    } else {
          p: NaN, t: NaN, unit: 'actions', perDrop: NaN,
       return { pStep: NaN, wear: NaN, baseStepsPerDrop: NaN, warnings: ['Unknown mode.'] };
          modeLabel: 'Percent (%)',
          warnings: ['Please enter a valid percent (0 < % ≤ 100).']
        };
       }
      var p = pct / 100;
       return {
        p: p,
        t: t,
        unit: 'actions',
        perDrop: 1 / p,
        modeLabel: 'Percent (%)',
        warnings: warnings
      };
     }
     }


     // “Base expected steps per drop” (unmodified) = stepsPerAction / pAction
     return {
     var baseStepsPerDrop = sBase / pAction;
      p: NaN, t: NaN, unit: '—', perDrop: NaN,
      modeLabel: '—',
      warnings: ['Unknown input mode.']
     };
  }


    // If the user also happened to provide WEAR elsewhere (not in this mode), you could compare.
  // -----------------------------
    // For now, we treat this as base-only and label it as such.
  // UI
    var pStepBase = 1 / baseStepsPerDrop;
  // -----------------------------


     return { pStep: pStepBase, wear: NaN, baseStepsPerDrop: baseStepsPerDrop, warnings: warnings };
  var CONF_LEVELS = [
   }
     { c: 0.50, label: '50%' },
    { c: 0.90, label: '90%' },
    { c: 0.95, label: '95%' },
    { c: 0.99, label: '99%' }
   ];


   function buildUI($host) {
   function buildUI($host) {
     var $wrap = $('<div>').addClass('ws-drycalc-card');
    // Optional prefills via data-*
     var $title = $('<div>').addClass('ws-drycalc-title').text('Drop Chance Calculator');
     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');


     // Mode radios
     // Container
     var $modeRow = $('<div>').addClass('ws-drycalc-row');
     var $card = $('<div>').addClass('ws-dropcalc-card');
     var $modeLabel = $('<div>').addClass('ws-drycalc-label').text('Input mode');
     var $title = $('<div>').addClass('ws-dropcalc-title').text('Drop Chance Calculator');


     function radio(id, value, text) {
    // Mode (radio)
       var $r = $('<input>').attr({ type: 'radio', name: 'ws-drycalc-mode', id: id, value: value });
    var groupName = 'ws-dropcalc-mode-' + Math.random().toString(36).slice(2);
       var $l = $('<label>').attr('for', id).addClass('ws-drycalc-radio-label').text(text);
 
       return $('<div>').addClass('ws-drycalc-radio').append($r, $l);
     function makeRadio(id, value, label) {
       var $wrap = $('<label>').addClass('ws-dropcalc-radio');
      var $input = $('<input>').attr({ type: 'radio', name: groupName, id: id, value: value });
       var $text = $('<span>').text(label);
       $wrap.append($input, $text);
      return $wrap;
     }
     }


     var $mWear = radio('wsdc-wear', 'wear', 'WEAR (steps per item)');
     var $modeRow = $('<div>').addClass('ws-dropcalc-row');
     var $mAct  = radio('wsdc-actions', 'actions', 'Actions (1 in X)');
     var $modeLabel = $('<div>').addClass('ws-dropcalc-label').text('Input mode');
    var $mPct  = radio('wsdc-percent', 'percent', 'Percent (%)');


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


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


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


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


     var $baseStepsRow = $('<div>').addClass('ws-drycalc-row');
     var $countRow = $('<div>').addClass('ws-dropcalc-row');
     var $baseStepsLabel = $('<label>').addClass('ws-drycalc-label').text('Base steps per action');
     var $countLabel = $('<label>').addClass('ws-dropcalc-label'); // text set dynamically
     var $baseSteps = $('<input>').addClass('ws-drycalc-input').attr({ type: 'number', min: '1', step: '1', value: '115' });
     var $count = $('<input>').addClass('ws-dropcalc-input').attr({ type: 'number', min: '0', step: '1' });
     $baseStepsRow.append($baseStepsLabel, $baseSteps);
     $countRow.append($countLabel, $count);


     var $stepsRow = $('<div>').addClass('ws-drycalc-row');
     // Output
     var $stepsLabel = $('<label>').addClass('ws-drycalc-label').text('Steps completed');
     var $out = $('<div>').addClass('ws-dropcalc-out');
    var $steps = $('<input>').addClass('ws-drycalc-input').attr({ type: 'number', min: '0', step: '1', value: '0' });
    $stepsRow.append($stepsLabel, $steps);


     var $out = $('<div>').addClass('ws-drycalc-out');
     // Prefill values (reasonable defaults if none supplied)
    $wear.val(isFinite(preWear) ? preWear : 62745);
    $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() {
       return $wrap.find('input[name="ws-drycalc-mode"]:checked').val() || 'wear';
       return $card.find('input[name="' + groupName + '"]:checked').val() || 'wear';
     }
     }


Line 131: Line 214:
       $oneInRow.toggle(mode === 'actions');
       $oneInRow.toggle(mode === 'actions');
       $pctRow.toggle(mode === 'percent');
       $pctRow.toggle(mode === 'percent');
      // baseSteps is required for actions/percent modes (to convert to steps)
      $baseStepsRow.toggle(mode === 'actions' || mode === 'percent');
     }
     }


Line 139: Line 220:
       setVisibility(mode);
       setVisibility(mode);


       var stepsDone = clampNumber(parseFloat($steps.val()), 0, 1e18);
       var countVal = parseFloat($count.val());
      var unit = (mode === 'wear') ? 'steps' : 'actions';
      $countLabel.text('Completed (' + unit + ')');


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


       $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) {
           $out.append($('<div>').addClass('ws-drycalc-warn').text(w));
           $out.append($('<div>').addClass('ws-dropcalc-warn').text(w));
         });
         });
       }
       }


       if (!(stepsDone >= 0) || !(norm.pStep > 0 && norm.pStep < 1)) {
      // If invalid, do not compute further
        return;
       if (!(norm.p > 0 && norm.p < 1) || !(norm.t >= 0)) return;
       }
 
       var r = calcBinomialSingle(norm.p, norm.t);


       var r = calc(norm.pStep, stepsDone);
       var expectedPerDropLabel = (mode === 'wear')
        ? 'WEAR (steps per drop)'
        : 'Expected actions per drop';


       var t50 = trialsForConfidence(norm.pStep, 0.50);
       var expectedPerDropValue = (mode === 'percent')
      var t90 = trialsForConfidence(norm.pStep, 0.90);
        ? formatNumber(norm.perDrop, 2)
      var t95 = trialsForConfidence(norm.pStep, 0.95);
        : formatInt(norm.perDrop);
      var t99 = trialsForConfidence(norm.pStep, 0.99);


      // Derive the “expected steps per drop” that the calculator is using
       var multiple = norm.t / norm.perDrop;
       var expectedStepsPerDrop = 1 / norm.pStep;
      var multiple = stepsDone / expectedStepsPerDrop;


       $out.append(
       $out.append(
         $('<div>').addClass('ws-drycalc-metric')
         $('<div>').addClass('ws-dropcalc-metric')
           .append($('<div>').addClass('ws-drycalc-k').text('Expected steps per drop (used in calc)'))
           .append($('<div>').addClass('ws-dropcalc-k').text(expectedPerDropLabel))
           .append($('<div>').addClass('ws-drycalc-v').text(formatNumber(expectedStepsPerDrop, 0))),
           .append($('<div>').addClass('ws-dropcalc-v').text(expectedPerDropValue + ' ' + unit)),


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


         (isFinite(norm.baseStepsPerDrop) ? $('<div>').addClass('ws-drycalc-metric')
         $('<div>').addClass('ws-dropcalc-metric')
           .append($('<div>').addClass('ws-drycalc-k').text('Base expected steps per drop'))
           .append($('<div>').addClass('ws-dropcalc-k').text('Expected drops so far'))
           .append($('<div>').addClass('ws-drycalc-v').text(formatNumber(norm.baseStepsPerDrop, 0))) : null),
           .append($('<div>').addClass('ws-dropcalc-v').text(formatNumber(r.mu, 2))),


         $('<div>').addClass('ws-drycalc-metric')
         $('<div>').addClass('ws-dropcalc-metric')
           .append($('<div>').addClass('ws-drycalc-k').text('Expected drops so far'))
           .append($('<div>').addClass('ws-dropcalc-k').text('Chance of at least 1 drop by now'))
           .append($('<div>').addClass('ws-drycalc-v').text(formatNumber(r.mu, 2))),
           .append($('<div>').addClass('ws-dropcalc-v').text(formatPercent(r.pge1))),


         $('<div>').addClass('ws-drycalc-metric')
         $('<div>').addClass('ws-dropcalc-metric')
           .append($('<div>').addClass('ws-drycalc-k').text('Chance of at least 1 drop by now'))
           .append($('<div>').addClass('ws-dropcalc-k').text('Chance you would be dry (0 drops)'))
           .append($('<div>').addClass('ws-drycalc-v').text(formatPercent(r.pge1))),
           .append($('<div>').addClass('ws-dropcalc-v').text(formatPercent(r.p0))),
 
        $('<div>').addClass('ws-dropcalc-subtitle').text('Milestones (chance of ≥1 drop)')
      );


        $('<div>').addClass('ws-drycalc-metric')
      var $milestones = $('<div>').addClass('ws-dropcalc-milestones');
          .append($('<div>').addClass('ws-drycalc-k').text('Chance you would be dry (0 drops)'))
      CONF_LEVELS.forEach(function (it) {
          .append($('<div>').addClass('ws-drycalc-v').text(formatPercent(r.p0))),
        var needed = trialsForConfidence(norm.p, it.c);
        $milestones.append(
          $('<div>').addClass('ws-dropcalc-chip')
            .text(it.label + ': ' + (isFinite(needed) ? needed : '') + ' ' + unit)
        );
      });


        $('<div>').addClass('ws-drycalc-subtitle').text('Milestones (chance of ≥1 drop)'),
      $out.append($milestones);


        $('<div>').addClass('ws-drycalc-milestones').append(
      // Optional informational line for percent mode
          $('<div>').addClass('ws-drycalc-chip').text('50%: ' + (isFinite(t50) ? t50 : '—') + ' steps'),
      if (mode === 'percent') {
           $('<div>').addClass('ws-drycalc-chip').text('90%: ' + (isFinite(t90) ? t90 : '—') + ' steps'),
        $out.append(
          $('<div>').addClass('ws-drycalc-chip').text('95%: ' + (isFinite(t95) ? t95 : '—') + ' steps'),
           $('<div>').addClass('ws-dropcalc-note')
          $('<div>').addClass('ws-drycalc-chip').text('99%: ' + (isFinite(t99) ? t99 : '—') + ' steps')
            .text('Percent mode assumes the chance is per action.')
         )
         );
       );
       }
     }
     }


    // Default mode
     $card.on('change input', 'input', render);
     $mWear.find('input').prop('checked', true);
 
    $wrap.on('change input', 'input, select', render);


     $wrap.append(
     $card.append(
       $title,
       $title,
       $modeRow,
       $modeRow,
Line 219: Line 308:
       $oneInRow,
       $oneInRow,
       $pctRow,
       $pctRow,
       $baseStepsRow,
       $countRow,
      $stepsRow,
       $out
       $out
     );
     );


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


   function init($content) {
   function init($content) {
     $content.find('.ws-drycalc').each(function () {
     $content.find('.ws-dropcalc').each(function () {
       buildUI($(this));
       buildUI($(this));
     });
     });

Revision as of 01:38, 29 December 2025

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

  // -----------------------------
  // Utilities
  // -----------------------------

  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 String(Math.round(x));
  }

  function calcBinomialSingle(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 p0 = Math.pow(q, t);
    var pge1 = 1 - p0;
    var mu = t * p;
    return { p0: p0, pge1: pge1, mu: mu };
  }

  function trialsForConfidence(p, c) {
    // Smallest integer t such that 1 - (1-p)^t >= 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 normalize(mode, wearInput, oneInX, percent, count) {
    // Returns: { p, t, unit, perDrop, modeLabel, warnings[] }
    var warnings = [];

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

    if (mode === 'wear') {
      var wear = clampNumber(wearInput, 1, 1e18);
      if (!(wear >= 1)) {
        return {
          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') {
      var x = clampNumber(oneInX, 1, 1e18);
      if (!(x >= 1)) {
        return {
          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') {
      var pct = clampNumber(percent, 0, 100);
      if (!(pct > 0 && pct <= 100)) {
        return {
          p: NaN, t: NaN, unit: 'actions', perDrop: NaN,
          modeLabel: 'Percent (%)',
          warnings: ['Please enter a valid percent (0 < % ≤ 100).']
        };
      }
      var p = pct / 100;
      return {
        p: p,
        t: t,
        unit: 'actions',
        perDrop: 1 / p,
        modeLabel: 'Percent (%)',
        warnings: warnings
      };
    }

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

  // -----------------------------
  // UI
  // -----------------------------

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

  function buildUI($host) {
    // Optional prefills via data-*
    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');

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

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

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

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

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

    // 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' });
    $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' });
    $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' });
    var $pctHint = $('<div>').addClass('ws-dropcalc-hint').text('Example: 0.102 means 0.102%');
    var $pctWrap = $('<div>').addClass('ws-dropcalc-col').append($pct, $pctHint);
    $pctRow.append($pctLabel, $pctWrap);

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

    // Output
    var $out = $('<div>').addClass('ws-dropcalc-out');

    // Prefill values (reasonable defaults if none supplied)
    $wear.val(isFinite(preWear) ? preWear : 62745);
    $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() {
      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 countVal = parseFloat($count.val());
      var unit = (mode === 'wear') ? 'steps' : 'actions';
      $countLabel.text('Completed (' + unit + ')');

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

      $out.empty();

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

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

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

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

      var expectedPerDropValue = (mode === 'percent')
        ? formatNumber(norm.perDrop, 2)
        : formatInt(norm.perDrop);

      var multiple = norm.t / norm.perDrop;

      $out.append(
        $('<div>').addClass('ws-dropcalc-metric')
          .append($('<div>').addClass('ws-dropcalc-k').text(expectedPerDropLabel))
          .append($('<div>').addClass('ws-dropcalc-v').text(expectedPerDropValue + ' ' + unit)),

        $('<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) ? needed : '—') + ' ' + unit)
        );
      });

      $out.append($milestones);

      // Optional informational line for percent mode
      if (mode === 'percent') {
        $out.append(
          $('<div>').addClass('ws-dropcalc-note')
            .text('Percent mode assumes the chance is per action.')
        );
      }
    }

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

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

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

  function init($content) {
    $content.find('.ws-dropcalc').each(function () {
      buildUI($(this));
    });
  }

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