Tip of the Day
If there's maintenance, don't worry, usually your steps will still count during the downtime. Check the Discord announcements to be sure!
MediaWiki:Gadget-DryCalc.js: Difference between revisions
From Walkscape Walkthrough
Created page with "→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 calc(p, t) { /..." |
mNo edit summary |
||
| (16 intermediate revisions by the same user not shown) | |||
| Line 17: | Line 17: | ||
var d = (typeof decimals === 'number') ? decimals : 2; | var d = (typeof decimals === 'number') ? decimals : 2; | ||
return x.toFixed(d); | 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) { | function calc(p, t) { | ||
var q = 1 - p; | var q = 1 - p; | ||
var p0 = Math.pow(q, t); | var p0 = Math.pow(q, t); | ||
var pge1 = 1 - p0; | var pge1 = 1 - p0; | ||
var mu = t * p; | var mu = t * p; | ||
return { p0: p0, pge1: pge1, mu: mu }; | return { p0: p0, pge1: pge1, mu: mu }; | ||
} | } | ||
function trialsForConfidence(p, c) { | function trialsForConfidence(p, 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; | |||
} | |||
var | 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); | $countRow.append($countLabel, $count); | ||
var $out = $('<div>').addClass('ws- | // 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() { | function render() { | ||
var mode = | var mode = getMode(); | ||
setVisibility(mode); | |||
var unit = (mode === 'wear') ? 'steps' : 'actions'; | |||
$countLabel.text('Completed (' + unit + ')'); | |||
var | var norm = normalize( | ||
$ | mode, | ||
parseFloat($wear.val()), | |||
parseFloat($oneIn.val()), | |||
parseFloat($pct.val()), | |||
parseFloat($count.val()) | |||
); | |||
$out.empty(); | |||
if ( | 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(p, t); | |||
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; | |||
var | |||
$out | $out.append( | ||
$('<div>').addClass('ws- | $('<div>').addClass('ws-dropcalc-metric') | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-k').text(expectedLabel)) | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-v').text(expectedValue)), | ||
$('<div>').addClass('ws- | $('<div>').addClass('ws-dropcalc-metric') | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-k').text('You are at')) | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-v').text(formatNumber(multiple, 2) + '× expected')), | ||
$('<div>').addClass('ws- | $('<div>').addClass('ws-dropcalc-metric') | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-k').text('Expected drops so far')) | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-v').text(formatNumber(r.mu, 2))), | ||
$('<div>').addClass('ws- | $('<div>').addClass('ws-dropcalc-metric') | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-k').text('Chance of at least 1 drop by now')) | ||
.append($('<div>').addClass('ws- | .append($('<div>').addClass('ws-dropcalc-v').text(formatPercent(r.pge1))), | ||
$('<div>').addClass('ws- | $('<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- | $('<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(); | render(); | ||
$host.empty().append($card); | |||
} | } | ||
function init($content) { | function init($content) { | ||
$content.find('.ws- | // 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)); | |||
}); | }); | ||
} | } | ||
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);
})();
