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 371: Line 371:
var bestBtn = el( 'button', { class: 'scalc-btn', type: 'button', text: 'Find best chance' } );
var bestBtn = el( 'button', { class: 'scalc-btn', type: 'button', text: 'Find best chance' } );
var countSel = el( 'input', {
var countSel = el( 'input', {
type: 'number', min: '1', max: '50', value: '5', class: 'scalc-num',
type: 'number', min: '1', value: '5', class: 'scalc-num',
title: 'How many top areas to list when using Find best chance.'
title: 'How many top areas to list when using Find best chance.'
} );
} );
Line 384: Line 384:


function getN() { return Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) ); }
function getN() { return Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) ); }
function getResultCount() { return Math.max( 1, Math.min( 50, parseInt( countSel.value, 10 ) || 5 ) ); }
function getResultCount() { return Math.max( 1, parseInt( countSel.value, 10 ) || 5 ); }
function getBase() {
function getBase() {
return {
return {

Revision as of 20:22, 18 June 2026

/*
 * Pixelmon spawn-chance calculator gadget (full-page).
 *
 * Mounts into <div id="spawn-calculator" data-species="..."> on the standalone
 * Spawn Calculator page. data-species is normally empty; a ?species=Name query
 * parameter (the link on each species page) pre-fills the picker.
 *
 * Left column: pick a species and situation (biome, location type, time of day,
 * weather, Y-level, light level, moon phase). Only dimensions that constrain the
 * chosen species are shown, and each categorical filter lists only values where
 * the species can spawn. Each shown field can be locked so "Find best chance"
 * leaves it fixed. Find best chance lists the top areas; clicking one fills the
 * form and shows that area's full spawn breakdown on the right.
 *
 * Right column: the chosen species' chance, plus every species that can spawn in
 * the current situation with its share, the chosen species highlighted.
 *
 * Data is provided by Gadget-spawncalc-data.js as window.PixelmonSpawnData.
 * This file is static UI/logic; it is NOT generated. The data file IS.
 */
( function () {
	'use strict';

	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;
	}

	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 uniq( arr ) {
		var seen = {}, out = [];
		arr.forEach( function ( v ) { if ( !seen[ v ] ) { seen[ v ] = 1; out.push( v ); } } );
		return out;
	}
	function getParam( name ) {
		var m = new RegExp( '[?&]' + name + '=([^&#]*)' ).exec( location.search || '' );
		if ( !m ) { return null; }
		try { return decodeURIComponent( m[ 1 ].replace( /\+/g, ' ' ) ); } catch ( e ) { return m[ 1 ]; }
	}
	function articlePath() {
		return ( window.mw && mw.config && mw.config.get( 'wgArticlePath' ) ) || '/index.php/$1';
	}
	function pageHref( title ) {
		return articlePath().replace( '$1', encodeURIComponent( String( title ).replace( / /g, '_' ) ) );
	}
	function fileHref( filename ) {
		return articlePath().replace( '$1', 'Special:FilePath/' + encodeURIComponent( filename ) );
	}

	function build( root, data, initialSpecies ) {
		var entries = data.entries;
		var NT = data.times.length;
		var timeLen = data.times.map( function ( name ) { return TIME_TICKS[ name ] || 1; } );
		var allTimes = data.times.map( function ( _t, i ) { return i; } );
		var allWeathers = data.weathers.map( function ( _w, i ) { return i; } );

		var byMethod = {};
		entries.forEach( function ( e ) {
			e.m.forEach( function ( m ) { ( byMethod[ m ] = byMethod[ m ] || [] ).push( e ); } );
		} );

		var speciesSet = {}, speciesList = [];
		entries.forEach( function ( e ) {
			if ( !speciesSet[ e.s ] ) { speciesSet[ e.s ] = true; speciesList.push( e.s ); }
		} );
		speciesList.sort( function ( a, b ) { return a.localeCompare( b ); } );

		var species = initialSpecies || '';
		var allSpeciesEntries = [];   // every form of the chosen species
		var speciesEntries = [];      // form-filtered (drives relevance/options/optimizer)
		var forms = [], showForm = false, curForm = 'All';
		var rel = {};
		// A spawn entry's form matches the chosen form ('All' accepts any).
		function formOk( c ) { return curForm === 'All' || ( c.f || 'Normal' ) === curForm; }
		function applyForm() {
			speciesEntries = ( curForm === 'All' ) ? allSpeciesEntries.slice()
				: allSpeciesEntries.filter( function ( e ) { return ( e.f || 'Normal' ) === curForm; } );
			function any( fn ) { return speciesEntries.some( fn ); }
			rel = {
				time: any( function ( e ) { return e.t; } ),
				weather: any( function ( e ) { return e.w; } ),
				y: any( function ( e ) { return e.y0 != null || e.y1 != null; } ),
				light: any( function ( e ) { return e.l0 != null || e.l1 != null; } ),
				moon: any( function ( e ) { return e.mp != null; } ),
				struct: any( function ( e ) { return e.st; } )
			};
		}
		function refreshSpecies() {
			allSpeciesEntries = entries.filter( function ( e ) { return e.s === species; } );
			var seen = {}; forms = [];
			allSpeciesEntries.forEach( function ( e ) {
				var f = e.f || 'Normal';
				if ( !seen[ f ] ) { seen[ f ] = 1; forms.push( f ); }
			} );
			forms.sort( function ( a, b ) {
				if ( a === 'Normal' ) { return -1; }
				if ( b === 'Normal' ) { return 1; }
				return a.localeCompare( b );
			} );
			// Only offer a form picker when more than one form spawns naturally;
			// then include "All" to accept any form.
			showForm = forms.length > 1;
			fillSelect( formSel, ( showForm ? [ 'All' ].concat( forms ) : forms )
				.map( function ( f ) { return { v: f, t: f }; } ) );
			curForm = showForm ? 'All' : ( forms[ 0 ] || 'All' );
			formSel.value = curForm;
			applyForm();
		}

		// ── core probability ───────────────────────────────────────────
		function passes( c, base ) {
			if ( c.b && !inList( c.b, base.B ) ) { return false; }
			if ( base.y != null ) {
				if ( c.y0 != null && base.y < c.y0 ) { return false; }
				if ( c.y1 != null && base.y > c.y1 ) { return false; }
			}
			if ( base.light != null ) {
				if ( c.l0 != null && base.light < c.l0 ) { return false; }
				if ( c.l1 != null && base.light > c.l1 ) { return false; }
			}
			if ( base.moon != null && c.mp != null && base.moon !== c.mp ) { return false; }
			if ( base.W != null && c.w && !inList( c.w, base.W ) ) { return false; }
			// Structure-gated spawns only occur inside a matching structure; with
			// no structure chosen ("Outside") they are excluded entirely.
			if ( c.st && ( base.struct == null || !inList( c.st, base.struct ) ) ) { return false; }
			return true;
		}

		function chanceP8( base ) {
			var pool = byMethod[ base.M ] || [];
			var num = [], den = [];
			for ( var z = 0; z < NT; z++ ) { num.push( 0 ); den.push( 0 ); }
			for ( var i = 0; i < pool.length; i++ ) {
				var c = pool[ i ];
				if ( !passes( c, base ) ) { continue; }
				var ct = c.t || allTimes;
				for ( var j = 0; j < ct.length; j++ ) {
					den[ ct[ j ] ] += c.r;
					if ( c.s === species && formOk( c ) ) { num[ ct[ j ] ] += c.r; }
				}
			}
			var p8 = [];
			for ( var q = 0; q < NT; q++ ) { p8.push( den[ q ] > 0 ? num[ q ] / den[ q ] : 0 ); }
			return p8;
		}

		function chanceAt( base, t ) {
			var pool = byMethod[ base.M ] || [];
			var num = 0, den = 0, comp = 0;
			for ( var i = 0; i < pool.length; i++ ) {
				var c = pool[ i ];
				if ( !passes( c, base ) ) { continue; }
				if ( t != null && c.t && !inList( c.t, t ) ) { continue; }
				den += c.r;
				if ( c.s === species && formOk( c ) ) { num += c.r; } else { comp++; }
			}
			return { num: num, den: den, comp: comp, p: den > 0 ? num / den : 0 };
		}

		// Length-weighted share of every species that can spawn in this area over
		// the given block of times. Target share matches the headline chance.
		function spawnBreakdown( base, times ) {
			var pool = byMethod[ base.M ] || [];
			var totals = {}, sp = {};
			for ( var i = 0; i < pool.length; i++ ) {
				var c = pool[ i ];
				if ( !passes( c, base ) ) { continue; }
				var ct = c.t || allTimes;
				for ( var j = 0; j < ct.length; j++ ) {
					var t = ct[ j ];
					if ( times.indexOf( t ) === -1 ) { continue; }
					totals[ t ] = ( totals[ t ] || 0 ) + c.r;
					if ( c.s === species && !formOk( c ) ) { continue; }
					var m = sp[ c.s ] || ( sp[ c.s ] = {} );
					m[ t ] = ( m[ t ] || 0 ) + c.r;
				}
			}
			var wsum = 0;
			for ( var k = 0; k < times.length; k++ ) { wsum += timeLen[ times[ k ] ]; }
			var out = [];
			Object.keys( sp ).forEach( function ( name ) {
				var acc = 0;
				times.forEach( function ( t ) {
					if ( totals[ t ] > 0 ) { acc += timeLen[ t ] * ( ( sp[ name ][ t ] || 0 ) / totals[ t ] ); }
				} );
				out.push( { s: name, share: wsum > 0 ? acc / wsum : 0 } );
			} );
			out.sort( function ( a, b ) { return b.share - a.share; } );
			return out;
		}

		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;
		}
		function blockWeighted( p8, times ) {
			var wsum = 0, acc = 0;
			for ( var i = 0; i < times.length; i++ ) {
				wsum += timeLen[ times[ i ] ];
				acc += timeLen[ times[ i ] ] * p8[ times[ i ] ];
			}
			return wsum > 0 ? acc / wsum : 0;
		}
		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 biomeSel = el( 'select', { class: 'scalc-input' } );
		var methodSel = el( 'select', { class: 'scalc-input' } );
		var timeSel = el( 'select', { class: 'scalc-input' } );
		var weatherSel = el( 'select', { class: 'scalc-input' } );
		var ySel = el( 'input', { type: 'number', min: '-64', max: '320', value: '63', class: 'scalc-num' } );
		var lightSel = el( 'input', { type: 'number', min: '0', max: '15', value: '7', class: 'scalc-num' } );
		var moonSel = el( 'select', { class: 'scalc-input' } );
		var structSel = el( 'select', { class: 'scalc-input' } );
		var formSel = el( 'select', { class: 'scalc-input' } );

		function fillSelect( sel, items ) {
			sel.innerHTML = '';
			items.forEach( function ( o ) { sel.appendChild( el( 'option', { value: o.v, text: o.t } ) ); } );
		}
		function allBiomeIdx() { return data.biomes.map( function ( _b, i ) { return i; } ); }
		function relevantTimes() {
			if ( !speciesEntries.length || speciesEntries.some( function ( e ) { return !e.t; } ) ) { return allTimes; }
			var all = [];
			speciesEntries.forEach( function ( e ) { all = all.concat( e.t ); } );
			return uniq( all ).sort( function ( a, b ) { return a - b; } );
		}
		function relevantWeathers() {
			if ( !speciesEntries.length || speciesEntries.some( function ( e ) { return !e.w; } ) ) { return allWeathers; }
			var all = [];
			speciesEntries.forEach( function ( e ) { all = all.concat( e.w ); } );
			return uniq( all ).sort( function ( a, b ) { return a - b; } );
		}
		function relevantMoons() {
			if ( !speciesEntries.length || speciesEntries.some( function ( e ) { return e.mp == null; } ) ) {
				var r = []; for ( var i = 0; i < 8; i++ ) { r.push( i ); } return r;
			}
			var all = [];
			speciesEntries.forEach( function ( e ) { if ( e.mp != null ) { all.push( e.mp ); } } );
			return uniq( all ).sort( function ( a, b ) { return a - b; } );
		}
		function applyNumRange( input, loF, hiF, absLo, absHi ) {
			var los = [], his = [];
			speciesEntries.forEach( function ( e ) {
				if ( e[ loF ] != null ) { los.push( e[ loF ] ); }
				if ( e[ hiF ] != null ) { his.push( e[ hiF ] ); }
			} );
			input.min = String( los.length ? Math.min.apply( null, los ) : absLo );
			input.max = String( his.length ? Math.max.apply( null, his ) : absHi );
		}
		function repopulate() {
			var noSp = !speciesEntries.length;
			var biomeIdx = noSp ? allBiomeIdx() : biomeCandidates();
			fillSelect( biomeSel, biomeIdx
				.map( function ( i ) { return { v: i, t: prettyBiome( data.biomes[ i ] ) }; } )
				.sort( function ( a, b ) { return a.t.localeCompare( b.t ); } ) );
			var methodIdx = noSp ? data.methods.map( function ( _m, i ) { return i; } ) : methodCandidates();
			fillSelect( methodSel, methodIdx
				.map( function ( i ) { return { v: i, t: data.methods[ i ] }; } )
				.sort( function ( a, b ) { return a.t.localeCompare( b.t ); } ) );
			fillSelect( timeSel, relevantTimes().map( function ( i ) { return { v: i, t: titleCase( data.times[ i ] ) }; } ) );
			fillSelect( weatherSel, relevantWeathers().map( function ( i ) { return { v: i, t: titleCase( data.weathers[ i ] ) }; } ) );
			fillSelect( moonSel, relevantMoons().map( function ( i ) { return { v: i, t: 'Phase ' + i }; } ) );
			var sopts = [ { v: 'Outside', t: 'Outside (open)' } ];
			structCandidates().forEach( function ( i ) { sopts.push( { v: i, t: prettyBiome( data.structures[ i ] ) } ); } );
			fillSelect( structSel, sopts );
			applyNumRange( ySel, 'y0', 'y1', -64, 320 );
			applyNumRange( lightSel, 'l0', 'l1', 0, 15 );
		}

		var consecSel = el( 'input', {
			type: 'number', min: '1', max: String( NT ), value: '1', class: 'scalc-num',
			title: 'Average a block of this many consecutive times of day, weighting '
				+ 'each by its real length. Set to ' + NT + ' for any time.'
		} );
		var strictChk = el( 'input', { type: 'checkbox', class: 'scalc-check', checked: 'checked' } );

		function lockBox() {
			return el( 'input', { type: 'checkbox', class: 'scalc-lock-chk',
				title: 'Lock: keep this value when using Find best chance.' } );
		}
		var lockBiome = lockBox(), lockMethod = lockBox(), lockTime = lockBox(),
			lockWeather = lockBox(), lockY = lockBox(), lockLight = lockBox(), lockMoon = lockBox(),
			lockStruct = lockBox();

		function field( labelText, control, lock ) {
			var lab = el( 'label', { class: 'scalc-label' }, [ document.createTextNode( labelText ) ] );
			if ( lock ) {
				lab.appendChild( el( 'label', { class: 'scalc-lock' }, [ lock, document.createTextNode( ' lock' ) ] ) );
			}
			return el( 'div', { class: 'scalc-field' }, [ lab, control ] );
		}

		var speciesInput = el( 'input', {
			class: 'scalc-input scalc-species', list: 'scalc-species-list',
			placeholder: 'Type a Pokemon name', autocomplete: 'off'
		} );
		var speciesDl = el( 'datalist', { id: 'scalc-species-list' } );
		speciesList.forEach( function ( n ) { speciesDl.appendChild( el( 'option', { value: n } ) ); } );
		function onSpeciesPick() { if ( speciesSet[ speciesInput.value ] ) { setSpecies( speciesInput.value ); } }
		speciesInput.addEventListener( 'change', onSpeciesPick );
		speciesInput.addEventListener( 'input', onSpeciesPick );
		var fSpecies = el( 'div', { class: 'scalc-field scalc-field-species' }, [
			el( 'label', { class: 'scalc-label', text: 'Pokemon' } ), speciesInput, speciesDl
		] );

		var fForm = field( 'Form', formSel );
		formSel.addEventListener( 'change', function () { setForm( formSel.value ); } );

		var fBiome = field( 'Biome', biomeSel, lockBiome );
		var fMethod = field( 'Location type', methodSel, lockMethod );
		var fTime = field( 'Time of day', timeSel, lockTime );
		var fWeather = field( 'Weather', weatherSel, lockWeather );
		var fY = field( 'Y-level', ySel, lockY );
		var fLight = field( 'Light level', lightSel, lockLight );
		var fMoon = field( 'Moon phase', moonSel, lockMoon );
		var fStruct = field( 'Structure', structSel, lockStruct );

		var formFields = [];
		if ( !initialSpecies ) { formFields.push( fSpecies ); }
		formFields.push( fForm, fBiome, fMethod, fTime, fWeather, fY, fLight, fMoon, fStruct );
		var form = el( 'div', { class: 'scalc-form' }, formFields );

		var meta = el( 'div', { class: 'scalc-form scalc-meta' }, [
			el( 'span', { class: 'scalc-meta-label', text: 'Time block' } ),
			field( 'Consecutive times', consecSel ),
			el( 'div', { class: 'scalc-field' }, [
				el( 'label', { class: 'scalc-label', text: 'Strict block' } ),
				el( 'label', { class: 'scalc-check-wrap',
					title: 'Only accept blocks where the species spawns in every time. '
						+ 'If none exists, the block size is reduced and you are warned.' }, [
					strictChk, el( 'span', { class: 'scalc-check-text', text: 'all times must spawn' } )
				] )
			] )
		] );

		var bestBtn = el( 'button', { class: 'scalc-btn', type: 'button', text: 'Find best chance' } );
		var countSel = el( 'input', {
			type: 'number', min: '1', value: '5', class: 'scalc-num',
			title: 'How many top areas to list when using Find best chance.'
		} );
		var actions = el( 'div', { class: 'scalc-actions' }, [
			bestBtn,
			el( 'label', { class: 'scalc-count-wrap' }, [
				el( 'span', { class: 'scalc-count-label', text: 'Results' } ), countSel
			] )
		] );
		var topWrap = el( 'div', { class: 'scalc-top' } );
		var results = el( 'div', { class: 'scalc-results' } );

		function getN() { return Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) ); }
		function getResultCount() { return Math.max( 1, parseInt( countSel.value, 10 ) || 5 ); }
		function getBase() {
			return {
				B: +biomeSel.value,
				M: +methodSel.value,
				W: rel.weather ? +weatherSel.value : null,
				y: rel.y ? ( ySel.value === '' ? null : +ySel.value ) : null,
				light: rel.light ? ( lightSel.value === '' ? null : +lightSel.value ) : null,
				moon: rel.moon ? +moonSel.value : null,
				struct: rel.struct && structSel.value !== 'Outside' && structSel.value !== ''
					? +structSel.value : null
			};
		}
		function structCandidates() {
			var all = [];
			speciesEntries.forEach( function ( e ) { if ( e.st ) { all = all.concat( e.st ); } } );
			return uniq( all );
		}
		function curTimes() { return rel.time ? blockTimes( +timeSel.value, getN() ) : allTimes; }

		function applyVisibility() {
			fForm.style.display = showForm ? '' : 'none';
			fTime.style.display = rel.time ? '' : 'none';
			fWeather.style.display = rel.weather ? '' : 'none';
			fY.style.display = rel.y ? '' : 'none';
			fLight.style.display = rel.light ? '' : 'none';
			fMoon.style.display = rel.moon ? '' : 'none';
			fStruct.style.display = rel.struct ? '' : 'none';
			meta.style.display = rel.time ? '' : 'none';
		}

		var flagBest = false, warnText = null, topAreas = [], activeArea = null, selectedTime = null, topN = 1;

		function areaLabel( area ) {
			var b = area.base;
			var parts = [ prettyBiome( data.biomes[ b.B ] ), data.methods[ b.M ] ];
			if ( rel.time && area.start != null ) {
				parts.push( titleCase( data.times[ area.start ] ) + ( topN > 1 ? ' +' + ( topN - 1 ) : '' ) );
			}
			if ( rel.weather && b.W != null ) { parts.push( titleCase( data.weathers[ b.W ] ) ); }
			if ( rel.y && b.y != null ) { parts.push( 'Y ' + b.y ); }
			if ( rel.light && b.light != null ) { parts.push( 'Light ' + b.light ); }
			if ( rel.moon && b.moon != null ) { parts.push( 'Moon ' + b.moon ); }
			if ( rel.struct ) { parts.push( b.struct != null ? prettyBiome( data.structures[ b.struct ] ) : 'Outside' ); }
			return parts.join( ' / ' );
		}

		function renderTop() {
			topWrap.innerHTML = '';
			if ( !topAreas.length ) { return; }
			topWrap.appendChild( el( 'div', { class: 'scalc-top-title', text: 'Top areas (click to view)' } ) );
			topAreas.forEach( function ( area, idx ) {
				var item = el( 'div', {
					class: 'scalc-top-item' + ( activeArea === area ? ' scalc-top-active' : '' )
				}, [
					el( 'span', { class: 'scalc-top-rank', text: '#' + ( idx + 1 ) } ),
					el( 'span', { class: 'scalc-top-pct', text: fmtPct( area.p ) } ),
					el( 'span', { class: 'scalc-top-params', text: areaLabel( area ) } )
				] );
				item.addEventListener( 'click', function () { applyArea( area ); } );
				topWrap.appendChild( item );
			} );
		}

		function recompute() {
			results.innerHTML = '';
			if ( !species ) {
				results.appendChild( el( 'div', { class: 'scalc-result' }, [
					el( 'span', { class: 'scalc-note', text: 'Choose a Pokemon to see its spawn chances.' } )
				] ) );
				flagBest = false; warnText = null; return;
			}
			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; warnText = null; return;
			}

			var base = getBase();
			var N = getN();
			var p8 = [];
			for ( var t = 0; t < NT; t++ ) { p8.push( chanceAt( base, t ).p ); }
			var times = rel.time ? blockTimes( +timeSel.value, N ) : allTimes;
			var showBreak = rel.time && times.length > 1;
			var single = rel.time && times.length === 1;
			var p = times.length === 1 ? p8[ times[ 0 ] ] : blockWeighted( p8, times );

			var detailText;
			if ( single ) {
				var res = chanceAt( base, times[ 0 ] );
				detailText = '~1 in ' + Math.round( 1 / res.p ) + ' spawns - rarity '
					+ round2( res.num ) + ' of ' + round2( res.den ) + ' vs ' + res.comp
					+ ' competing spawn' + ( res.comp === 1 ? '' : 's' ) + '.';
			} else {
				detailText = '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
					+ ( !rel.time ? 'any time (this Pokemon ignores time of day)'
						: ( N >= NT ? 'any time' : N + ' consecutive times' ) ) + '.';
			}

			var hit = p > 0;
			var head = el( 'div', { class: 'scalc-result-head' }, [
				el( 'span', { class: 'scalc-pct', text: fmtPct( hit ? p : 0 ) } ),
				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 = [];
			if ( warnText ) { children.push( el( 'div', { class: 'scalc-warn', text: warnText } ) ); }
			children.push( head, detail );

			// Which time the breakdown shows: a single block time (chip-selected,
			// defaulting to the highest-chance time) or the whole block / day.
			var bdTimes = times, bdLabel = null;
			if ( hit && showBreak ) {
				if ( selectedTime == null || times.indexOf( selectedTime ) === -1 ) {
					selectedTime = times[ 0 ];
					times.forEach( function ( t ) { if ( p8[ t ] > p8[ selectedTime ] ) { selectedTime = t; } } );
				}
				bdTimes = [ selectedTime ];
				bdLabel = titleCase( data.times[ selectedTime ] );
				var deadCount = 0;
				var chips = times.map( function ( ti ) {
					var pv = p8[ ti ], dead = !( pv > 0 );
					if ( dead ) { deadCount++; }
					var chip = el( 'span', {
						class: 'scalc-chip scalc-chip-click' + ( dead ? ' scalc-chip-dead' : '' )
							+ ( ti === selectedTime ? ' scalc-chip-active' : '' ),
						title: 'Show spawns during ' + titleCase( data.times[ ti ] )
					}, [
						el( 'span', { class: 'scalc-chip-time', text: titleCase( data.times[ ti ] ) } ),
						el( 'span', { class: 'scalc-chip-pct', text: fmtPct( pv ) } )
					] );
					chip.addEventListener( 'click', function () { selectedTime = ti; recompute(); } );
					return chip;
				} );
				children.push( el( 'div', { class: 'scalc-breakdown' }, chips ) );
				if ( deadCount > 0 ) {
					children.push( el( 'div', { class: 'scalc-note', text:
						deadCount + ' of ' + times.length + ' times have no ' + species
						+ ' spawns here' + ( strictChk.checked ? ''
							: '; enable "Strict block" to make the optimizer avoid them' ) + '.' } ) );
				}
			} else if ( single ) {
				bdLabel = titleCase( data.times[ times[ 0 ] ] );
			} else if ( !rel.time ) {
				bdLabel = 'any time';
			}
			results.appendChild( el( 'div', {
				class: 'scalc-result ' + ( hit ? 'scalc-result-hit' : 'scalc-result-miss' )
				+ ( flagBest && hit ? ' scalc-result-best' : '' )
			}, children ) );

			// Full spawn breakdown for the selected time (or whole block / day).
			var bd = spawnBreakdown( base, bdTimes );
			var card = el( 'div', { class: 'scalc-bd' }, [
				el( 'div', { class: 'scalc-bd-title',
					text: 'Spawns in this area' + ( bdLabel ? ' (' + bdLabel + ')' : '' ) } )
			] );
			if ( !bd.length ) {
				card.appendChild( el( 'div', { class: 'scalc-note', text: 'Nothing spawns here.' } ) );
			} else {
				var list = el( 'div', { class: 'scalc-bd-list' } );
				bd.slice( 0, 60 ).forEach( function ( r ) {
					var isT = r.s === species;
					var left = el( 'span', { class: 'scalc-bd-left' } );
					var spriteFile = data.sprites && data.sprites[ r.s ];
					if ( spriteFile ) {
						left.appendChild( el( 'img', { class: 'scalc-bd-img',
							src: fileHref( spriteFile ), alt: r.s, loading: 'lazy' } ) );
					}
					left.appendChild( el( 'a', { class: 'scalc-bd-name', href: pageHref( r.s ),
						title: r.s }, [ document.createTextNode( r.s ) ] ) );
					list.appendChild( el( 'div', { class: 'scalc-bd-row' + ( isT ? ' scalc-bd-target' : '' ) }, [
						left,
						el( 'span', { class: 'scalc-bd-pct', text: fmtPct( r.share ) } )
					] ) );
				} );
				card.appendChild( list );
				if ( bd.length > 60 ) {
					card.appendChild( el( 'div', { class: 'scalc-note', text: 'and ' + ( bd.length - 60 ) + ' more.' } ) );
				}
			}
			results.appendChild( card );

			flagBest = false; warnText = null;
		}

		// ── optimizer ────────────────────────────────────────────────────
		function biomeCandidates() {
			if ( speciesEntries.some( function ( e ) { return !e.b; } ) ) { return allBiomeIdx(); }
			var all = [];
			speciesEntries.forEach( function ( e ) { if ( e.b ) { all = all.concat( e.b ); } } );
			return uniq( all );
		}
		function methodCandidates() {
			var all = [];
			speciesEntries.forEach( function ( e ) { all = all.concat( e.m ); } );
			return uniq( all );
		}
		function numCandidates( lo, hi, extra ) {
			var vals = [];
			speciesEntries.forEach( function ( e ) {
				if ( e[ lo ] != null ) { vals.push( e[ lo ] ); }
				if ( e[ hi ] != null ) { vals.push( e[ hi ] ); }
			} );
			( extra || [] ).forEach( function ( v ) { vals.push( v ); } );
			return uniq( vals );
		}
		function moonCandidatesList() {
			var vals = [];
			speciesEntries.forEach( function ( e ) { if ( e.mp != null ) { vals.push( e.mp ); } } );
			return uniq( vals );
		}
		function placeKey( b ) { return [ b.B, b.M, b.W, b.y, b.light, b.moon ].join( '|' ); }

		function applyArea( area ) {
			var b = area.base;
			biomeSel.value = b.B;
			methodSel.value = b.M;
			if ( rel.weather && b.W != null ) { weatherSel.value = b.W; }
			if ( rel.y && b.y != null ) { ySel.value = b.y; }
			if ( rel.light && b.light != null ) { lightSel.value = b.light; }
			if ( rel.moon && b.moon != null ) { moonSel.value = b.moon; }
			if ( rel.struct ) { structSel.value = ( b.struct != null ? b.struct : 'Outside' ); }
			if ( rel.time && area.start != null ) { timeSel.value = area.start; }
			activeArea = area;
			selectedTime = null;
			flagBest = true;
			recompute();
			renderTop();
		}

		function findBest() {
			if ( !species || !speciesEntries.length ) { recompute(); return; }
			var N = getN(), strict = strictChk.checked, lockT = lockTime.checked, curT = +timeSel.value;

			var biomeC = lockBiome.checked ? [ +biomeSel.value ] : biomeCandidates();
			var methodC = lockMethod.checked ? [ +methodSel.value ] : methodCandidates();
			var weatherC = !rel.weather ? [ null ] : ( lockWeather.checked ? [ +weatherSel.value ] : allWeathers );
			var yC = !rel.y ? [ null ] : ( lockY.checked ? [ +ySel.value ] : numCandidates( 'y0', 'y1', [ 63 ] ) );
			var lightC = !rel.light ? [ null ] : ( lockLight.checked ? [ +lightSel.value ] : numCandidates( 'l0', 'l1', [ 0, 7, 15 ] ) );
			var moonC = !rel.moon ? [ null ] : ( lockMoon.checked ? [ +moonSel.value ] : moonCandidatesList() );
			var curStruct = ( structSel.value === 'Outside' || structSel.value === '' ? null : +structSel.value );
			var hasOpen = speciesEntries.some( function ( e ) { return !e.st; } );
			var structC = !rel.struct ? [ null ]
				: ( lockStruct.checked ? [ curStruct ]
					: ( hasOpen ? [ null ] : [] ).concat( structCandidates() ) );

			var situations = [];
			for ( var a = 0; a < biomeC.length; a++ ) {
				for ( var b = 0; b < methodC.length; b++ ) {
					for ( var c = 0; c < weatherC.length; c++ ) {
						for ( var d = 0; d < yC.length; d++ ) {
							for ( var f = 0; f < lightC.length; f++ ) {
								for ( var g = 0; g < moonC.length; g++ ) {
									for ( var h = 0; h < structC.length; h++ ) {
										var base = { B: biomeC[ a ], M: methodC[ b ], W: weatherC[ c ],
											y: yC[ d ], light: lightC[ f ], moon: moonC[ g ], struct: structC[ h ] };
										situations.push( { base: base, p8: chanceP8( base ) } );
									}
								}
							}
						}
					}
				}
			}

			// Best block per distinct area (place) for block size n.
			function rankAt( n, strictFlag ) {
				var byPlace = {};
				for ( var i = 0; i < situations.length; i++ ) {
					var s = situations[ i ], p8 = s.p8, key = placeKey( s.base );
					if ( !rel.time ) {
						var pw = blockWeighted( p8, allTimes );
						if ( pw > 0 && ( !byPlace[ key ] || pw > byPlace[ key ].p ) ) {
							byPlace[ key ] = { base: s.base, start: null, p: pw };
						}
						continue;
					}
					var starts = lockT ? [ curT ] : allTimes;
					for ( var si = 0; si < starts.length; si++ ) {
						var start = starts[ si ];
						var times = blockTimes( start, n );
						var allSpawn = true;
						for ( var j = 0; j < times.length; j++ ) {
							if ( !( p8[ times[ j ] ] > 0 ) ) { allSpawn = false; break; }
						}
						if ( strictFlag && !allSpawn ) { if ( n >= NT ) { break; } continue; }
						var p = times.length === 1 ? p8[ times[ 0 ] ] : blockWeighted( p8, times );
						if ( p > 0 && ( !byPlace[ key ] || p > byPlace[ key ].p ) ) {
							byPlace[ key ] = { base: s.base, start: start, p: p };
						}
						if ( n >= NT ) { break; }
					}
				}
				return Object.keys( byPlace ).map( function ( k ) { return byPlace[ k ]; } )
					.sort( function ( x, y ) { return y.p - x.p; } );
			}

			var ranked = [], effN = N;
			if ( !rel.time ) {
				ranked = rankAt( N, false );
			} else if ( strict ) {
				for ( var n = N; n >= 1; n-- ) {
					ranked = rankAt( n, true );
					if ( ranked.length ) { effN = n; break; }
				}
			} else {
				ranked = rankAt( N, false );
			}
			if ( !ranked.length ) { topAreas = []; renderTop(); recompute(); return; }

			topAreas = ranked.slice( 0, getResultCount() );
			topN = effN;
			if ( rel.time && strict && effN < N ) {
				consecSel.value = effN;
				warnText = species + ' spawns in at most ' + effN + ' consecutive time'
					+ ( effN === 1 ? '' : 's' ) + ' in any situation, so the block was reduced from '
					+ N + ' to ' + effN + '.';
			}
			applyArea( topAreas[ 0 ] );
		}

		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 );
			if ( rel.light ) { lightSel.value = ( se.l0 != null ? se.l0 : ( se.l1 != null ? se.l1 : 7 ) ); }
			if ( rel.moon && se.mp != null ) { moonSel.value = se.mp; }
			if ( rel.struct ) {
				var hasOpen = speciesEntries.some( function ( e ) { return !e.st; } );
				if ( hasOpen ) { structSel.value = 'Outside'; }
				else { var sc = structCandidates(); if ( sc.length ) { structSel.value = sc[ 0 ]; } }
			}
		}

		function setSpecies( name ) {
			species = name;
			topAreas = []; activeArea = null; selectedTime = null;
			refreshSpecies();
			repopulate();
			applyVisibility();
			renderTop();
			setDefaults();
			recompute();
		}

		function setForm( f ) {
			curForm = f;
			topAreas = []; activeArea = null; selectedTime = null;
			applyForm();
			repopulate();
			applyVisibility();
			renderTop();
			setDefaults();
			recompute();
		}

		// A manual control change deselects the chosen top area (its conditions no
		// longer match) but keeps the list available to re-click.
		function userChanged() {
			selectedTime = null;
			if ( activeArea ) { activeArea = null; renderTop(); }
			recompute();
		}
		[ biomeSel, methodSel, timeSel, weatherSel, ySel, lightSel, moonSel, structSel, consecSel ]
			.forEach( function ( ctl ) {
				ctl.addEventListener( 'change', userChanged );
				ctl.addEventListener( 'input', userChanged );
			} );
		bestBtn.addEventListener( 'click', findBest );

		// ── assemble (two columns) ───────────────────────────────────────
		var header = el( 'div', { class: 'scalc-header' }, [
			el( 'span', { class: 'scalc-header-title', text: 'Spawn Chance Calculator' } )
		] );
		var leftCol = el( 'div', { class: 'scalc-left' }, [ form, meta, actions, topWrap ] );
		var rightCol = el( 'div', { class: 'scalc-right' }, [ results ] );
		var body = el( 'div', { class: 'scalc-body scalc-cols' }, [ leftCol, rightCol ] );
		root.appendChild( header );
		root.appendChild( body );

		refreshSpecies();
		repopulate();
		applyVisibility();
		setDefaults();

		if ( !initialSpecies ) {
			var pre = getParam( 'species' );
			if ( pre && speciesSet[ pre ] ) { speciesInput.value = pre; setSpecies( pre ); }
		}
		recompute();
	}

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

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