Tip of the Day
Many NPCs can tell you rumours to help find things in the game.

MediaWiki:Gadget-DryCalc.js: Difference between revisions

From Walkscape Walkthrough
mNo edit summary
mNo edit summary
 
Line 119: Line 119:
       "Malik, foraging on the glacier.",
       "Malik, foraging on the glacier.",
       "Agile, scrambling along the ridge.",
       "Agile, scrambling along the ridge.",
      "Ravaha, trying to get anything.",
       "You, doing whatever you're doing."
       "You, doing whatever you're doing."
     ]},
     ]},

Latest revision as of 11:26, 15 January 2026

/* 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.",
      "Ravaha, trying to get anything.",
      "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);
})();