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 3: Line 3:
  *
  *
  * Mounts into <div id="spawn-calculator" data-species="..."> on a species page.
  * Mounts into <div id="spawn-calculator" data-species="..."> on a species page.
  * Two features:
  * Features:
  *  1. Pick a situation (biome, time, weather, location type, Y-level) and see
  *  1. Pick a situation (biome, time, weather, location type, Y-level) and see
  *      the chance that a single Better-Spawner selection is this species:
  *      the chance that a single Better-Spawner selection is this species:
  *        P = sum(rarity of this species' matching spawns)
  *        P = sum(rarity of this species' matching spawns)
  *            / sum(rarity of all matching spawns)
  *            / sum(rarity of all matching spawns)
  *  2. "Find best chance" searches the situations where this species can spawn
  *  2. "Consecutive times": evaluate a block of N consecutive times of day
  *      for the combination giving the highest P, and fills the form with it.
*      (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
  * Data (every species' spawn entries) is provided by the companion
Line 18: Line 22:
( function () {
( function () {
'use strict';
'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 ) {
function el( tag, attrs, children ) {
Line 52: Line 64:
var entries = data.entries;
var entries = data.entries;
var speciesEntries = entries.filter( function ( e ) { return e.s === species; } );
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.
// Pre-bucket entries by method id for the "find best" search.
Line 84: Line 98:
}
}
return { num: num, den: den, p: den > 0 ? num / den : 0, competitors: 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;
}
}


Line 121: Line 154:
data.weathers.forEach( function ( w, i ) {
data.weathers.forEach( function ( w, i ) {
weatherSel.appendChild( el( 'option', { value: i, text: titleCase( w ) } ) );
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.'
} );
} );


Line 138: Line 180:
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 ),
el( 'div', { class: 'scalc-field scalc-field-btn' }, [
el( 'div', { class: 'scalc-field scalc-field-btn' }, [
el( 'span', { class: 'scalc-label', text: ' ' } ), bestBtn
el( 'span', { class: 'scalc-label', text: ' ' } ), bestBtn
] )
] )
] );
] );
Line 153: Line 196:
T: +timeSel.value,
T: +timeSel.value,
W: +weatherSel.value,
W: +weatherSel.value,
y: ySel.value === '' ? null : +ySel.value
y: ySel.value === '' ? null : +ySel.value,
N: Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) )
};
};
}
}
Line 171: Line 215:
}
}
var s = getInputs();
var s = getInputs();
var res = chance( s.B, s.M, s.T, s.W, s.y );
var times = blockTimes( s.T, s.N );
var hit = res.p > 0;
var single = times.length === 1;
 
var p, detailText;
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 );
var names = times.map( function ( ti ) { return titleCase( data.times[ ti ] ); } );
detailText = p > 0
? '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
+ ( s.N >= NT ? 'any time' : names.join( ', ' ) ) + '.'
: '';
}
 
var hit = p > 0;
var pctText = !hit ? '0%'
var pctText = !hit ? '0%'
: ( res.p * 100 >= 1 ? ( res.p * 100 ).toFixed( 1 ) : ( res.p * 100 ).toFixed( 2 ) ) + '%';
: ( 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 183: Line 250:
] );
] );
var detail = hit
var detail = hit
? el( 'span', { class: 'scalc-detail', text:
? el( 'span', { class: 'scalc-detail', text: detailText } )
'~1 in ' + Math.round( 1 / res.p ) + ' spawns - rarity '
+ round2( res.num ) + ' of ' + round2( res.den ) + ' vs '
+ res.competitors + ' competing spawn'
+ ( res.competitors === 1 ? '' : 's' ) + '.' } )
: el( 'span', { class: 'scalc-note', text:
: el( 'span', { class: 'scalc-note', text:
'Does not spawn in this situation. Try "Find best chance" or '
'Does not spawn in this situation. Try "Find best chance" or '
Line 198: Line 261:
}
}


// ── feature 2: search the species' situations for the best P ────
// ── find best: situation (and, for N > 1, best time block) ──────
function findBest() {
function findBest() {
var N = getInputs().N;
var best = null;
var best = null;
for ( var i = 0; i < speciesEntries.length; i++ ) {
for ( var i = 0; i < speciesEntries.length; i++ ) {
Line 205: Line 269:
var y = pickY( se );
var y = pickY( se );
var biomes = se.b || data.biomes.map( function ( _b, k ) { return k; } );
var biomes = se.b || data.biomes.map( function ( _b, k ) { return k; } );
var times = se.t || allTimes;
var weathers = se.w || allWeathers;
var weathers = se.w || allWeathers;
for ( var mi = 0; mi < se.m.length; mi++ ) {
for ( var mi = 0; mi < se.m.length; mi++ ) {
Line 212: Line 275:
for ( var bi = 0; bi < biomes.length; bi++ ) {
for ( var bi = 0; bi < biomes.length; bi++ ) {
var B = biomes[ bi ];
var B = biomes[ bi ];
// biome- and y-filtered competitor subset for this (B, M)
var sub = [];
var sub = [];
for ( var pj = 0; pj < pool.length; pj++ ) {
for ( var pj = 0; pj < pool.length; pj++ ) {
Line 221: Line 283:
sub.push( e );
sub.push( e );
}
}
for ( var ti = 0; ti < times.length; ti++ ) {
for ( var wi = 0; wi < weathers.length; wi++ ) {
var T = times[ ti ];
var W = weathers[ wi ];
for ( var wi = 0; wi < weathers.length; wi++ ) {
// per-time num/den over this (B, M, W, y) subset in one pass
var W = weathers[ wi ];
var num = [], den = [];
var num = 0, den = 0;
for ( var z = 0; z < NT; z++ ) { num.push( 0 ); den.push( 0 ); }
for ( var k = 0; k < sub.length; k++ ) {
for ( var k = 0; k < sub.length; k++ ) {
var c = sub[ k ];
var c = sub[ k ];
if ( c.t && !inList( c.t, T ) ) { continue; }
if ( c.w && !inList( c.w, W ) ) { continue; }
if ( c.w && !inList( c.w, W ) ) { continue; }
var ct = c.t || allTimes;
den += c.r;
for ( var ti = 0; ti < ct.length; ti++ ) {
if ( c.s === species ) { num += c.r; }
var tt = ct[ ti ];
den[ tt ] += c.r;
if ( c.s === species ) { num[ tt ] += c.r; }
}
}
var p = den > 0 ? num / den : 0;
}
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 ) ) {
if ( p > 0 && ( !best || p > best.p ) ) {
best = { B: B, M: M, T: T, W: W, y: y, p: p };
best = { B: B, M: M, W: W, y: y, start: start, p: p };
}
}
if ( N >= NT ) { break; }  // whole-day block: start is irrelevant
}
}
}
}
Line 245: Line 315:
biomeSel.value = best.B;
biomeSel.value = best.B;
methodSel.value = best.M;
methodSel.value = best.M;
timeSel.value = best.T;
weatherSel.value = best.W;
weatherSel.value = best.W;
ySel.value = best.y;
ySel.value = best.y;
timeSel.value = best.start;
flagBest = true;
flagBest = true;
recompute();
recompute();
Line 278: Line 348:
}
}


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

Revision as of 07:24, 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; }

		// ── 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' } );

		var form = el( 'div', { class: 'scalc-form' }, [
			field( 'Biome', biomeSel ),
			field( 'Location type', methodSel ),
			field( 'Time of day', timeSel ),
			field( 'Consecutive times', consecSel ),
			field( 'Weather', weatherSel ),
			field( 'Y-level', ySel ),
			el( 'div', { class: 'scalc-field scalc-field-btn' }, [
				el( 'span', { class: 'scalc-label', text: ' ' } ), bestBtn
			] )
		] );

		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;
			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 );
				var names = times.map( function ( ti ) { return titleCase( data.times[ ti ] ); } );
				detailText = p > 0
					? '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
						+ ( s.N >= NT ? 'any time' : names.join( ', ' ) ) + '.'
					: '';
			}

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

			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.' } );
			results.appendChild( el( 'div', {
				class: 'scalc-result ' + ( hit ? 'scalc-result-hit' : 'scalc-result-miss' )
				+ ( flagBest && hit ? ' scalc-result-best' : '' )
			}, [ head, detail ] ) );
			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, 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 ); }
}() );