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 1: Line 1:
/*
/*
  * Pixelmon spawn-chance calculator gadget.
  * Pixelmon spawn-chance calculator gadget (full-page).
  *
  *
  * Mounts into <div id="spawn-calculator" data-species="..."> on a species page.
  * Mounts into <div id="spawn-calculator" data-species="..."> on the standalone
  * If data-species is empty (the standalone Spawn Calculator page), the gadget
  * Spawn Calculator page. data-species is normally empty there; a ?species=Name
* shows a Pokemon picker so any species can be chosen.
* query parameter (used by the link on each species page) pre-fills the picker.
  *
  *
  * Features:
  * For a chosen species and situation it shows the chance that a single
*  1. Pick a situation (biome, time, weather, location type, Y-level) and see
* Better-Spawner selection is that 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. "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). Times in
*      the block where the species does not spawn (0%) are flagged.
*  3. "Find best chance" searches the situations (and, for N > 1, the best
*      consecutive time block) giving the highest weighted chance. With "Strict
*      block" on, only blocks where the species spawns in every time count; if
*      no full block exists the optimizer reduces N and warns the user.
  *
  *
  * Data (every species' spawn entries) is provided by the companion
  * Dimensions: biome, location type, time of day, weather, Y-level, light level,
  * Gadget-spawncalc-data.js file as window.PixelmonSpawnData.
* moon phase. Only the dimensions that actually constrain the chosen species are
* shown. Each shown field can be locked so "Find best chance" leaves it fixed.
* "Consecutive times" averages a cyclic block of times of day, weighting each by
* its real in-game length; with "Strict block" the optimizer only accepts blocks
  * where the species spawns in every time, otherwise it reduces the block size.
  *
  *
* 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.
  * This file is static UI/logic; it is NOT generated. The data file IS.
  */
  */
Line 29: Line 24:
'use strict';
'use strict';


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


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


function titleCase( s ) {
function titleCase( s ) { return s.charAt( 0 ) + s.slice( 1 ).toLowerCase(); }
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 inList( list, v ) { return list && list.indexOf( v ) !== -1; }
function getParam( name ) {
var q = location.search || '';
var m = new RegExp( '[?&]' + name + '=([^&#]*)' ).exec( q );
if ( !m ) { return null; }
try { return decodeURIComponent( m[ 1 ].replace( /\+/g, ' ' ) ); } catch ( e ) { return m[ 1 ]; }
}


function build( root, data, initialSpecies ) {
function build( root, data, initialSpecies ) {
Line 71: Line 71:
var NT = data.times.length;
var NT = data.times.length;
var timeLen = data.times.map( function ( name ) { return TIME_TICKS[ name ] || 1; } );
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; } );


// Current species (mutable: the standalone page lets the user change it).
var byMethod = {};
var species = initialSpecies || '';
entries.forEach( function ( e ) {
var speciesEntries = [];
e.m.forEach( function ( m ) { ( byMethod[ m ] = byMethod[ m ] || [] ).push( e ); } );
function refreshSpeciesEntries() {
} );
speciesEntries = entries.filter( function ( e ) { return e.s === species; } );
}
refreshSpeciesEntries();


// Distinct species that have standard spawn data, for the picker.
var speciesSet = {}, speciesList = [];
var speciesSet = {};
var speciesList = [];
entries.forEach( function ( e ) {
entries.forEach( function ( e ) {
if ( !speciesSet[ e.s ] ) { speciesSet[ e.s ] = true; speciesList.push( e.s ); }
if ( !speciesSet[ e.s ] ) { speciesSet[ e.s ] = true; speciesList.push( e.s ); }
Line 88: Line 85:
speciesList.sort( function ( a, b ) { return a.localeCompare( b ); } );
speciesList.sort( function ( a, b ) { return a.localeCompare( b ); } );


// Pre-bucket entries by method id for the "find best" search.
var species = initialSpecies || '';
var byMethod = {};
var speciesEntries = [];
entries.forEach( function ( e ) {
var rel = {};
e.m.forEach( function ( m ) {
function refreshSpecies() {
( byMethod[ m ] = byMethod[ m ] || [] ).push( e );
speciesEntries = entries.filter( function ( e ) { return e.s === species; } );
} );
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; } )
};
}
refreshSpecies();


// ── situation match + probability ─────────────────────────────
// ── core probability ───────────────────────────────────────────
function matches( e, B, M, T, W, y ) {
// base: { B, M, W, y, light, moon } where any null field is "any".
if ( !inList( e.m, M ) ) { return false; }
function passes( c, base ) {
if ( e.b && !inList( e.b, B ) ) { return false; }
if ( c.b && !inList( c.b, base.B ) ) { return false; }
if ( e.t && !inList( e.t, T ) ) { return false; }
if ( base.y != null ) {
if ( e.w && !inList( e.w, W ) ) { return false; }
if ( c.y0 != null && base.y < c.y0 ) { return false; }
if ( y !== null ) {
if ( c.y1 != null && base.y > c.y1 ) { return false; }
if ( e.y0 != null && y < e.y0 ) { return false; }
if ( e.y1 != null && y > e.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; }
return true;
return true;
}
}


function chance( B, M, T, W, y ) {
// Per-time probability array for a base situation (one pass).
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 ) { 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;
}
 
// Full breakdown at one time (null = any time): rarity, competitors.
function chanceAt( base, t ) {
var pool = byMethod[ base.M ] || [];
var num = 0, den = 0, comp = 0;
var num = 0, den = 0, comp = 0;
for ( var i = 0; i < entries.length; i++ ) {
for ( var i = 0; i < pool.length; i++ ) {
if ( matches( entries[ i ], B, M, T, W, y ) ) {
var c = pool[ i ];
den += entries[ i ].r;
if ( !passes( c, base ) ) { continue; }
if ( entries[ i ].s === species ) { num += entries[ i ].r; }
if ( t != null && c.t && !inList( c.t, t ) ) { continue; }
else { comp++; }
den += c.r;
}
if ( c.s === species ) { num += c.r; } else { comp++; }
}
}
return { num: num, den: den, p: den > 0 ? num / den : 0, competitors: comp };
return { num: num, den: den, comp: comp, p: den > 0 ? num / den : 0 };
}
}


// Cyclic block of n time indices starting at `start`.
function blockTimes( start, n ) {
function blockTimes( start, n ) {
n = Math.max( 1, Math.min( NT, n ) );
n = Math.max( 1, Math.min( NT, n ) );
Line 128: Line 157:
return out;
return out;
}
}
 
function blockWeighted( p8, times ) {
// Length-weighted average of per-time probabilities over a block.
function blockWeighted( pByTime, times ) {
var wsum = 0, acc = 0;
var wsum = 0, acc = 0;
for ( var i = 0; i < times.length; i++ ) {
for ( var i = 0; i < times.length; i++ ) {
var t = times[ i ];
wsum += timeLen[ times[ i ] ];
wsum += timeLen[ t ];
acc += timeLen[ times[ i ] ] * p8[ times[ i ] ];
acc += timeLen[ t ] * pByTime[ t ];
}
}
return wsum > 0 ? acc / wsum : 0;
return wsum > 0 ? acc / wsum : 0;
}
}
// Default Y inside an entry's range (else 63).
function pickY( e ) {
function pickY( e ) {
var y = 63;
var y = 63;
Line 147: Line 171:
return y;
return y;
}
}
function round2( n ) { return Math.round( n * 100 ) / 100; }
function round2( n ) { return Math.round( n * 100 ) / 100; }
function fmtPct( p ) {
function fmtPct( p ) {
if ( !( p > 0 ) ) { return '0%'; }
if ( !( p > 0 ) ) { return '0%'; }
Line 156: Line 178:
}
}


// ── controls ──────────────────────────────────────────────────
// ── 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' } );
var biomeSel = el( 'select', { class: 'scalc-input' } );
data.biomes
data.biomes
.map( function ( id, i ) { return { i: i, name: prettyBiome( id ) }; } )
.map( function ( id, i ) { return { i: i, name: prettyBiome( id ) }; } )
.sort( function ( a, b ) { return a.name.localeCompare( b.name ); } )
.sort( function ( a, b ) { return a.name.localeCompare( b.name ); } )
.forEach( function ( o ) {
.forEach( function ( o ) { biomeSel.appendChild( el( 'option', { value: o.i, text: o.name } ) ); } );
biomeSel.appendChild( el( 'option', { value: o.i, text: o.name } ) );
} );


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


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


var weatherSel = el( 'select', { class: 'scalc-input' } );
var weatherSel = el( 'select', { class: 'scalc-input' } );
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 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' } );
for ( var mp = 0; mp < 8; mp++ ) { moonSel.appendChild( el( 'option', { value: mp, text: 'Phase ' + mp } ) ); }


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


var ySel = el( 'input', { type: 'number', min: '-64', max: '320', value: '63', class: 'scalc-num' } );
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();


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


var bestBtn = el( 'button', { class: 'scalc-btn', type: 'button',
// Species picker (standalone page).
text: 'Find best chance' } );
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
] );


// Optional species picker (standalone page only).
var fBiome = field( 'Biome', biomeSel, lockBiome );
var speciesField = null, speciesInput = null;
var fMethod = field( 'Location type', methodSel, lockMethod );
if ( !initialSpecies ) {
var fTime = field( 'Time of day', timeSel, lockTime );
var dl = el( 'datalist', { id: 'scalc-species-list' } );
var fWeather = field( 'Weather', weatherSel, lockWeather );
speciesList.forEach( function ( n ) { dl.appendChild( el( 'option', { value: n } ) ); } );
var fY = field( 'Y-level', ySel, lockY );
speciesInput = el( 'input', {
var fLight = field( 'Light level', lightSel, lockLight );
class: 'scalc-input scalc-species', list: 'scalc-species-list',
var fMoon = field( 'Moon phase', moonSel, lockMoon );
placeholder: 'e.g. Bulbasaur', autocomplete: 'off'
} );
function onSpeciesPick() {
if ( speciesSet[ speciesInput.value ] ) { setSpecies( speciesInput.value ); }
}
speciesInput.addEventListener( 'change', onSpeciesPick );
speciesInput.addEventListener( 'input', onSpeciesPick );
speciesField = el( 'div', { class: 'scalc-field scalc-field-species' }, [
el( 'label', { class: 'scalc-label', text: 'Pokemon' } ), speciesInput, dl
] );
}


// Actual in-game spawn conditions the player can stand in.
var formFields = [];
var condFields = [];
if ( !initialSpecies ) { formFields.push( fSpecies ); }
if ( speciesField ) { condFields.push( speciesField ); }
formFields.push( fBiome, fMethod, fTime, fWeather, fY, fLight, fMoon );
condFields.push(
var form = el( 'div', { class: 'scalc-form' }, formFields );
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
] )
);
var form = el( 'div', { class: 'scalc-form' }, condFields );


// Meta/analysis controls: not a spawn condition, they only change how the
// chance is aggregated, so they live in their own slim section.
var strictField = el( 'div', { class: 'scalc-field' }, [
el( 'label', { class: 'scalc-label', text: 'Strict block' } ),
el( 'label', { class: 'scalc-check-wrap',
title: 'When finding the best chance, only accept consecutive-time '
+ 'blocks where the species spawns in every time. If no full block '
+ 'exists, the block size is reduced and you are warned.' }, [
strictChk, el( 'span', { class: 'scalc-check-text', text: 'all times must spawn' } )
] )
] );
var meta = el( 'div', { class: 'scalc-form scalc-meta' }, [
var meta = el( 'div', { class: 'scalc-form scalc-meta' }, [
el( 'span', { class: 'scalc-meta-label', text: 'Analysis' } ),
el( 'span', { class: 'scalc-meta-label', text: 'Time block' } ),
field( 'Consecutive times', consecSel ),
field( 'Consecutive times', consecSel ),
strictField
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 actions = el( 'div', { class: 'scalc-actions' }, [ bestBtn ] );
var results = el( 'div', { class: 'scalc-results' } );
var results = el( 'div', { class: 'scalc-results' } );


function getInputs() {
function getN() { return Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) ); }
function getBase() {
return {
return {
B: +biomeSel.value,
B: +biomeSel.value,
M: +methodSel.value,
M: +methodSel.value,
T: +timeSel.value,
W: rel.weather ? +weatherSel.value : null,
W: +weatherSel.value,
y: rel.y ? ( ySel.value === '' ? null : +ySel.value ) : null,
y: ySel.value === '' ? null : +ySel.value,
light: rel.light ? ( lightSel.value === '' ? null : +lightSel.value ) : null,
N: Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) ),
moon: rel.moon ? +moonSel.value : null
strict: strictChk.checked
};
};
}
}


var flagBest = false;
function applyVisibility() {
var warnText = null;
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';
meta.style.display = rel.time ? '' : 'none';
}
 
var flagBest = false, warnText = null;


function recompute() {
function recompute() {
Line 277: Line 292:
if ( !species ) {
if ( !species ) {
results.appendChild( el( 'div', { class: 'scalc-result' }, [
results.appendChild( el( 'div', { class: 'scalc-result' }, [
el( 'span', { class: 'scalc-note', text:
el( 'span', { class: 'scalc-note', text: 'Choose a Pokemon to see its spawn chances.' } )
'Choose a Pokemon above to see its spawn chances.' } )
] ) );
] ) );
flagBest = false;
flagBest = false; warnText = null; return;
warnText = null;
return;
}
}
if ( !speciesEntries.length ) {
if ( !speciesEntries.length ) {
Line 290: Line 302:
+ 'fossil, evolution-only or event Pokemon).' } )
+ 'fossil, evolution-only or event Pokemon).' } )
] ) );
] ) );
flagBest = false;
flagBest = false; warnText = null; return;
warnText = null;
return;
}
}
var s = getInputs();
var times = blockTimes( s.T, s.N );
var single = times.length === 1;


var p, detailText;
var base = getBase();
var perTime = null;    // per-time breakdown when the block spans >1 time
var N = getN();
var deadCount = 0;     // times in the block with no spawn for this species
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 ) {
if ( single ) {
var res = chance( s.B, s.M, times[ 0 ], s.W, s.y );
var res = chanceAt( base, times[ 0 ] );
p = res.p;
detailText = '~1 in ' + Math.round( 1 / res.p ) + ' spawns - rarity '
detailText = p > 0
+ round2( res.num ) + ' of ' + round2( res.den ) + ' vs ' + res.comp
? '~1 in ' + Math.round( 1 / p ) + ' spawns - rarity '
+ ' competing spawn' + ( res.comp === 1 ? '' : 's' ) + '.';
+ round2( res.num ) + ' of ' + round2( res.den ) + ' vs '
+ res.competitors + ' competing spawn'
+ ( res.competitors === 1 ? '' : 's' ) + '.'
: '';
} else {
} else {
var p8 = [];
detailText = '~1 in ' + Math.round( 1 / p ) + ' spawns, length-weighted across '
for ( var t = 0; t < NT; t++ ) { p8.push( chance( s.B, s.M, t, s.W, s.y ).p ); }
+ ( !rel.time ? 'any time (this Pokemon ignores time of day)'
p = blockWeighted( p8, times );
: ( N >= NT ? 'any time' : N + ' consecutive times' ) ) + '.';
perTime = times.map( function ( ti ) {
return { name: titleCase( data.times[ ti ] ), p: p8[ ti ] };
} );
deadCount = perTime.filter( function ( pt ) { return !( pt.p > 0 ); } ).length;
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 hit = p > 0;
var pctText = fmtPct( hit ? p : 0 );
var head = el( 'div', { class: 'scalc-result-head' }, [
var head = el( 'div', { class: 'scalc-result-head' }, [
el( 'span', { class: 'scalc-pct', text: pctText } ),
el( 'span', { class: 'scalc-pct', text: fmtPct( hit ? p : 0 ) } ),
el( 'span', { class: 'scalc-pctlabel', text:
el( 'span', { class: 'scalc-pctlabel', text:
'chance a wild spawn here is ' + species
'chance a wild spawn here is ' + species + ( flagBest && hit ? ' (best found)' : '' ) } )
+ ( flagBest && hit ? ' (best found)' : '' ) } )
] );
] );
var detail = hit
var detail = hit
? el( 'span', { class: 'scalc-detail', text: detailText } )
? el( 'span', { class: 'scalc-detail', text: detailText } )
: 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 change '
+ 'change the biome / location type / time.' } );
+ 'the biome / location type / time.' } );


var children = [];
var children = [];
if ( warnText ) {
if ( warnText ) { children.push( el( 'div', { class: 'scalc-warn', text: warnText } ) ); }
children.push( el( 'div', { class: 'scalc-warn', text: warnText } ) );
}
children.push( head, detail );
children.push( head, detail );


// One compact chip per time in the block, each labelled with its time
if ( hit && showBreak ) {
// and that time's own chance; 0% times are flagged so a block is never
var deadCount = 0;
// mistaken for a genuine run of spawning times.
var chips = times.map( function ( ti ) {
if ( hit && perTime ) {
var pv = p8[ ti ], dead = !( pv > 0 );
var chips = perTime.map( function ( pt ) {
if ( dead ) { deadCount++; }
var dead = !( pt.p > 0 );
return el( 'span', { class: 'scalc-chip' + ( dead ? ' scalc-chip-dead' : '' ) }, [
return el( 'span', { class: 'scalc-chip' + ( dead ? ' scalc-chip-dead' : '' ) }, [
el( 'span', { class: 'scalc-chip-time', text: pt.name } ),
el( 'span', { class: 'scalc-chip-time', text: titleCase( data.times[ ti ] ) } ),
el( 'span', { class: 'scalc-chip-pct', text: fmtPct( pt.p ) } )
el( 'span', { class: 'scalc-chip-pct', text: fmtPct( pv ) } )
] );
] );
} );
} );
Line 360: Line 356:
if ( deadCount > 0 ) {
if ( deadCount > 0 ) {
children.push( el( 'div', { class: 'scalc-note', text:
children.push( el( 'div', { class: 'scalc-note', text:
deadCount + ' of ' + perTime.length + ' times have no ' + species
deadCount + ' of ' + times.length + ' times have no ' + species
+ ' spawns here' + ( s.strict ? ''
+ ' spawns here' + ( strictChk.checked ? ''
: '; enable "Strict block" to make the optimizer avoid them' )
: '; enable "Strict block" to make the optimizer avoid them' ) + '.' } ) );
+ '.' } ) );
}
}
}
}
Line 370: Line 365:
+ ( flagBest && hit ? ' scalc-result-best' : '' )
+ ( flagBest && hit ? ' scalc-result-best' : '' )
}, children ) );
}, children ) );
flagBest = false;
flagBest = false; warnText = null;
warnText = null;
}
 
// ── optimizer ────────────────────────────────────────────────────
function biomeCandidates() {
if ( speciesEntries.some( function ( e ) { return !e.b; } ) ) {
return data.biomes.map( function ( _b, i ) { return i; } );
}
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 moonCandidates() {
var vals = [];
speciesEntries.forEach( function ( e ) { if ( e.mp != null ) { vals.push( e.mp ); } } );
return uniq( vals );
}
}


// ── find best: situation (and, for N > 1, best time block) ──────
function findBest() {
function findBest() {
if ( !species || !speciesEntries.length ) { recompute(); return; }
if ( !species || !speciesEntries.length ) { recompute(); return; }
var s = getInputs();
var N = getN(), strict = strictChk.checked, lockT = lockTime.checked, curT = +timeSel.value;
var N = s.N, strict = s.strict;
 
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 ] : moonCandidates() );


// Build every candidate (biome, method, weather, y) situation once,
// each with its per-time probability array, then search over them.
var situations = [];
var situations = [];
for ( var i = 0; i < speciesEntries.length; i++ ) {
for ( var a = 0; a < biomeC.length; a++ ) {
var se = speciesEntries[ i ];
for ( var b = 0; b < methodC.length; b++ ) {
var y = pickY( se );
for ( var c = 0; c < weatherC.length; c++ ) {
var biomes = se.b || data.biomes.map( function ( _b, k ) { return k; } );
for ( var d = 0; d < yC.length; d++ ) {
var weathers = se.w || allWeathers;
for ( var f = 0; f < lightC.length; f++ ) {
for ( var mi = 0; mi < se.m.length; mi++ ) {
for ( var g = 0; g < moonC.length; g++ ) {
var M = se.m[ mi ];
var base = { B: biomeC[ a ], M: methodC[ b ], W: weatherC[ c ],
var pool = byMethod[ M ] || [];
y: yC[ d ], light: lightC[ f ], moon: moonC[ g ] };
for ( var bi = 0; bi < biomes.length; bi++ ) {
situations.push( { base: base, p8: chanceP8( base ) } );
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 ];
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 ); }
situations.push( { B: B, M: M, W: W, y: y, p8: p8 } );
}
}
}
}
Line 423: Line 425:
}
}


// Best block of exactly size n. When strictFlag, only accept blocks in
// which every time spawns this species (no 0% time inside the block).
function searchAt( n, strictFlag ) {
function searchAt( n, strictFlag ) {
var best = null;
var best = null;
for ( var i = 0; i < situations.length; i++ ) {
for ( var i = 0; i < situations.length; i++ ) {
var p8 = situations[ i ].p8;
var p8 = situations[ i ].p8;
for ( var start = 0; start < NT; start++ ) {
var starts = lockT ? [ curT ] : allTimes;
for ( var si = 0; si < starts.length; si++ ) {
var start = starts[ si ];
var times = blockTimes( start, n );
var times = blockTimes( start, n );
var allSpawn = true;
var allSpawn = true;
Line 441: Line 443:
var p = times.length === 1 ? p8[ times[ 0 ] ] : blockWeighted( p8, times );
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: situations[ i ].B, M: situations[ i ].M,
best = { base: situations[ i ].base, start: start, p: p };
W: situations[ i ].W, y: situations[ i ].y, start: start, p: p };
}
}
if ( n >= NT ) { break; }
if ( n >= NT && !lockT ) { break; }
}
}
}
}
Line 451: Line 452:


var best = null, effN = N;
var best = null, effN = N;
if ( strict ) {
if ( !rel.time ) {
// time irrelevant: whole-day length-weighted, no start search
for ( var k = 0; k < situations.length; k++ ) {
var pw = blockWeighted( situations[ k ].p8, allTimes );
if ( pw > 0 && ( !best || pw > best.p ) ) {
best = { base: situations[ k ].base, start: null, p: pw };
}
}
} else if ( strict ) {
for ( var n = N; n >= 1; n-- ) {
for ( var n = N; n >= 1; n-- ) {
best = searchAt( n, true );
best = searchAt( n, true );
Line 461: Line 470:
if ( !best ) { recompute(); return; }
if ( !best ) { recompute(); return; }


biomeSel.value = best.B;
var bb = best.base;
methodSel.value = best.M;
biomeSel.value = bb.B;
weatherSel.value = best.W;
methodSel.value = bb.M;
ySel.value = best.y;
if ( rel.weather && bb.W != null ) { weatherSel.value = bb.W; }
timeSel.value = best.start;
if ( rel.y && bb.y != null ) { ySel.value = bb.y; }
if ( strict && effN < N ) {
if ( rel.light && bb.light != null ) { lightSel.value = bb.light; }
if ( rel.moon && bb.moon != null ) { moonSel.value = bb.moon; }
if ( rel.time && best.start != null ) { timeSel.value = best.start; }
if ( rel.time && strict && effN < N ) {
consecSel.value = effN;
consecSel.value = effN;
warnText = species + ' spawns in at most ' + effN + ' consecutive time'
warnText = species + ' spawns in at most ' + effN + ' consecutive time'
+ ( effN === 1 ? '' : 's' ) + ' in any situation, so the block was '
+ ( effN === 1 ? '' : 's' ) + ' in any situation, so the block was reduced from '
+ 'reduced from ' + N + ' to ' + effN + '.';
+ N + ' to ' + effN + '.';
}
}
flagBest = true;
flagBest = true;
Line 476: Line 488:
}
}


// 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() {
function setDefaults() {
if ( !speciesEntries.length ) { return; }
if ( !speciesEntries.length ) { return; }
var common = {};
var common = {};
[ 'Grass', 'Land', 'Water', 'Surface Water', 'Air', 'Tree Top',
[ 'Grass', 'Land', 'Water', 'Surface Water', 'Air', 'Tree Top', 'Underground', 'Seafloor' ]
  'Underground', 'Seafloor' ].forEach( function ( name ) {
.forEach( function ( name ) {
var idx = data.methods.indexOf( name );
var idx = data.methods.indexOf( name );
if ( idx !== -1 ) { common[ idx ] = true; }
if ( idx !== -1 ) { common[ idx ] = true; }
} );
} );
var pick = null;
var pick = null;
speciesEntries.forEach( function ( e ) {
speciesEntries.forEach( function ( e ) {
Line 500: Line 508:
if ( se.t && se.t.length ) { timeSel.value = se.t[ 0 ]; }
if ( se.t && se.t.length ) { timeSel.value = se.t[ 0 ]; }
ySel.value = pickY( se );
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; }
}
}


// Switch to a new species (standalone page): refresh data + form defaults.
function setSpecies( name ) {
function setSpecies( name ) {
species = name;
species = name;
refreshSpeciesEntries();
refreshSpecies();
applyVisibility();
setDefaults();
setDefaults();
recompute();
recompute();
}
}


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


// ── assemble the card (compact: header, controls row, result row) ──
// ── assemble ─────────────────────────────────────────────────────
var header = el( 'div', { class: 'scalc-header' }, [
var header = el( 'div', { class: 'scalc-header' }, [
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, meta, results ] );
var body = el( 'div', { class: 'scalc-body' }, [ form, meta, actions, results ] );
root.appendChild( header );
root.appendChild( header );
root.appendChild( body );
root.appendChild( body );
applyVisibility();
setDefaults();
setDefaults();
// Pre-fill from ?species= (link from a species page) when not fixed.
if ( !initialSpecies ) {
var pre = getParam( 'species' );
if ( pre && speciesSet[ pre ] ) { speciesInput.value = pre; setSpecies( pre ); }
}
recompute();
recompute();
}
}

Revision as of 08:50, 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 there; a ?species=Name
 * query parameter (used by the link on each species page) pre-fills the picker.
 *
 * For a chosen species and situation it shows the chance that a single
 * Better-Spawner selection is that species:
 *     P = sum(rarity of this species' matching spawns)
 *         / sum(rarity of all matching spawns)
 *
 * Dimensions: biome, location type, time of day, weather, Y-level, light level,
 * moon phase. Only the dimensions that actually constrain the chosen species are
 * shown. Each shown field can be locked so "Find best chance" leaves it fixed.
 * "Consecutive times" averages a cyclic block of times of day, weighting each by
 * its real in-game length; with "Strict block" the optimizer only accepts blocks
 * where the species spawns in every time, otherwise it reduces the block size.
 *
 * 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';

	// Active in-game duration (ticks out of 24000) of each WorldTime, from the
	// Pixelmon WorldTime SEGMENTS partition. Weights a block of times by 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;
	}

	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 q = location.search || '';
		var m = new RegExp( '[?&]' + name + '=([^&#]*)' ).exec( q );
		if ( !m ) { return null; }
		try { return decodeURIComponent( m[ 1 ].replace( /\+/g, ' ' ) ); } catch ( e ) { return m[ 1 ]; }
	}

	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 speciesEntries = [];
		var rel = {};
		function refreshSpecies() {
			speciesEntries = entries.filter( function ( e ) { return e.s === species; } );
			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; } )
			};
		}
		refreshSpecies();

		// ── core probability ───────────────────────────────────────────
		// base: { B, M, W, y, light, moon } where any null field is "any".
		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; }
			return true;
		}

		// Per-time probability array for a base situation (one pass).
		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 ) { 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;
		}

		// Full breakdown at one time (null = any time): rarity, competitors.
		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 ) { num += c.r; } else { comp++; }
			}
			return { num: num, den: den, comp: comp, p: den > 0 ? num / den : 0 };
		}

		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' } );
		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 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' } );
		for ( var mp = 0; mp < 8; mp++ ) { moonSel.appendChild( el( 'option', { value: mp, text: 'Phase ' + mp } ) ); }

		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();

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

		// Species picker (standalone page).
		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 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 formFields = [];
		if ( !initialSpecies ) { formFields.push( fSpecies ); }
		formFields.push( fBiome, fMethod, fTime, fWeather, fY, fLight, fMoon );
		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 actions = el( 'div', { class: 'scalc-actions' }, [ bestBtn ] );
		var results = el( 'div', { class: 'scalc-results' } );

		function getN() { return Math.max( 1, Math.min( NT, parseInt( consecSel.value, 10 ) || 1 ) ); }
		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
			};
		}

		function applyVisibility() {
			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';
			meta.style.display = rel.time ? '' : 'none';
		}

		var flagBest = false, warnText = null;

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

			if ( hit && showBreak ) {
				var deadCount = 0;
				var chips = times.map( function ( ti ) {
					var pv = p8[ ti ], dead = !( pv > 0 );
					if ( dead ) { deadCount++; }
					return el( 'span', { class: 'scalc-chip' + ( dead ? ' scalc-chip-dead' : '' ) }, [
						el( 'span', { class: 'scalc-chip-time', text: titleCase( data.times[ ti ] ) } ),
						el( 'span', { class: 'scalc-chip-pct', text: fmtPct( pv ) } )
					] );
				} );
				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' ) + '.' } ) );
				}
			}
			results.appendChild( el( 'div', {
				class: 'scalc-result ' + ( hit ? 'scalc-result-hit' : 'scalc-result-miss' )
				+ ( flagBest && hit ? ' scalc-result-best' : '' )
			}, children ) );
			flagBest = false; warnText = null;
		}

		// ── optimizer ────────────────────────────────────────────────────
		function biomeCandidates() {
			if ( speciesEntries.some( function ( e ) { return !e.b; } ) ) {
				return data.biomes.map( function ( _b, i ) { return i; } );
			}
			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 moonCandidates() {
			var vals = [];
			speciesEntries.forEach( function ( e ) { if ( e.mp != null ) { vals.push( e.mp ); } } );
			return uniq( vals );
		}

		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 ] : moonCandidates() );

			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++ ) {
									var base = { B: biomeC[ a ], M: methodC[ b ], W: weatherC[ c ],
										y: yC[ d ], light: lightC[ f ], moon: moonC[ g ] };
									situations.push( { base: base, p8: chanceP8( base ) } );
								}
							}
						}
					}
				}
			}

			function searchAt( n, strictFlag ) {
				var best = null;
				for ( var i = 0; i < situations.length; i++ ) {
					var p8 = situations[ i ].p8;
					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 && ( !best || p > best.p ) ) {
							best = { base: situations[ i ].base, start: start, p: p };
						}
						if ( n >= NT && !lockT ) { break; }
					}
				}
				return best;
			}

			var best = null, effN = N;
			if ( !rel.time ) {
				// time irrelevant: whole-day length-weighted, no start search
				for ( var k = 0; k < situations.length; k++ ) {
					var pw = blockWeighted( situations[ k ].p8, allTimes );
					if ( pw > 0 && ( !best || pw > best.p ) ) {
						best = { base: situations[ k ].base, start: null, p: pw };
					}
				}
			} else if ( strict ) {
				for ( var n = N; n >= 1; n-- ) {
					best = searchAt( n, true );
					if ( best ) { effN = n; break; }
				}
			} else {
				best = searchAt( N, false );
			}
			if ( !best ) { recompute(); return; }

			var bb = best.base;
			biomeSel.value = bb.B;
			methodSel.value = bb.M;
			if ( rel.weather && bb.W != null ) { weatherSel.value = bb.W; }
			if ( rel.y && bb.y != null ) { ySel.value = bb.y; }
			if ( rel.light && bb.light != null ) { lightSel.value = bb.light; }
			if ( rel.moon && bb.moon != null ) { moonSel.value = bb.moon; }
			if ( rel.time && best.start != null ) { timeSel.value = best.start; }
			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 + '.';
			}
			flagBest = true;
			recompute();
		}

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

		function setSpecies( name ) {
			species = name;
			refreshSpecies();
			applyVisibility();
			setDefaults();
			recompute();
		}

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

		// ── assemble ─────────────────────────────────────────────────────
		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, actions, results ] );
		root.appendChild( header );
		root.appendChild( body );

		applyVisibility();
		setDefaults();

		// Pre-fill from ?species= (link from a species page) when not fixed.
		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 ); }
}() );