MediaWiki:Gadget-spawncalc.js: Difference between revisions

From Pixelrus
Jump to navigation Jump to search
m (Update MediaWiki:Gadget-spawncalc.js)
m (Update MediaWiki:Gadget-spawncalc.js)
Line 128: Line 128:


function round2( n ) { return Math.round( n * 100 ) / 100; }
function round2( n ) { return Math.round( n * 100 ) / 100; }
function fmtPct( p ) {
if ( !( p > 0 ) ) { return '0%'; }
var v = p * 100;
return ( v >= 1 ? v.toFixed( 1 ) : v.toFixed( 2 ) ) + '%';
}


// ── controls ──────────────────────────────────────────────────
// ── controls ──────────────────────────────────────────────────
Line 176: Line 182:
text: 'Find best chance' } );
text: 'Find best chance' } );


// Actual in-game spawn conditions the player can stand in.
var form = el( 'div', { class: 'scalc-form' }, [
var form = el( 'div', { class: 'scalc-form' }, [
field( 'Biome', biomeSel ),
field( 'Biome', biomeSel ),
field( 'Location type', methodSel ),
field( 'Location type', methodSel ),
field( 'Time of day', timeSel ),
field( 'Time of day', timeSel ),
field( 'Consecutive times', consecSel ),
field( 'Weather', weatherSel ),
field( 'Weather', weatherSel ),
field( 'Y-level', ySel ),
field( 'Y-level', ySel ),
Line 186: Line 192:
el( 'span', { class: 'scalc-label', text: ' ' } ), bestBtn
el( 'span', { class: 'scalc-label', text: ' ' } ), bestBtn
] )
] )
] );
// Meta/analysis controls: not a spawn condition, they only change how the
// chance is aggregated, so they live in their own slim section.
var meta = el( 'div', { class: 'scalc-form scalc-meta' }, [
el( 'span', { class: 'scalc-meta-label', text: 'Analysis' } ),
field( 'Consecutive times', consecSel )
] );
] );


Line 219: Line 232:


var p, detailText;
var p, detailText;
// Per-time breakdown shown when a block spans more than one time.
var perTime = null;
if ( single ) {
if ( single ) {
var res = chance( s.B, s.M, times[ 0 ], s.W, s.y );
var res = chance( s.B, s.M, times[ 0 ], s.W, s.y );
Line 232: Line 247:
for ( var t = 0; t < NT; t++ ) { p8.push( chance( s.B, s.M, t, s.W, s.y ).p ); }
for ( var t = 0; t < NT; t++ ) { p8.push( chance( s.B, s.M, t, s.W, s.y ).p ); }
p = blockWeighted( p8, times );
p = blockWeighted( p8, times );
var names = times.map( function ( ti ) { return titleCase( data.times[ ti ] ); } );
perTime = times.map( function ( ti ) {
return { name: titleCase( data.times[ ti ] ), p: p8[ ti ] };
} );
detailText = p > 0
detailText = p > 0
? '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
? '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
+ ( s.N >= NT ? 'any time' : names.join( ', ' ) ) + '.'
+ ( s.N >= NT ? 'any time' : s.N + ' consecutive times' )
+ ' (per-time below).'
: '';
: '';
}
}


var hit = p > 0;
var hit = p > 0;
var pctText = !hit ? '0%'
var pctText = fmtPct( hit ? p : 0 );
: ( p * 100 >= 1 ? ( p * 100 ).toFixed( 1 ) : ( p * 100 ).toFixed( 2 ) ) + '%';


var head = el( 'div', { class: 'scalc-result-head' }, [
var head = el( 'div', { class: 'scalc-result-head' }, [
Line 254: Line 271:
'Does not spawn in this situation. Try "Find best chance" or '
'Does not spawn in this situation. Try "Find best chance" or '
+ 'change the biome / location type / time.' } );
+ 'change the biome / location type / time.' } );
var children = [ head, detail ];
// One compact chip per time in the block, each labelled with its time
// and that time's own chance, so multi-time blocks are not opaque.
if ( hit && perTime ) {
var chips = perTime.map( function ( pt ) {
return el( 'span', { class: 'scalc-chip' }, [
el( 'span', { class: 'scalc-chip-time', text: pt.name } ),
el( 'span', { class: 'scalc-chip-pct', text: fmtPct( pt.p ) } )
] );
} );
children.push( el( 'div', { class: 'scalc-breakdown' }, chips ) );
}
results.appendChild( el( 'div', {
results.appendChild( el( 'div', {
class: 'scalc-result ' + ( hit ? 'scalc-result-hit' : 'scalc-result-miss' )
class: 'scalc-result ' + ( hit ? 'scalc-result-hit' : 'scalc-result-miss' )
+ ( flagBest && hit ? ' scalc-result-best' : '' )
+ ( flagBest && hit ? ' scalc-result-best' : '' )
}, [ head, detail ] ) );
}, children ) );
flagBest = false;
flagBest = false;
}
}
Line 358: Line 388:
el( 'span', { class: 'scalc-header-title', text: 'Spawn Chance Calculator' } )
el( 'span', { class: 'scalc-header-title', text: 'Spawn Chance Calculator' } )
] );
] );
var body = el( 'div', { class: 'scalc-body' }, [ form, results ] );
var body = el( 'div', { class: 'scalc-body' }, [ form, meta, results ] );
root.appendChild( header );
root.appendChild( header );
root.appendChild( body );
root.appendChild( body );

Revision as of 07:35, 17 June 2026

/*
 * Pixelmon spawn-chance calculator gadget.
 *
 * Mounts into <div id="spawn-calculator" data-species="..."> on a species page.
 * Features:
 *   1. Pick a situation (biome, time, weather, location type, Y-level) and see
 *      the chance that a single Better-Spawner selection is this species:
 *        P = sum(rarity of this species' matching spawns)
 *            / sum(rarity of all matching spawns)
 *   2. "Consecutive times": evaluate a block of N consecutive times of day
 *      (cyclic; 1..all). With N > 1 the chance is the per-time chance averaged
 *      over the block, weighting each time by its real in-game length so longer
 *      times (e.g. Night) count more than short ones (e.g. Midnight).
 *   3. "Find best chance" searches the situations (and, for N > 1, the best
 *      consecutive time block) giving the highest weighted chance.
 *
 * Data (every species' spawn entries) is provided by the companion
 * Gadget-spawncalc-data.js file as window.PixelmonSpawnData.
 *
 * This file is static UI/logic; it is NOT generated. The data file IS.
 */
( function () {
	'use strict';

	// Active in-game duration (ticks out of 24000) of each WorldTime, derived
	// from the Pixelmon WorldTime SEGMENTS partition. Used to weight a block of
	// consecutive times by relative length.
	var TIME_TICKS = {
		DAWN: 1800, MORNING: 7451, DAY: 12001, MIDDAY: 1001,
		AFTERNOON: 6000, DUSK: 1800, NIGHT: 10550, MIDNIGHT: 1001
	};

	function el( tag, attrs, children ) {
		var node = document.createElement( tag );
		attrs = attrs || {};
		Object.keys( attrs ).forEach( function ( k ) {
			if ( k === 'class' ) { node.className = attrs[ k ]; }
			else if ( k === 'text' ) { node.textContent = attrs[ k ]; }
			else { node.setAttribute( k, attrs[ k ] ); }
		} );
		( children || [] ).forEach( function ( c ) {
			node.appendChild( typeof c === 'string' ? document.createTextNode( c ) : c );
		} );
		return node;
	}

	// "minecraft:plains" -> "Plains", "#pixelmon:spawning/mesas" -> "Mesas".
	function prettyBiome( id ) {
		var s = id.charAt( 0 ) === '#' ? id.slice( 1 ) : id;
		if ( s.indexOf( ':' ) !== -1 ) { s = s.split( ':' )[ 1 ]; }
		if ( s.indexOf( '/' ) !== -1 ) { s = s.split( '/' ).pop(); }
		return s.replace( /_/g, ' ' ).replace( /\b\w/g, function ( c ) {
			return c.toUpperCase();
		} );
	}

	function titleCase( s ) {
		return s.charAt( 0 ) + s.slice( 1 ).toLowerCase();
	}

	function inList( list, v ) { return list && list.indexOf( v ) !== -1; }

	function build( root, data, species ) {
		var entries = data.entries;
		var speciesEntries = entries.filter( function ( e ) { return e.s === species; } );
		var NT = data.times.length;
		var timeLen = data.times.map( function ( name ) { return TIME_TICKS[ name ] || 1; } );

		// Pre-bucket entries by method id for the "find best" search.
		var byMethod = {};
		entries.forEach( function ( e ) {
			e.m.forEach( function ( m ) {
				( byMethod[ m ] = byMethod[ m ] || [] ).push( e );
			} );
		} );

		// ── situation match + probability ─────────────────────────────
		function matches( e, B, M, T, W, y ) {
			if ( !inList( e.m, M ) ) { return false; }
			if ( e.b && !inList( e.b, B ) ) { return false; }
			if ( e.t && !inList( e.t, T ) ) { return false; }
			if ( e.w && !inList( e.w, W ) ) { return false; }
			if ( y !== null ) {
				if ( e.y0 != null && y < e.y0 ) { return false; }
				if ( e.y1 != null && y > e.y1 ) { return false; }
			}
			return true;
		}

		function chance( B, M, T, W, y ) {
			var num = 0, den = 0, comp = 0;
			for ( var i = 0; i < entries.length; i++ ) {
				if ( matches( entries[ i ], B, M, T, W, y ) ) {
					den += entries[ i ].r;
					if ( entries[ i ].s === species ) { num += entries[ i ].r; }
					else { comp++; }
				}
			}
			return { num: num, den: den, p: den > 0 ? num / den : 0, competitors: comp };
		}

		// Cyclic block of n time indices starting at `start`.
		function blockTimes( start, n ) {
			n = Math.max( 1, Math.min( NT, n ) );
			var out = [];
			for ( var k = 0; k < n; k++ ) { out.push( ( start + k ) % NT ); }
			return out;
		}

		// Length-weighted average of per-time probabilities over a block.
		function blockWeighted( pByTime, times ) {
			var wsum = 0, acc = 0;
			for ( var i = 0; i < times.length; i++ ) {
				var t = times[ i ];
				wsum += timeLen[ t ];
				acc += timeLen[ t ] * pByTime[ t ];
			}
			return wsum > 0 ? acc / wsum : 0;
		}

		// Default Y inside an entry's range (else 63).
		function pickY( e ) {
			var y = 63;
			if ( e.y0 != null && y < e.y0 ) { y = e.y0; }
			if ( e.y1 != null && y > e.y1 ) { y = e.y1; }
			return y;
		}

		function round2( n ) { return Math.round( n * 100 ) / 100; }

		function fmtPct( p ) {
			if ( !( p > 0 ) ) { return '0%'; }
			var v = p * 100;
			return ( v >= 1 ? v.toFixed( 1 ) : v.toFixed( 2 ) ) + '%';
		}

		// ── controls ──────────────────────────────────────────────────
		var allTimes = data.times.map( function ( _t, i ) { return i; } );
		var allWeathers = data.weathers.map( function ( _w, i ) { return i; } );

		var biomeSel = el( 'select', { class: 'scalc-input' } );
		data.biomes
			.map( function ( id, i ) { return { i: i, name: prettyBiome( id ) }; } )
			.sort( function ( a, b ) { return a.name.localeCompare( b.name ); } )
			.forEach( function ( o ) {
				biomeSel.appendChild( el( 'option', { value: o.i, text: o.name } ) );
			} );

		var methodSel = el( 'select', { class: 'scalc-input' } );
		data.methods.forEach( function ( m, i ) {
			methodSel.appendChild( el( 'option', { value: i, text: m } ) );
		} );

		var timeSel = el( 'select', { class: 'scalc-input' } );
		data.times.forEach( function ( t, i ) {
			timeSel.appendChild( el( 'option', { value: i, text: titleCase( t ) } ) );
		} );

		var weatherSel = el( 'select', { class: 'scalc-input' } );
		data.weathers.forEach( function ( w, i ) {
			weatherSel.appendChild( el( 'option', { value: i, text: titleCase( w ) } ) );
		} );

		var consecSel = el( 'input', {
			type: 'number', min: '1', max: String( NT ), value: String( Math.min( 3, NT ) ),
			class: 'scalc-num',
			title: 'Evaluate a block of this many consecutive times of day, starting '
				+ 'at the chosen time. Each time is weighted by its real length, so '
				+ 'longer times (Night) count more than short ones (Midnight). Set to '
				+ NT + ' for any time.'
		} );

		var ySel = el( 'input', { type: 'number', min: '-64', max: '320', value: '63', class: 'scalc-num' } );

		function field( label, control ) {
			return el( 'div', { class: 'scalc-field' }, [
				el( 'label', { class: 'scalc-label', text: label } ), control
			] );
		}

		var bestBtn = el( 'button', { class: 'scalc-btn', type: 'button',
			text: 'Find best chance' } );

		// Actual in-game spawn conditions the player can stand in.
		var form = el( 'div', { class: 'scalc-form' }, [
			field( 'Biome', biomeSel ),
			field( 'Location type', methodSel ),
			field( 'Time of day', timeSel ),
			field( 'Weather', weatherSel ),
			field( 'Y-level', ySel ),
			el( 'div', { class: 'scalc-field scalc-field-btn' }, [
				el( 'span', { class: 'scalc-label', text: ' ' } ), bestBtn
			] )
		] );

		// Meta/analysis controls: not a spawn condition, they only change how the
		// chance is aggregated, so they live in their own slim section.
		var meta = el( 'div', { class: 'scalc-form scalc-meta' }, [
			el( 'span', { class: 'scalc-meta-label', text: 'Analysis' } ),
			field( 'Consecutive times', consecSel )
		] );

		var results = el( 'div', { class: 'scalc-results' } );

		function getInputs() {
			return {
				B: +biomeSel.value,
				M: +methodSel.value,
				T: +timeSel.value,
				W: +weatherSel.value,
				y: ySel.value === '' ? null : +ySel.value,
				N: Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) )
			};
		}

		var flagBest = false;

		function recompute() {
			results.innerHTML = '';
			if ( !speciesEntries.length ) {
				results.appendChild( el( 'div', { class: 'scalc-result scalc-result-miss' }, [
					el( 'span', { class: 'scalc-note', text:
						species + ' has no standard overworld spawns (legendary, boss, '
						+ 'fossil, evolution-only or event Pokemon).' } )
				] ) );
				flagBest = false;
				return;
			}
			var s = getInputs();
			var times = blockTimes( s.T, s.N );
			var single = times.length === 1;

			var p, detailText;
			// Per-time breakdown shown when a block spans more than one time.
			var perTime = null;
			if ( single ) {
				var res = chance( s.B, s.M, times[ 0 ], s.W, s.y );
				p = res.p;
				detailText = p > 0
					? '~1 in ' + Math.round( 1 / p ) + ' spawns - rarity '
						+ round2( res.num ) + ' of ' + round2( res.den ) + ' vs '
						+ res.competitors + ' competing spawn'
						+ ( res.competitors === 1 ? '' : 's' ) + '.'
					: '';
			} else {
				var p8 = [];
				for ( var t = 0; t < NT; t++ ) { p8.push( chance( s.B, s.M, t, s.W, s.y ).p ); }
				p = blockWeighted( p8, times );
				perTime = times.map( function ( ti ) {
					return { name: titleCase( data.times[ ti ] ), p: p8[ ti ] };
				} );
				detailText = p > 0
					? '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
						+ ( s.N >= NT ? 'any time' : s.N + ' consecutive times' )
						+ ' (per-time below).'
					: '';
			}

			var hit = p > 0;
			var pctText = fmtPct( hit ? p : 0 );

			var head = el( 'div', { class: 'scalc-result-head' }, [
				el( 'span', { class: 'scalc-pct', text: pctText } ),
				el( 'span', { class: 'scalc-pctlabel', text:
					'chance a wild spawn here is ' + species
					+ ( flagBest && hit ? ' (best found)' : '' ) } )
			] );
			var detail = hit
				? el( 'span', { class: 'scalc-detail', text: detailText } )
				: el( 'span', { class: 'scalc-note', text:
					'Does not spawn in this situation. Try "Find best chance" or '
					+ 'change the biome / location type / time.' } );

			var children = [ head, detail ];
			// One compact chip per time in the block, each labelled with its time
			// and that time's own chance, so multi-time blocks are not opaque.
			if ( hit && perTime ) {
				var chips = perTime.map( function ( pt ) {
					return el( 'span', { class: 'scalc-chip' }, [
						el( 'span', { class: 'scalc-chip-time', text: pt.name } ),
						el( 'span', { class: 'scalc-chip-pct', text: fmtPct( pt.p ) } )
					] );
				} );
				children.push( el( 'div', { class: 'scalc-breakdown' }, chips ) );
			}
			results.appendChild( el( 'div', {
				class: 'scalc-result ' + ( hit ? 'scalc-result-hit' : 'scalc-result-miss' )
				+ ( flagBest && hit ? ' scalc-result-best' : '' )
			}, children ) );
			flagBest = false;
		}

		// ── find best: situation (and, for N > 1, best time block) ──────
		function findBest() {
			var N = getInputs().N;
			var best = null;
			for ( var i = 0; i < speciesEntries.length; i++ ) {
				var se = speciesEntries[ i ];
				var y = pickY( se );
				var biomes = se.b || data.biomes.map( function ( _b, k ) { return k; } );
				var weathers = se.w || allWeathers;
				for ( var mi = 0; mi < se.m.length; mi++ ) {
					var M = se.m[ mi ];
					var pool = byMethod[ M ] || [];
					for ( var bi = 0; bi < biomes.length; bi++ ) {
						var B = biomes[ bi ];
						var sub = [];
						for ( var pj = 0; pj < pool.length; pj++ ) {
							var e = pool[ pj ];
							if ( e.b && !inList( e.b, B ) ) { continue; }
							if ( e.y0 != null && y < e.y0 ) { continue; }
							if ( e.y1 != null && y > e.y1 ) { continue; }
							sub.push( e );
						}
						for ( var wi = 0; wi < weathers.length; wi++ ) {
							var W = weathers[ wi ];
							// per-time num/den over this (B, M, W, y) subset in one pass
							var num = [], den = [];
							for ( var z = 0; z < NT; z++ ) { num.push( 0 ); den.push( 0 ); }
							for ( var k = 0; k < sub.length; k++ ) {
								var c = sub[ k ];
								if ( c.w && !inList( c.w, W ) ) { continue; }
								var ct = c.t || allTimes;
								for ( var ti = 0; ti < ct.length; ti++ ) {
									var tt = ct[ ti ];
									den[ tt ] += c.r;
									if ( c.s === species ) { num[ tt ] += c.r; }
								}
							}
							var p8 = [];
							for ( var q = 0; q < NT; q++ ) { p8.push( den[ q ] > 0 ? num[ q ] / den[ q ] : 0 ); }
							for ( var start = 0; start < NT; start++ ) {
								var times = blockTimes( start, N );
								var p = times.length === 1 ? p8[ times[ 0 ] ] : blockWeighted( p8, times );
								if ( p > 0 && ( !best || p > best.p ) ) {
									best = { B: B, M: M, W: W, y: y, start: start, p: p };
								}
								if ( N >= NT ) { break; }  // whole-day block: start is irrelevant
							}
						}
					}
				}
			}
			if ( !best ) { recompute(); return; }
			biomeSel.value = best.B;
			methodSel.value = best.M;
			weatherSel.value = best.W;
			ySel.value = best.y;
			timeSel.value = best.start;
			flagBest = true;
			recompute();
		}

		// Default the form to a representative situation where the species
		// spawns: prefer a common overworld method (Grass/Land/Water/...) and the
		// highest-rarity such entry, so the landing view is not a niche 100%
		// (e.g. a single Curry flavour).
		function setDefaults() {
			if ( !speciesEntries.length ) { return; }
			var common = {};
			[ 'Grass', 'Land', 'Water', 'Surface Water', 'Air', 'Tree Top',
			  'Underground', 'Seafloor' ].forEach( function ( name ) {
				var idx = data.methods.indexOf( name );
				if ( idx !== -1 ) { common[ idx ] = true; }
			} );
			var pick = null;
			speciesEntries.forEach( function ( e ) {
				var isCommon = e.m.some( function ( m ) { return common[ m ]; } );
				var score = ( isCommon ? 1e6 : 0 ) + e.r;
				if ( !pick || score > pick.score ) { pick = { e: e, score: score }; }
			} );
			var se = pick.e;
			var m0 = se.m.filter( function ( m ) { return common[ m ]; } )[ 0 ];
			methodSel.value = ( m0 != null ? m0 : se.m[ 0 ] );
			if ( se.b && se.b.length ) { biomeSel.value = se.b[ 0 ]; }
			if ( se.t && se.t.length ) { timeSel.value = se.t[ 0 ]; }
			ySel.value = pickY( se );
		}

		[ biomeSel, methodSel, timeSel, weatherSel, ySel, consecSel ].forEach( function ( c ) {
			c.addEventListener( 'change', recompute );
			c.addEventListener( 'input', recompute );
		} );
		bestBtn.addEventListener( 'click', findBest );

		// ── assemble the card (compact: header, controls row, result row) ──
		var header = el( 'div', { class: 'scalc-header' }, [
			el( 'span', { class: 'scalc-header-title', text: 'Spawn Chance Calculator' } )
		] );
		var body = el( 'div', { class: 'scalc-body' }, [ form, meta, results ] );
		root.appendChild( header );
		root.appendChild( body );
		setDefaults();
		recompute();
	}

	function init() {
		var root = document.getElementById( 'spawn-calculator' );
		if ( !root ) { return; }
		var data = window.PixelmonSpawnData;
		var species = root.getAttribute( 'data-species' );
		if ( !data || !data.entries || !species ) {
			root.textContent = 'Spawn calculator data failed to load.';
			return;
		}
		root.classList.add( 'scalc-ready' );
		root.innerHTML = '';
		build( root, data, species );
	}

	if ( document.readyState !== 'loading' ) { init(); }
	else { document.addEventListener( 'DOMContentLoaded', init ); }
}() );