Jump to content

User:Chonx/eqlItemScore.js: Difference between revisions

From EverQuest Legends Wiki
Chonx (talk | contribs)
column fixes
Chonx (talk | contribs)
revamps
Line 1: Line 1:
/**
/**
  * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.
  * ext.itemScoreTable — EQL Equipment Analyzer / Item Score guide.
  *
  *
  * Single self-contained module (same shape as ext.itemLevelSlider /
  * Mounts into <div id="eql-item-score-table">. All data access goes through
* ext.classSlotEquip). Paste into User:<you>/common.js, then create a page
  * ACTIVE_DATASOURCE so a server-side Special:ItemScoreTable can later replace
* containing:  <div id="eql-item-score-table"></div>
* ClientDataSource with a one-line change.
*
  * Data access goes through ACTIVE_DATASOURCE so a future server-side
* Special:ItemScoreTable can replace ClientDataSource with a one-line change.
*
* RELOADING AFTER AN EDIT: MediaWiki serves the user JS bundle from a server-side
* ResourceLoader cache keyed by a version hash. After you save common.js the hash
* can take a few minutes to recompute, so a browser hard-refresh (Ctrl+Shift+R)
* alone may still run the OLD code. Reliable ways to force the new version:
*  1. Append &debug=true to the page URL (bypasses the RL cache/minifier), or
*  2. Visit  index.php?title=User:Chonx/common.js&action=raw  once, then reload, or
*  3. Run this in the console to eval the freshest copy immediately:
*      fetch('/index.php?title=User:Chonx/common.js&action=raw&_='+Date.now())
*        .then(r=>r.text()).then(t=>eval(t));
  */
  */
( function ( $, mw ) {
( function ( $, mw ) {
'use strict';
'use strict';


// ---- styles (injected; common.js has no separate CSS channel) ----
// Styles are injected from JS — the module has no separate CSS channel.
( function () {
( function () {
var s = document.createElement( 'style' );
var s = document.createElement( 'style' );
Line 31: Line 18:




var MAX_LEVEL = 10;
// Only truly-global tunables live here. Anything specific to a stat, a score
// component, or an effect lives ON that stat/component/effect (STATS, the
// SCORE_COMPONENTS entries, EFFECTS) — the same way DMG's params live on its row.
var CONFIG = {
UPGRADE_MAX: 10,  // item upgrade slider range 0..UPGRADE_MAX
LEVEL_MIN: 1,      // Level min/max filter bounds
LEVEL_MAX: 100,
SCORE_ROUND: 1000  // score rounded to 1/SCORE_ROUND
};
 
var MAX_LEVEL = CONFIG.UPGRADE_MAX;


// Stats that scale via the "primary spreadsheet" rule (incl. saves).
/*
var PRIMARY_STATS = [
* SINGLE SOURCE OF TRUTH for every stat. Each entry owns everything about that
'AC', 'HP', 'MP', 'END',
* stat: parse aliases, scaling class, SV-Void group, default score weight, and
'STR', 'STA', 'AGI', 'DEX', 'WIS', 'INT', 'CHA',
* default enablement as a score component / table column. STAT_ALIASES,
'SV_MAGIC', 'SV_FIRE', 'SV_COLD', 'SV_POISON', 'SV_DISEASE'
* PRIMARY_STATS, SV_VOID_QUALIFIERS, the stat columns and stat score-components
* are all DERIVED from this — nothing about a stat is listed twice.
*
* group:  attr | save | defense | weapon  (drives SV-Void qualification)
* scaling: primary | damage | none          (mirror of ext.itemLevelSlider rules)
*/
var STATS = [
{ key: 'DMG', label: 'DMG', aliases: [ 'DMG', 'DAMAGE' ], group: 'weapon', scaling: 'damage', weight: 1, column: true, columnDefault: true, score: false, scoreDefault: false },
// delay is parsed like DMG (alias "Atk Delay"); ItemLevelSlider leaves it
// unchanged (scaling 'none'). scoreScale is its scoring param (score = scale/delay).
{ key: 'DELAY', label: 'Delay', aliases: [ 'ATK DELAY' ], group: 'weapon', scaling: 'none', weight: 1, scoreScale: 100, column: true, columnDefault: true, score: false, scoreDefault: false },
{ key: 'AC', label: 'AC', aliases: [ 'AC', 'ARMOR CLASS' ], group: 'defense', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'STR', label: 'STR', aliases: [ 'STR', 'STRENGTH' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'STA', label: 'STA', aliases: [ 'STA', 'STAMINA' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'AGI', label: 'AGI', aliases: [ 'AGI', 'AGILITY' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'DEX', label: 'DEX', aliases: [ 'DEX', 'DEXTERITY' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'INT', label: 'INT', aliases: [ 'INT', 'INTELLIGENCE' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'WIS', label: 'WIS', aliases: [ 'WIS', 'WISDOM' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'CHA', label: 'CHA', aliases: [ 'CHA', 'CHARISMA' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'SV_MAGIC', label: 'SV Mag', aliases: [ 'SV MAGIC', 'MAGIC' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'SV_FIRE', label: 'SV Fire', aliases: [ 'SV FIRE', 'FIRE' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'SV_COLD', label: 'SV Cold', aliases: [ 'SV COLD', 'COLD' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'SV_POISON', label: 'SV Poi', aliases: [ 'SV POISON', 'POISON' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
{ key: 'SV_DISEASE', label: 'SV Dis', aliases: [ 'SV DISEASE', 'DISEASE' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false }
];
];


// Stats that, when >=2 are present, grant SV VOID: +level.
var STAT_BY_KEY = {};
var SV_VOID_QUALIFIERS = [
STATS.forEach( function ( s ) { STAT_BY_KEY[ s.key ] = s; } );
'STR', 'STA', 'INT', 'AGI', 'DEX', 'CHA', 'WIS',
'SV_FIRE', 'SV_COLD', 'SV_POISON', 'SV_MAGIC', 'SV_DISEASE'
];


// All stat labels we recognize in a stat block, longest-first for regex.
// Everything below is derived from STATS.
var STAT_ALIASES = {
var STAT_ALIASES = {};
'DAMAGE': 'DMG',
STATS.forEach( function ( s ) {
'DMG': 'DMG',
s.aliases.forEach( function ( a ) { STAT_ALIASES[ a.toUpperCase() ] = s.key; } );
'MANA': 'MP',
} );
'MP': 'MP',
'ENDUR': 'END',
'END': 'END',
'SV MAGIC': 'SV_MAGIC',
'SV FIRE': 'SV_FIRE',
'SV COLD': 'SV_COLD',
'SV POISON': 'SV_POISON',
'SV DISEASE': 'SV_DISEASE',
'MAGIC': 'SV_MAGIC',
'FIRE': 'SV_FIRE',
'COLD': 'SV_COLD',
'POISON': 'SV_POISON',
'DISEASE': 'SV_DISEASE',
'HP REGEN': 'HP_REGEN',
'WEIGHT': 'WT',
'WT': 'WT',
'AC': 'AC',
'ARMOR CLASS': 'AC',
'HP': 'HP',
'HIT POINTS': 'HP',
'STR': 'STR',
'STRENGTH': 'STR',
'STA': 'STA',
'STAMINA': 'STA',
'AGI': 'AGI',
'AGILITY': 'AGI',
'DEX': 'DEX',
'DEXTERITY': 'DEX',
'WIS': 'WIS',
'WISDOM': 'WIS',
'INT': 'INT',
'INTELLIGENCE': 'INT',
'CHA': 'CHA',
'CHARISMA': 'CHA',
'HASTE': 'HASTE'
};


// Labels to match in the stat text (longest first so "SV FIRE" beats "FIRE").
// Labels to match in the stat text (longest first so "SV FIRE" beats "FIRE").
Line 93: Line 74:
} );
} );


// ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----
var PRIMARY_STATS = STATS.filter( function ( s ) {
return s.scaling === 'primary';
} ).map( function ( s ) { return s.key; } );
 
var SV_VOID_QUALIFIERS = STATS.filter( function ( s ) {
return s.group === 'attr' || s.group === 'save';
} ).map( function ( s ) { return s.key; } );
 
// Effects that add a flat score bonus when present on an item. Each owns its
// own default bonus (EFFECT_TYPES is derived for code that just needs the keys).
var EFFECTS = {
focus: { bonus: 25 },
worn: { bonus: 25 },
click: { bonus: 25 },
combat: { bonus: 25 }
};
var EFFECT_TYPES = Object.keys( EFFECTS );
 
/* =====================================================================
* MIRROR of ext.itemLevelSlider.js — keep in sync until that module
* exposes these. excelRound / intFloor / scalePrimaryStat / scaleDamage
* are copied verbatim from its scaling logic. This is the ONLY copy of
* that math in this file; do not re-implement it elsewhere.
* ===================================================================== */
 
// ---- Excel-parity math ----


function excelRound( value, digits ) {
function excelRound( value, digits ) {
Line 108: Line 114:
}
}


// ---- Scaling formulas (ported from ext.itemLevelSlider.js) --------------
// ---- Scaling formulas ----
// state here is just an integer effective level (0..10).


function scalePrimaryStat( signedBase, level ) {
function scalePrimaryStat( signedBase, level ) {
Line 153: Line 158:
}
}


/**
// Callers must strip HTML first. focus_effect isn't in the stat block — enrichment sets it.
* Parse a stat block (plain text; callers should strip HTML first).
* Returns a normalized item fields object. focus_effect is NOT present in
* stat blocks — it is set later by the data-source enrichment step.
*/
function parseItemData( text ) {
function parseItemData( text ) {
var raw = String( text || '' ).replace( / /g, ' ' );
var raw = String( text || '' ).replace( / /g, ' ' );
Line 164: Line 165:
skill: null,        // e.g. "1H Slashing" (weapon skill line)
skill: null,        // e.g. "1H Slashing" (weapon skill line)
type: 'armor',      // weapon skill, or "armor" when no Skill: line
type: 'armor',      // weapon skill, or "armor" when no Skill: line
delay: 0,
stats: {},          // canonical key -> signed base number (incl. DELAY)
stats: {},          // canonical key -> signed base number
effects: [],        // [{ type, name }]
effects: [],        // [{ type, name }]
effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }
effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }
Line 180: Line 180:
}
}


var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );
// Weapon skill / type only. The delay value itself is parsed as the DELAY
// stat by the generic stat loop below (its alias is "Atk Delay").
var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:/i );
if ( skillMatch ) {
if ( skillMatch ) {
item.skill = skillMatch[ 1 ].trim();
item.skill = skillMatch[ 1 ].trim();
item.type = item.skill;
item.type = item.skill;
item.delay = parseFloat( skillMatch[ 2 ] );
} else {
var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );
if ( delayOnly ) {
item.delay = parseFloat( delayOnly[ 1 ] );
}
}
}


Line 221: Line 217:
}
}


// Stats: for each known label, capture "LABEL: [+-]?num"
var labelAlt = STAT_LABELS.map( function ( s ) {
var labelAlt = STAT_LABELS.map( function ( s ) {
return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
Line 233: Line 228:
value = -value;
value = -value;
}
}
// DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.
item.stats[ key ] = value;
item.stats[ key ] = value;
}
}
Line 241: Line 235:


// ---- Scoring ------------------------------------------------------------
// ---- Scoring ------------------------------------------------------------
// Default score component weights (all 1). A component contributes its
// scaled stat value when enabled.
function defaultComponents() {
return {
ratio: false, dmg: false, delay: false,
ac: false, str: false, sta: false, agi: false, dex: false,
int: false, wis: false, cha: false,
totalSV: false,
svMagic: false, svFire: false, svCold: false,
svPoison: false, svDisease: false, svVoid: false
};
}


function svVoidBonus( item, level ) {
function svVoidBonus( item, level ) {
Line 268: Line 249:
}
}


/**
* Compute the scaled value of one stat key at a given level.
*/
function scaledStat( item, key, level ) {
function scaledStat( item, key, level ) {
if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {
if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {
Line 276: Line 254:
}
}
var base = item.stats[ key ];
var base = item.stats[ key ];
if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {
var stat = STAT_BY_KEY[ key ];
if ( !stat ) {
return base;
}
if ( stat.scaling === 'primary' ) {
return scalePrimaryStat( base, level );
return scalePrimaryStat( base, level );
}
}
if ( key === 'DMG' ) {
if ( stat.scaling === 'damage' ) {
return scaleDamage( base, level );
return scaleDamage( base, level );
}
}
Line 285: Line 267:
}
}


/**
// ---- Weapon ratio helper (for the display column) -----------------------
* scoreItem(item, opts)
 
*  opts.level       integer 0..10
function weaponRatio( item, level ) {
*  opts.components    map of enabled score components (see defaultComponents)
var dmg = scaledStat( item, 'DMG', level || 0 );
*  opts.effectBonus  { focus, worn, click, combat } flat points per present effect
var delay = item.stats.DELAY;
*/
if ( !delay || dmg <= 0 ) {
return null;
}
return dmg / delay;
}
 
// Order here is the display order in the score panel.
var SCORE_COMPONENTS = ( function () {
var list = [];
 
// ratio is fully derived (no parsed stat), so it owns its own scale here.
list.push( {
key: 'ratio', label: 'Weapon Ratio', group: 'weapon',
weight: 1, scoreDefault: false, scale: 25, // ratio score = (dmg/delay) * scale
compute: function ( item, level ) {
var dmg = scaledStat( item, 'DMG', level );
var delay = item.stats.DELAY;
return ( delay > 0 && dmg > 0 ) ? ( dmg / delay ) * this.scale : 0;
}
} );
list.push( {
key: 'DMG', label: 'Weapon Damage', group: 'weapon',
weight: STAT_BY_KEY.DMG.weight, scoreDefault: false,
compute: function ( item, level ) { return scaledStat( item, 'DMG', level ); }
} );
// delay's scoring scale lives on its STATS row (score = scoreScale / delay).
list.push( {
key: 'DELAY', label: 'Weapon Delay', group: 'weapon',
weight: STAT_BY_KEY.DELAY.weight, scoreDefault: false,
compute: function ( item ) {
var delay = item.stats.DELAY;
return delay > 0 ? STAT_BY_KEY.DELAY.scoreScale / delay : 0;
}
} );
 
STATS.forEach( function ( s ) {
if ( s.score && s.group !== 'save' ) {
list.push( {
key: s.key, label: s.label, group: s.group,
weight: s.weight, scoreDefault: s.scoreDefault,
compute: function ( item, level ) { return scaledStat( item, s.key, level ); }
} );
}
} );
 
list.push( {
key: 'totalSV', label: 'Total SV', group: 'save', special: 'totalSV',
weight: 0.5, scoreDefault: false,
compute: function ( item, level ) {
var total = 0;
STATS.forEach( function ( s ) {
if ( s.group === 'save' ) { total += scaledStat( item, s.key, level ); }
} );
return total;
}
} );
STATS.forEach( function ( s ) {
if ( s.score && s.group === 'save' ) {
list.push( {
key: s.key, label: s.label, group: 'save', saveStat: true,
weight: s.weight, scoreDefault: s.scoreDefault,
compute: function ( item, level ) { return scaledStat( item, s.key, level ); }
} );
}
} );
list.push( {
key: 'svVoid', label: 'SV Void', group: 'save', special: 'svVoid',
weight: 0.5, scoreDefault: false,
compute: function ( item, level ) { return svVoidBonus( item, level ); }
} );
 
return list;
}() );
 
function scoreItem( item, opts ) {
function scoreItem( item, opts ) {
opts = opts || {};
opts = opts || {};
var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );
var level = Math.max( 0, Math.min( CONFIG.UPGRADE_MAX, parseInt( opts.level, 10 ) || 0 ) );
var comp = opts.components || defaultComponents();
var comp = opts.components || {};
var weights = opts.weights || {};
var effectBonus = opts.effectBonus || {};
var effectBonus = opts.effectBonus || {};
var totalSvOn = !!comp.totalSV;
var breakdown = {};
var breakdown = {};
var score = 0;
var score = 0;


var weights = opts.weights || {};
SCORE_COMPONENTS.forEach( function ( c ) {
function w( key ) {
if ( !comp[ c.key ] ) {
var x = weights[ key ];
return;
return ( x == null || isNaN( x ) ) ? 1 : Number( x );
}
}
if ( c.special === 'totalSV' && !totalSvOn ) {
 
return;
// value is the raw scaled stat; it is multiplied by the component weight.
}
function add( name, value ) {
if ( c.saveStat && totalSvOn ) {
var weighted = value * w( name );
return; // individual saves are folded into Total SV
}
var raw = c.compute( item, level );
var wt = weights[ c.key ];
wt = ( wt == null || isNaN( wt ) ) ? c.weight : Number( wt );
var weighted = raw * wt;
if ( weighted ) {
if ( weighted ) {
breakdown[ name ] = weighted;
breakdown[ c.key ] = weighted;
score += weighted;
score += weighted;
}
}
var svKeys = {
svMagic: 'SV_MAGIC', svFire: 'SV_FIRE', svCold: 'SV_COLD',
svPoison: 'SV_POISON', svDisease: 'SV_DISEASE'
};
var simpleKeys = {
ac: 'AC', str: 'STR', sta: 'STA', agi: 'AGI', dex: 'DEX',
int: 'INT', wis: 'WIS', cha: 'CHA'
};
var scaledDmg = scaledStat( item, 'DMG', level );
if ( comp.ratio && item.delay > 0 && scaledDmg > 0 ) {
// Ratio score = (damage / delay) * 10  (damage/delay per user; *10 to scale up)
add( 'ratio', ( scaledDmg / item.delay ) * 10 );
}
if ( comp.dmg ) {
add( 'dmg', scaledDmg );
}
if ( comp.delay && item.delay > 0 ) {
// Lower delay is better; contribute inverse so faster weapons score higher.
add( 'delay', 100 / item.delay );
}
Object.keys( simpleKeys ).forEach( function ( ck ) {
if ( comp[ ck ] ) {
add( ck, scaledStat( item, simpleKeys[ ck ], level ) );
}
}
} );
} );


if ( comp.totalSV ) {
EFFECT_TYPES.forEach( function ( t ) {
var svTotal = 0;
Object.keys( svKeys ).forEach( function ( sk ) {
svTotal += scaledStat( item, svKeys[ sk ], level );
} );
add( 'totalSV', svTotal );
} else {
Object.keys( svKeys ).forEach( function ( sk ) {
if ( comp[ sk ] ) {
add( sk, scaledStat( item, svKeys[ sk ], level ) );
}
} );
}
 
if ( comp.svVoid ) {
add( 'svVoid', svVoidBonus( item, level ) );
}
 
// Effect flat bonuses (per present effect type).
[ 'worn', 'combat', 'click' ].forEach( function ( t ) {
if ( item.effectTypes[ t ] && effectBonus[ t ] ) {
if ( item.effectTypes[ t ] && effectBonus[ t ] ) {
add( 'effect_' + t, effectBonus[ t ] );
breakdown[ 'effect_' + t ] = effectBonus[ t ];
score += effectBonus[ t ];
}
}
} );
} );
if ( item.effectTypes.focus && effectBonus.focus ) {
add( 'effect_focus', effectBonus.focus );
}


return {
return {
score: Math.round( score * 1000 ) / 1000,
score: Math.round( score * CONFIG.SCORE_ROUND ) / CONFIG.SCORE_ROUND,
breakdown: breakdown
breakdown: breakdown
};
};
}
// ---- Weapon ratio helper (for the display column) -----------------------
function weaponRatio( item, level ) {
var dmg = scaledStat( item, 'DMG', level || 0 );
if ( !item.delay || dmg <= 0 ) {
return null;
}
return dmg / item.delay;
}
}


var EQLIS = {
var EQLIS = {
MAX_LEVEL: MAX_LEVEL,
MAX_LEVEL: MAX_LEVEL,
STATS: STATS,
SCORE_COMPONENTS: SCORE_COMPONENTS,
EFFECT_TYPES: EFFECT_TYPES,
PRIMARY_STATS: PRIMARY_STATS,
PRIMARY_STATS: PRIMARY_STATS,
SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,
SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,
Line 396: Line 401:
canonicalStatKey: canonicalStatKey,
canonicalStatKey: canonicalStatKey,
parseItemData: parseItemData,
parseItemData: parseItemData,
defaultComponents: defaultComponents,
scaledStat: scaledStat,
scaledStat: scaledStat,
svVoidBonus: svVoidBonus,
svVoidBonus: svVoidBonus,
Line 408: Line 412:
var MOUNT_ID = 'eql-item-score-table';
var MOUNT_ID = 'eql-item-score-table';


// ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).
/* MIRROR of ext.classSlotEquip.js `slots` / `classes` — keep in sync until that
* module exposes them. These are the exact slot/class values it accepts. */
var SLOTS = [
var SLOTS = [
'Primary', 'One-Handed', 'Two-Handed', 'Secondary', 'Range', 'Ammo',
'Primary', 'One-Handed', 'Two-Handed', 'Secondary', 'Range', 'Ammo',
Line 528: Line 533:
}() );
}() );


// Canonical ZONE_AVG_LEVEL name for any wiki zone string, or null if out of scope.
function canonicalZone( name ) {
function canonicalZone( name ) {
if ( !name ) {
if ( !name ) {
Line 536: Line 540:
}
}


// Average mob level for any wiki zone string, or null if out of scope.
// enrich:true columns are populated by the enrichment phase (rendered '—' until then).
function zoneAverageLevel( name ) {
var COLUMNS = ( function () {
var canonical = canonicalZone( name );
var lead = [
return canonical ? ZONE_AVG_LEVEL[ canonical ] : null;
{ key: 'name', label: 'Name', always: true },
}
{ key: 'score', label: 'Item Score', always: true },
 
{ key: 'slot', label: 'Slot', columnDefault: true },
function zoneDisplayNames() {
{ key: 'type', label: 'Type', columnDefault: true },
return Object.keys( ZONE_AVG_LEVEL ).sort();
{ key: 'focus', label: 'Focus', enrich: true, columnDefault: false },
}
{ key: 'worn', label: 'Worn', columnDefault: true },
 
{ key: 'click', label: 'Click', columnDefault: true },
// Score component checkboxes. `sv` marks the mutually-exclusive save group.
{ key: 'combat', label: 'Combat', columnDefault: true },
var SCORE_COMPONENTS = [
{ key: 'ratio', label: 'Ratio', columnDefault: true },
{ key: 'ratio', label: 'Weapon Ratio (dmg/delay)' },
{ key: 'DMG', label: 'DMG', columnDefault: true },
{ key: 'dmg', label: 'Weapon Damage' },
{ key: 'DELAY', label: 'Delay', columnDefault: true }
{ key: 'delay', label: 'Weapon Delay' },
];
{ key: 'ac', label: 'AC' },
var statCols = STATS.filter( function ( s ) {
{ key: 'str', label: 'Strength' },
// DMG and DELAY are placed with the weapon columns above.
{ key: 'dex', label: 'Dexterity' },
return s.column && s.key !== 'DMG' && s.key !== 'DELAY';
{ key: 'agi', label: 'Agility' },
} ).map( function ( s ) {
{ key: 'sta', label: 'Stamina' },
return { key: s.key, label: s.label, columnDefault: s.columnDefault };
{ key: 'int', label: 'Intelligence' },
} );
{ key: 'wis', label: 'Wisdom' },
var trail = [
{ key: 'cha', label: 'Charisma' }
{ key: 'svVoid', label: 'SV Void', columnDefault: true },
];
{ key: 'source', label: 'Source', columnDefault: true },
var SV_COMPONENTS = [
{ key: 'level', label: 'Level', enrich: true, columnDefault: false },
{ key: 'svMagic', label: 'SV Magic' },
{ key: 'zone', label: 'Zone', enrich: true, columnDefault: false },
{ key: 'svFire', label: 'SV Fire' },
{ key: 'zoneAvg', label: 'Zone Avg Lvl', enrich: true, columnDefault: false }
{ key: 'svCold', label: 'SV Cold' },
];
{ key: 'svPoison', label: 'SV Poison' },
return lead.concat( statCols ).concat( trail );
{ key: 'svDisease', label: 'SV Disease' },
}() );
{ key: 'svVoid', label: 'SV Void' }
];
var EFFECT_TYPES = [ 'focus', 'worn', 'click', 'combat' ];
 
// Result columns. `enrich:true` = populated by the (future) enrichment phase.
var COLUMNS = [
{ key: 'name', label: 'Name', always: true },
{ key: 'score', label: 'Item Score', always: true },
{ key: 'slot', label: 'Slot' },
{ key: 'type', label: 'Type' },
{ key: 'focus', label: 'Focus', enrich: true },
{ key: 'worn', label: 'Worn' },
{ key: 'click', label: 'Click' },
{ key: 'combat', label: 'Combat' },
{ key: 'ratio', label: 'Ratio' },
{ key: 'DMG', label: 'DMG' },
{ key: 'delay', label: 'Delay' },
{ key: 'AC', label: 'AC' },
{ key: 'STR', label: 'STR' },
{ key: 'DEX', label: 'DEX' },
{ key: 'AGI', label: 'AGI' },
{ key: 'STA', label: 'STA' },
{ key: 'INT', label: 'INT' },
{ key: 'WIS', label: 'WIS' },
{ key: 'CHA', label: 'CHA' },
{ key: 'SV_MAGIC', label: 'SV Mag' },
{ key: 'SV_FIRE', label: 'SV Fire' },
{ key: 'SV_COLD', label: 'SV Cold' },
{ key: 'SV_POISON', label: 'SV Poi' },
{ key: 'SV_DISEASE', label: 'SV Dis' },
{ key: 'svVoid', label: 'SV Void' },
{ key: 'source', label: 'Source' },
{ key: 'level', label: 'Level', enrich: true },
{ key: 'zone', label: 'Zone', enrich: true },
{ key: 'zoneAvg', label: 'Zone Avg Lvl', enrich: true }
];


// ---- state / persistence ------------------------------------------------
// ---- state / persistence ------------------------------------------------
Line 609: Line 577:
var weights = {};
var weights = {};
SCORE_COMPONENTS.forEach( function ( c ) {
SCORE_COMPONENTS.forEach( function ( c ) {
comps[ c.key ] = false;
comps[ c.key ] = !!c.scoreDefault;
weights[ c.key ] = 1;      // stat / weapon components default weight 1
weights[ c.key ] = c.weight;
} );
SV_COMPONENTS.forEach( function ( c ) {
comps[ c.key ] = false;
weights[ c.key ] = 0.5;   // resist (SV) components default weight 0.5
} );
} );
comps.totalSV = false;
weights.totalSV = 0.5;        // Total SV behaves like a resist
var cols = {};
var cols = {};
COLUMNS.forEach( function ( c ) {
COLUMNS.forEach( function ( c ) {
cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1
cols[ c.key ] = c.always ? true : !!c.columnDefault;
} );
var effectBonus = {};
EFFECT_TYPES.forEach( function ( t ) {
effectBonus[ t ] = EFFECTS[ t ].bonus;
} );
} );
return {
return {
Line 628: Line 594:
weaponTypes: [],
weaponTypes: [],
zones: [],
zones: [],
minLevel: 1,
minLevel: CONFIG.LEVEL_MIN,
maxLevel: 100,
maxLevel: CONFIG.LEVEL_MAX,
components: comps,
components: comps,
componentWeights: weights,
componentWeights: weights,
effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },
effectBonus: effectBonus,
columns: cols,
columns: cols,
columnOrder: COLUMNS.map( function ( c ) { return c.key; } )
columnOrder: COLUMNS.map( function ( c ) { return c.key; } )
Line 665: Line 631:
}
}


// Parse one ClassSlotEquip HTML fragment into item records.
function parseFragment( html, slot ) {
function parseFragment( html, slot ) {
var $frag = $( '<div>' ).html( html );
var $frag = $( '<div>' ).html( html );
Line 699: Line 664:
source: { type: sourceType, text: srcText, html: $src.html() },
source: { type: sourceType, text: srcText, html: $src.html() },
// enrichment placeholders. available: true|false|null (null = unknown)
// enrichment placeholders. available: true|false|null (null = unknown)
enrich: { focus: null, level: null, zone: null, available: null }
enrich: { level: null, zone: null, available: null }
} );
} );
} );
} );
Line 705: Line 670:
}
}


// ---- enrichment: item -> zone + availability (Phase 1, via categories) ----
// ---- enrichment: zone + availability (item categories) ----


function titleFromHref( href, name ) {
function titleFromHref( href, name ) {
Line 725: Line 690:
}
}


// Batched prop=categories with redirect/normalize + continuation handling.
// Resolves to { cats: {title:[categoryName,...]}, alias: {from:to} }.
// Resolves to { cats: {finalTitle:[categoryName,...]}, alias: {from:to} }.
function fetchCategories( titles ) {
function fetchCategories( titles ) {
var result = {};
var result = {};
Line 776: Line 740:
}
}


// Set item.enrich.zone / .available from each item's categories.
// Availability (Requirement #1): a drop is in-scope if a zone category is in
// Availability = "drops in a zone that is in ZONE_AVG_LEVEL" (Requirement #1).
// ZONE_AVG_LEVEL. Quest items have no zone category — left unresolved here for
// Quest items carry no zone category (zone is on the quest page) — kept as
// enrichLevels to settle from the quest page.
// available:null pending Phase 2 quest resolution rather than excluded.
function enrichZones( items ) {
function enrichZones( items ) {
var titles = [];
var titles = [];
Line 825: Line 788:
return items; // enrichment failed -> keep everything, unfiltered
return items; // enrichment failed -> keep everything, unfiltered
} );
} );
}
// ---- enrichment: level (mob median for drops, quest min-level for quests) ----
function median( nums ) {
if ( !nums.length ) {
return null;
}
var s = nums.slice().sort( function ( a, b ) { return a - b; } );
var m = Math.floor( s.length / 2 );
return s.length % 2 ? s[ m ] : Math.round( ( s[ m - 1 ] + s[ m ] ) / 2 );
}
// Mob/quest pages transclude the item ({{:Item}}), so transcludedin maps an
// item to the pages that drop or reward it.
function fetchTranscludedIn( titles ) {
var map = {};
var alias = {};
var batches = [];
var i;
for ( i = 0; i < titles.length; i += 50 ) {
batches.push( titles.slice( i, i + 50 ) );
}
return mw.loader.using( [ 'mediawiki.api' ] ).then( function () {
var api = new mw.Api();
function runBatch( batch, cont ) {
var params = {
action: 'query', prop: 'transcludedin', titles: batch.join( '|' ),
tinamespace: 0, tilimit: 'max', redirects: 1, format: 'json'
};
if ( cont ) { params.ticontinue = cont; }
return api.get( params ).then( function ( data ) {
var q = data.query || {};
( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );
( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );
var pages = q.pages || {};
Object.keys( pages ).forEach( function ( pid ) {
var pg = pages[ pid ];
if ( !pg.title ) { return; }
var arr = map[ pg.title ] = map[ pg.title ] || [];
( pg.transcludedin || [] ).forEach( function ( t ) { arr.push( t.title ); } );
} );
if ( data.continue && data.continue.ticontinue ) {
return runBatch( batch, data.continue.ticontinue );
}
} );
}
return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );
} ).then( function () { return { map: map, alias: alias }; } );
}
function fetchWikitext( titles ) {
var content = {};
var batches = [];
var i;
for ( i = 0; i < titles.length; i += 50 ) {
batches.push( titles.slice( i, i + 50 ) );
}
return mw.loader.using( [ 'mediawiki.api' ] ).then( function () {
var api = new mw.Api();
return $.when.apply( $, batches.map( function ( batch ) {
return api.get( {
action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main',
titles: batch.join( '|' ), format: 'json'
} ).then( function ( data ) {
var pages = ( data.query || {} ).pages || {};
Object.keys( pages ).forEach( function ( pid ) {
var pg = pages[ pid ];
var rev = pg.revisions && pg.revisions[ 0 ];
var main = rev && rev.slots && rev.slots.main;
if ( pg.title && main ) { content[ pg.title ] = main[ '*' ] || ''; }
} );
} );
} ) );
} ).then( function () { return content; } );
}
// A page transcluding an item is its mob ({{Namedmobpage}}, has | level =) or
// its quest (questTopTable, with Minimum Level / Start Zone).
function parseSourcePage( text ) {
var out = { kind: null, level: null, zone: null };
var lvl = text.match( /\|\s*level\s*=\s*(\d+)/i );
if ( /Namedmobpage/i.test( text ) || lvl ) {
out.kind = 'mob';
if ( lvl ) { out.level = parseInt( lvl[ 1 ], 10 ); }
var z = text.match( /\|\s*zone\s*=\s*\[\[\s*([^\]|]+)/i );
if ( z ) { out.zone = z[ 1 ].trim(); }
return out;
}
if ( /Minimum Level:|Start Zone:|questTopTable/i.test( text ) ) {
out.kind = 'quest';
var ml = text.match( /Minimum Level:[\s\S]*?\|\s*(\d+)/i );
if ( ml ) { out.level = parseInt( ml[ 1 ], 10 ); }
var sz = text.match( /Start Zone:[\s\S]*?\|\s*\[\[\s*([^\]|]+)/i );
if ( sz ) { out.zone = sz[ 1 ].trim(); }
}
return out;
}
function applyLevelInfo( it, infos ) {
var mobLevels = infos
.filter( function ( x ) { return x.kind === 'mob' && x.level != null; } )
.map( function ( x ) { return x.level; } );
if ( mobLevels.length ) {
it.enrich.level = median( mobLevels );
return;
}
var quest = infos.filter( function ( x ) { return x.kind === 'quest'; } )[ 0 ];
if ( quest ) {
it.enrich.level = quest.level != null ? quest.level : 'quest';
if ( quest.zone ) {
var cz = canonicalZone( quest.zone );
it.enrich.zone = cz || quest.zone;
// hide a quest whose listed start zone is out of scope; keep in-scope
// (or unlisted) quests.
it.enrich.available = cz ? true : false;
}
}
}
function enrichLevels( items ) {
var titles = [];
items.forEach( function ( it ) {
if ( it.title && titles.indexOf( it.title ) === -1 ) { titles.push( it.title ); }
} );
if ( !titles.length ) {
return $.Deferred().resolve( items ).promise();
}
return fetchTranscludedIn( titles ).then( function ( ti ) {
var pageSet = {};
Object.keys( ti.map ).forEach( function ( t ) {
ti.map[ t ].forEach( function ( p ) { pageSet[ p ] = true; } );
} );
var pages = Object.keys( pageSet );
if ( !pages.length ) {
return items;
}
return fetchWikitext( pages ).then( function ( wt ) {
var info = {};
pages.forEach( function ( p ) { info[ p ] = parseSourcePage( wt[ p ] || '' ); } );
items.forEach( function ( it ) {
var srcTitle = ti.alias[ it.title ] || it.title;
var infos = ( ti.map[ srcTitle ] || ti.map[ it.title ] || [] )
.map( function ( p ) { return info[ p ]; } )
.filter( Boolean );
applyLevelInfo( it, infos );
} );
return items;
} );
}, function () { return items; } );
}
}


var ClientDataSource = {
var ClientDataSource = {
/**
* fetchItems(query) -> Promise<Array<itemRecord>>
* query: { classes:[a,b,c], slots:[...] }
* Fans out one request per slot, merges, de-dupes by name+slot,
* then enriches each item with zone + availability.
*/
fetchItems: function ( query ) {
fetchItems: function ( query ) {
var slots = query.slots && query.slots.length ? query.slots : [ 'Primary' ];
var slots = query.slots && query.slots.length ? query.slots : [ 'Primary' ];
Line 865: Line 972:
} );
} );
return merged;
return merged;
} ).then( enrichZones );
} ).then( enrichZones ).then( enrichLevels );
}
}
};
};
Line 902: Line 1,009:
return false;
return false;
}
}
// Weapon type
if ( state.weaponTypes.length ) {
if ( state.weaponTypes.length ) {
var t = normalizeType( item.fields.type );
var t = normalizeType( item.fields.type );
Line 909: Line 1,015:
}
}
}
}
// Level range (enrichment-dependent). In v1, item.enrich.level is null;
// Quest items may carry level 'quest' (a string) rather than a number.
// only filter when we actually have a level so we don't hide everything.
var lvl = item.enrich.level;
var lvl = item.enrich.level;
if ( lvl != null ) {
if ( typeof lvl === 'number' ) {
if ( lvl < state.minLevel || lvl > state.maxLevel ) {
if ( lvl < state.minLevel || lvl > state.maxLevel ) {
return false;
return false;
Line 929: Line 1,034:
}
}
return true;
return true;
}
function scoreOpts( state ) {
return {
level: state.level,
components: state.components,
weights: state.componentWeights,
effectBonus: state.effectBonus
};
}
}


Line 957: Line 1,053:
var r = EQLIS.weaponRatio( item.fields, level );
var r = EQLIS.weaponRatio( item.fields, level );
return r == null ? '' : r.toFixed( 3 );
return r == null ? '' : r.toFixed( 3 );
}
if ( key === 'delay' ) {
return item.fields.delay || '';
}
}
if ( key === 'svVoid' ) {
if ( key === 'svVoid' ) {
Line 965: Line 1,058:
return v ? '+' + v : '';
return v ? '+' + v : '';
}
}
// scalable stat key (DMG / AC / STR / SV_*)
// scalable/parsed stat key (DMG / DELAY / AC / STR / SV_*).
// DELAY has scaling 'none', so scaledStat returns the raw delay.
if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {
if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {
return EQLIS.scaledStat( item.fields, key, level );
return EQLIS.scaledStat( item.fields, key, level );
Line 988: Line 1,082:
}
}


// Reconcile a saved columnOrder with the current COLUMNS set: drop unknown/dupe
// Migration: reconcile a saved columnOrder against the current COLUMNS.
// keys, append any columns missing from the saved order.
function normalizeColumnOrder( state ) {
function normalizeColumnOrder( state ) {
var seen = {};
var seen = {};
Line 1,025: Line 1,118:
var $tbody = el( 'tbody' );
var $tbody = el( 'tbody' );


// score + filter, then sort by score desc for a useful default
var opts = {
level: state.level,
components: state.components,
weights: state.componentWeights,
effectBonus: state.effectBonus
};
var rows = items
var rows = items
.filter( function ( it ) { return passesFilters( it, state ); } )
.filter( function ( it ) { return passesFilters( it, state ); } )
.map( function ( it ) {
.map( function ( it ) {
it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).score;
it._score = EQLIS.scoreItem( it.fields, opts ).score;
return it;
return it;
} )
} )
Line 1,057: Line 1,155:
$td.text( normalizeType( it.fields.type ) );
$td.text( normalizeType( it.fields.type ) );
break;
break;
case 'focus':
case 'focus': case 'worn': case 'click': case 'combat':
$td.text( it.enrich.focus || '' );
break;
case 'worn': case 'click': case 'combat':
$td.text( effectCell( it, c.key ) );
$td.text( effectCell( it, c.key ) );
break;
break;
Line 1,073: Line 1,168:
break;
break;
case 'zoneAvg':
case 'zoneAvg':
var avg = zoneAverageLevel( it.enrich.zone );
var cz = canonicalZone( it.enrich.zone );
$td.text( avg == null ? '—' : avg );
$td.text( cz ? ZONE_AVG_LEVEL[ cz ] : '—' );
break;
break;
default:
default:
Line 1,112: Line 1,207:
$root.append( el( 'div', { 'class': 'eqlis-subtitle' },
$root.append( el( 'div', { 'class': 'eqlis-subtitle' },
'Pick classes/slots and scoring options, then press List Results. ' +
'Pick classes/slots and scoring options, then press List Results. ' +
'Out-of-era drops are filtered out; quest items and the Level / Focus ' +
'Out-of-scope zones are filtered out. The first List Results is slower — ' +
'columns are still being wired up.' ) );
'it resolves each item\'s zone and mob/quest level via the API.' ) );


var $controls = el( 'div', { 'class': 'eqlis-controls' } );
var $controls = el( 'div', { 'class': 'eqlis-controls' } );
Line 1,163: Line 1,258:
var $zoneWrap = el( 'div', { 'class': 'eqlis-field' } );
var $zoneWrap = el( 'div', { 'class': 'eqlis-field' } );
$zoneWrap.append( el( 'label', null, 'Zone: ' ) );
$zoneWrap.append( el( 'label', null, 'Zone: ' ) );
$zoneWrap.append( multiSelect( 'eqlis-zones', zoneDisplayNames(), state.zones ) );
$zoneWrap.append( multiSelect( 'eqlis-zones', Object.keys( ZONE_AVG_LEVEL ).sort(), state.zones ) );
$col2.append( $zoneWrap );
$col2.append( $zoneWrap );


Line 1,184: Line 1,279:
}
}


SCORE_COMPONENTS.forEach( function ( c ) { $score.append( scoreRow( c ) ); } );
// Saves + SV Void render indented under Total SV.
var $totalCb = null;
SCORE_COMPONENTS.forEach( function ( c ) {
var indent = ( c.group === 'save' && c.special !== 'totalSV' );
var $row = scoreRow( c, indent ? 'eqlis-sv' : '' );
$score.append( $row );
if ( c.special === 'totalSV' ) {
$totalCb = $row.find( 'input[data-comp]' );
}
} );


var $totalRow = scoreRow( { key: 'totalSV', label: 'Total SV (sum of saves)' } );
// disable individual saves (not SV Void) when Total SV is on
$score.append( $totalRow );
var $totalCb = $totalRow.find( 'input[data-comp]' );
SV_COMPONENTS.forEach( function ( c ) { $score.append( scoreRow( c, 'eqlis-sv' ) ); } );
 
// disable individual saves when Total SV is on
function syncSvDisabled() {
function syncSvDisabled() {
if ( !$totalCb ) { return; }
var on = $totalCb.prop( 'checked' );
var on = $totalCb.prop( 'checked' );
$score.find( '.eqlis-sv input[data-comp]' ).each( function () {
$score.find( '.eqlis-sv input[data-comp]' ).each( function () {
Line 1,200: Line 1,300:
} );
} );
}
}
$totalCb.on( 'change', syncSvDisabled );
if ( $totalCb ) {
$totalCb.on( 'change', syncSvDisabled );
}
syncSvDisabled();
syncSvDisabled();


Line 1,314: Line 1,416:
$root.append( $controls );
$root.append( $controls );


// List Results button + status + results container
var $btn = el( 'button', { type: 'button', 'class': 'eqlis-run' }, 'List Results' );
var $btn = el( 'button', { type: 'button', 'class': 'eqlis-run' }, 'List Results' );
var $status = el( 'div', { 'class': 'eqlis-status' } );
var $status = el( 'div', { 'class': 'eqlis-status' } );
Line 1,320: Line 1,421:
$root.append( $btn ).append( $status ).append( $results );
$root.append( $btn ).append( $status ).append( $results );


// ---- read UI back into state ----
function collect() {
function collect() {
state.level = parseInt( $lvl.val(), 10 ) || 0;
state.level = parseInt( $lvl.val(), 10 ) || 0;

Revision as of 16:30, 24 July 2026

/**
 * ext.itemScoreTable — EQL Equipment Analyzer / Item Score guide.
 *
 * Mounts into <div id="eql-item-score-table">. All data access goes through
 * ACTIVE_DATASOURCE so a server-side Special:ItemScoreTable can later replace
 * ClientDataSource with a one-line change.
 */
( function ( $, mw ) {
	'use strict';

	// Styles are injected from JS — the module has no separate CSS channel.
	( function () {
		var s = document.createElement( 'style' );
		s.setAttribute( 'data-eqlis', '1' );
		s.textContent = "/* EQL Item Score Table \u2014 styles (paste into User:<you>/common.css, optional).\n   Mirrors the wiki's immersive gold/dark palette. */\n\n.eqlis-app { margin: 1.25rem 0; padding: 1rem; background: linear-gradient(180deg, rgba(13,18,26,0.9), rgba(8,12,18,0.95)); border: 1px solid rgba(216,183,92,0.34); border-radius: 10px; color: #cdd3df; box-sizing: border-box; }\n.eqlis-title { color: #d8b75c; font-family: Georgia, serif; font-size: 1.5rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }\n.eqlis-subtitle { color: #8894a8; margin-bottom: 0.75rem; }\n\n.eqlis-controls { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 1.4rem; margin-bottom: 0.75rem; }\n.eqlis-col { display: flex; flex-direction: column; gap: 0.9rem; min-width: 170px; }\n.eqlis-field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }\n.eqlis-wt { width: 3.2rem; margin-left: auto; background: rgba(8,12,18,0.88); border: 1px solid rgba(216,183,92,0.28); border-radius: 4px; color: #cdd3df; padding: 0 0.25rem; }\n.eqlis-field > label, .eqlis-legend { color: #d8b75c; font-weight: 700; font-size: 0.82rem; letter-spacing: 0.03em; text-transform: uppercase; }\n.eqlis-single, .eqlis-multi, .eqlis-num { background: rgba(8,12,18,0.88); border: 1px solid rgba(216,183,92,0.28); border-radius: 6px; color: #cdd3df; padding: 0.35rem 0.45rem; }\n.eqlis-single { margin-right: 0.35rem; }\n.eqlis-num { width: 4.5rem; }\n.eqlis-level-out { color: #ffd700; font-weight: bold; margin-left: 0.4rem; }\n\n.eqlis-scorebox, .eqlis-colbox { max-width: 22rem; }\n.eqlis-check { display: flex; align-items: center; gap: 0.25rem; margin: 0.1rem 0; font-size: 0.85rem; color: #cdd3df; font-weight: normal; text-transform: none; letter-spacing: 0; }\n.eqlis-sv { padding-left: 1rem; }\n\n.eqlis-run { margin: 0.5rem 0; padding: 0.5rem 1.2rem; background: linear-gradient(180deg, rgba(147,197,114,0.96), rgba(61,117,62,0.98)); border: 1px solid rgba(245,220,150,0.82); border-radius: 999px; color: #0d1220; font-weight: 700; cursor: pointer; }\n.eqlis-run:disabled { opacity: 0.6; cursor: default; }\n.eqlis-status { min-height: 1.2rem; color: #8894a8; }\n.eqlis-status.eqlis-err { color: #ff9b9b; }\n\n.eqlis-count { color: #8894a8; margin: 0.4rem 0; }\n.eqlis-results { max-width: 100%; overflow-x: auto; }\n.eqlis-results table.eqlis-results { width: max-content; min-width: 100%; }\n.eqlis-results th { white-space: nowrap; }\n.eqlis-score { color: #ffd700; font-weight: bold; font-variant-numeric: tabular-nums; }\n.eqlis-dual { display: flex; align-items: center; gap: 0.4rem; }\n.eqlis-dual-side { display: flex; flex-direction: column; gap: 0.2rem; }\n.eqlis-dual-cap { color: #d8b75c; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; }\n.eqlis-dual-list { min-width: 8.5rem; background: rgba(8,12,18,0.88); border: 1px solid rgba(216,183,92,0.28); border-radius: 6px; color: #cdd3df; padding: 0.2rem; }\n.eqlis-dual-mid, .eqlis-dual-ord { display: flex; flex-direction: row; gap: 0.25rem; justify-content: center; margin-top: 0.2rem; }\n.eqlis-dual-mid { flex-direction: column; }\n.eqlis-dual-btn { background: #202044; color: #ffd700; border: 1px solid #6a5acd; border-radius: 4px; cursor: pointer; line-height: 1; padding: 3px 7px; }\n.eqlis-dual-btn:hover { background: #2c2c5c; }\n";
		( document.head || document.documentElement ).appendChild( s );
	}() );


	// Only truly-global tunables live here. Anything specific to a stat, a score
	// component, or an effect lives ON that stat/component/effect (STATS, the
	// SCORE_COMPONENTS entries, EFFECTS) — the same way DMG's params live on its row.
	var CONFIG = {
		UPGRADE_MAX: 10,   // item upgrade slider range 0..UPGRADE_MAX
		LEVEL_MIN: 1,      // Level min/max filter bounds
		LEVEL_MAX: 100,
		SCORE_ROUND: 1000  // score rounded to 1/SCORE_ROUND
	};

	var MAX_LEVEL = CONFIG.UPGRADE_MAX;

	/*
	 * SINGLE SOURCE OF TRUTH for every stat. Each entry owns everything about that
	 * stat: parse aliases, scaling class, SV-Void group, default score weight, and
	 * default enablement as a score component / table column. STAT_ALIASES,
	 * PRIMARY_STATS, SV_VOID_QUALIFIERS, the stat columns and stat score-components
	 * are all DERIVED from this — nothing about a stat is listed twice.
	 *
	 * group:   attr | save | defense | weapon  (drives SV-Void qualification)
	 * scaling: primary | damage | none          (mirror of ext.itemLevelSlider rules)
	 */
	var STATS = [
		{ key: 'DMG', label: 'DMG', aliases: [ 'DMG', 'DAMAGE' ], group: 'weapon', scaling: 'damage', weight: 1, column: true, columnDefault: true, score: false, scoreDefault: false },
		// delay is parsed like DMG (alias "Atk Delay"); ItemLevelSlider leaves it
		// unchanged (scaling 'none'). scoreScale is its scoring param (score = scale/delay).
		{ key: 'DELAY', label: 'Delay', aliases: [ 'ATK DELAY' ], group: 'weapon', scaling: 'none', weight: 1, scoreScale: 100, column: true, columnDefault: true, score: false, scoreDefault: false },
		{ key: 'AC', label: 'AC', aliases: [ 'AC', 'ARMOR CLASS' ], group: 'defense', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'STR', label: 'STR', aliases: [ 'STR', 'STRENGTH' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'STA', label: 'STA', aliases: [ 'STA', 'STAMINA' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'AGI', label: 'AGI', aliases: [ 'AGI', 'AGILITY' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'DEX', label: 'DEX', aliases: [ 'DEX', 'DEXTERITY' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'INT', label: 'INT', aliases: [ 'INT', 'INTELLIGENCE' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'WIS', label: 'WIS', aliases: [ 'WIS', 'WISDOM' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'CHA', label: 'CHA', aliases: [ 'CHA', 'CHARISMA' ], group: 'attr', scaling: 'primary', weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'SV_MAGIC', label: 'SV Mag', aliases: [ 'SV MAGIC', 'MAGIC' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'SV_FIRE', label: 'SV Fire', aliases: [ 'SV FIRE', 'FIRE' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'SV_COLD', label: 'SV Cold', aliases: [ 'SV COLD', 'COLD' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'SV_POISON', label: 'SV Poi', aliases: [ 'SV POISON', 'POISON' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },
		{ key: 'SV_DISEASE', label: 'SV Dis', aliases: [ 'SV DISEASE', 'DISEASE' ], group: 'save', scaling: 'primary', weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false }
	];

	var STAT_BY_KEY = {};
	STATS.forEach( function ( s ) { STAT_BY_KEY[ s.key ] = s; } );

	// Everything below is derived from STATS.
	var STAT_ALIASES = {};
	STATS.forEach( function ( s ) {
		s.aliases.forEach( function ( a ) { STAT_ALIASES[ a.toUpperCase() ] = s.key; } );
	} );

	// Labels to match in the stat text (longest first so "SV FIRE" beats "FIRE").
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {
		return b.length - a.length;
	} );

	var PRIMARY_STATS = STATS.filter( function ( s ) {
		return s.scaling === 'primary';
	} ).map( function ( s ) { return s.key; } );

	var SV_VOID_QUALIFIERS = STATS.filter( function ( s ) {
		return s.group === 'attr' || s.group === 'save';
	} ).map( function ( s ) { return s.key; } );

	// Effects that add a flat score bonus when present on an item. Each owns its
	// own default bonus (EFFECT_TYPES is derived for code that just needs the keys).
	var EFFECTS = {
		focus: { bonus: 25 },
		worn: { bonus: 25 },
		click: { bonus: 25 },
		combat: { bonus: 25 }
	};
	var EFFECT_TYPES = Object.keys( EFFECTS );

	/* =====================================================================
	 * MIRROR of ext.itemLevelSlider.js — keep in sync until that module
	 * exposes these. excelRound / intFloor / scalePrimaryStat / scaleDamage
	 * are copied verbatim from its scaling logic. This is the ONLY copy of
	 * that math in this file; do not re-implement it elsewhere.
	 * ===================================================================== */

	// ---- Excel-parity math ----

	function excelRound( value, digits ) {
		var factor = Math.pow( 10, digits || 0 );
		var scaled = value * factor;
		if ( value >= 0 ) {
			return Math.floor( scaled + 0.5 ) / factor;
		}
		return Math.ceil( scaled - 0.5 ) / factor;
	}

	function intFloor( value ) {
		return Math.floor( value );
	}

	// ---- Scaling formulas ----

	function scalePrimaryStat( signedBase, level ) {
		if ( signedBase === 0 ) {
			return 0;
		}
		if ( signedBase > 0 && signedBase <= 10 ) {
			return signedBase + level;
		}
		if ( signedBase > 10 ) {
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );
		}
		return Math.min( 0, signedBase + level );
	}

	function scaleDamage( signedBase, level ) {
		if ( signedBase <= 0 ) {
			return signedBase;
		}
		return signedBase + intFloor( signedBase * level / 10 );
	}

	// ---- Parsing ------------------------------------------------------------

	function canonicalStatKey( label ) {
		var name = String( label || '' ).toUpperCase().replace( /\s+/g, ' ' ).trim();
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, '_' );
	}

	function detectEffectType( descriptor ) {
		var d = String( descriptor || '' ).toLowerCase();
		if ( /\(worn/.test( d ) ) {
			return 'worn';
		}
		if ( /\(combat/.test( d ) ) {
			return 'combat';
		}
		// Click effects render a casting time but aren't Worn/Combat.
		if ( /casting time/.test( d ) ) {
			return 'click';
		}
		return 'click';
	}

	// Callers must strip HTML first. focus_effect isn't in the stat block — enrichment sets it.
	function parseItemData( text ) {
		var raw = String( text || '' ).replace( / /g, ' ' );
		var item = {
			slot: null,
			skill: null,        // e.g. "1H Slashing" (weapon skill line)
			type: 'armor',      // weapon skill, or "armor" when no Skill: line
			stats: {},          // canonical key -> signed base number (incl. DELAY)
			effects: [],        // [{ type, name }]
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }
			flags: {
				magic: /\bMAGIC ITEM\b/i.test( raw ),
				lore: /\bLORE ITEM\b/i.test( raw ),
				noDrop: /\bNO DROP\b/i.test( raw )
			}
		};

		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|<|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );
		if ( slotMatch ) {
			item.slot = slotMatch[ 1 ].trim();
		}

		// Weapon skill / type only. The delay value itself is parsed as the DELAY
		// stat by the generic stat loop below (its alias is "Atk Delay").
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:/i );
		if ( skillMatch ) {
			item.skill = skillMatch[ 1 ].trim();
			item.type = item.skill;
		}

		// Effects come in two formats on the wiki:
		//   1. "Effect: <name> (<Worn|Combat|Casting Time...>)"  -> type from descriptor
		//   2. "<Focus|Worn|Click|Combat> Effect: <name>"        -> type from the word
		function pushEffect( type, name ) {
			name = String( name || '' ).replace( /\s*\(.*$/, '' ).trim();
			if ( !name ) {
				return;
			}
			var dup = item.effects.some( function ( e ) {
				return e.type === type && e.name === name;
			} );
			if ( !dup ) {
				item.effects.push( { type: type, name: name } );
				item.effectTypes[ type ] = true;
			}
		}

		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n<(]+)/gi;
		var tm;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );
		}

		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n<]+?)\s*\(([^)]*)\)/gi;
		var em;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {
			pushEffect( detectEffectType( '(' + em[ 2 ] + ')' ), em[ 1 ] );
		}

		var labelAlt = STAT_LABELS.map( function ( s ) {
			return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
		} ).join( '|' );
		var statRe = new RegExp( '\\b(' + labelAlt + ')\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)', 'gi' );
		var sm;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {
			var key = canonicalStatKey( sm[ 1 ] );
			var value = parseFloat( sm[ 3 ] );
			if ( sm[ 2 ] === '-' ) {
				value = -value;
			}
			item.stats[ key ] = value;
		}

		return item;
	}

	// ---- Scoring ------------------------------------------------------------

	function svVoidBonus( item, level ) {
		if ( level <= 0 ) {
			return 0;
		}
		var count = 0;
		for ( var i = 0; i < SV_VOID_QUALIFIERS.length; i++ ) {
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {
				count++;
			}
		}
		return count >= 2 ? level : 0;
	}

	function scaledStat( item, key, level ) {
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {
			return 0;
		}
		var base = item.stats[ key ];
		var stat = STAT_BY_KEY[ key ];
		if ( !stat ) {
			return base;
		}
		if ( stat.scaling === 'primary' ) {
			return scalePrimaryStat( base, level );
		}
		if ( stat.scaling === 'damage' ) {
			return scaleDamage( base, level );
		}
		return base;
	}

	// ---- Weapon ratio helper (for the display column) -----------------------

	function weaponRatio( item, level ) {
		var dmg = scaledStat( item, 'DMG', level || 0 );
		var delay = item.stats.DELAY;
		if ( !delay || dmg <= 0 ) {
			return null;
		}
		return dmg / delay;
	}

	// Order here is the display order in the score panel.
	var SCORE_COMPONENTS = ( function () {
		var list = [];

		// ratio is fully derived (no parsed stat), so it owns its own scale here.
		list.push( {
			key: 'ratio', label: 'Weapon Ratio', group: 'weapon',
			weight: 1, scoreDefault: false, scale: 25, // ratio score = (dmg/delay) * scale
			compute: function ( item, level ) {
				var dmg = scaledStat( item, 'DMG', level );
				var delay = item.stats.DELAY;
				return ( delay > 0 && dmg > 0 ) ? ( dmg / delay ) * this.scale : 0;
			}
		} );
		list.push( {
			key: 'DMG', label: 'Weapon Damage', group: 'weapon',
			weight: STAT_BY_KEY.DMG.weight, scoreDefault: false,
			compute: function ( item, level ) { return scaledStat( item, 'DMG', level ); }
		} );
		// delay's scoring scale lives on its STATS row (score = scoreScale / delay).
		list.push( {
			key: 'DELAY', label: 'Weapon Delay', group: 'weapon',
			weight: STAT_BY_KEY.DELAY.weight, scoreDefault: false,
			compute: function ( item ) {
				var delay = item.stats.DELAY;
				return delay > 0 ? STAT_BY_KEY.DELAY.scoreScale / delay : 0;
			}
		} );

		STATS.forEach( function ( s ) {
			if ( s.score && s.group !== 'save' ) {
				list.push( {
					key: s.key, label: s.label, group: s.group,
					weight: s.weight, scoreDefault: s.scoreDefault,
					compute: function ( item, level ) { return scaledStat( item, s.key, level ); }
				} );
			}
		} );

		list.push( {
			key: 'totalSV', label: 'Total SV', group: 'save', special: 'totalSV',
			weight: 0.5, scoreDefault: false,
			compute: function ( item, level ) {
				var total = 0;
				STATS.forEach( function ( s ) {
					if ( s.group === 'save' ) { total += scaledStat( item, s.key, level ); }
				} );
				return total;
			}
		} );
		STATS.forEach( function ( s ) {
			if ( s.score && s.group === 'save' ) {
				list.push( {
					key: s.key, label: s.label, group: 'save', saveStat: true,
					weight: s.weight, scoreDefault: s.scoreDefault,
					compute: function ( item, level ) { return scaledStat( item, s.key, level ); }
				} );
			}
		} );
		list.push( {
			key: 'svVoid', label: 'SV Void', group: 'save', special: 'svVoid',
			weight: 0.5, scoreDefault: false,
			compute: function ( item, level ) { return svVoidBonus( item, level ); }
		} );

		return list;
	}() );

	function scoreItem( item, opts ) {
		opts = opts || {};
		var level = Math.max( 0, Math.min( CONFIG.UPGRADE_MAX, parseInt( opts.level, 10 ) || 0 ) );
		var comp = opts.components || {};
		var weights = opts.weights || {};
		var effectBonus = opts.effectBonus || {};
		var totalSvOn = !!comp.totalSV;
		var breakdown = {};
		var score = 0;

		SCORE_COMPONENTS.forEach( function ( c ) {
			if ( !comp[ c.key ] ) {
				return;
			}
			if ( c.special === 'totalSV' && !totalSvOn ) {
				return;
			}
			if ( c.saveStat && totalSvOn ) {
				return; // individual saves are folded into Total SV
			}
			var raw = c.compute( item, level );
			var wt = weights[ c.key ];
			wt = ( wt == null || isNaN( wt ) ) ? c.weight : Number( wt );
			var weighted = raw * wt;
			if ( weighted ) {
				breakdown[ c.key ] = weighted;
				score += weighted;
			}
		} );

		EFFECT_TYPES.forEach( function ( t ) {
			if ( item.effectTypes[ t ] && effectBonus[ t ] ) {
				breakdown[ 'effect_' + t ] = effectBonus[ t ];
				score += effectBonus[ t ];
			}
		} );

		return {
			score: Math.round( score * CONFIG.SCORE_ROUND ) / CONFIG.SCORE_ROUND,
			breakdown: breakdown
		};
	}

	var EQLIS = {
		MAX_LEVEL: MAX_LEVEL,
		STATS: STATS,
		SCORE_COMPONENTS: SCORE_COMPONENTS,
		EFFECT_TYPES: EFFECT_TYPES,
		PRIMARY_STATS: PRIMARY_STATS,
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,
		excelRound: excelRound,
		scalePrimaryStat: scalePrimaryStat,
		scaleDamage: scaleDamage,
		canonicalStatKey: canonicalStatKey,
		parseItemData: parseItemData,
		scaledStat: scaledStat,
		svVoidBonus: svVoidBonus,
		scoreItem: scoreItem,
		weaponRatio: weaponRatio
	};

	// ===================== app layer (UI / data / mount) =====================

	var STORAGE_KEY = 'eqlis-state-v1';
	var MOUNT_ID = 'eql-item-score-table';

	/* MIRROR of ext.classSlotEquip.js `slots` / `classes` — keep in sync until that
	 * module exposes them. These are the exact slot/class values it accepts. */
	var SLOTS = [
		'Primary', 'One-Handed', 'Two-Handed', 'Secondary', 'Range', 'Ammo',
		'Bard Instrument', 'Head', 'Face', 'Ear', 'Neck', 'Shoulders', 'Arms',
		'Back', 'Wrist', 'Hands', 'Fingers', 'Chest', 'Waist', 'Legs', 'Feet'
	];

	var CLASSES = [
		'', 'Bard', 'Beastlord', 'Berserker', 'Cleric', 'Druid', 'Enchanter',
		'Magician', 'Monk', 'Necromancer', 'Paladin', 'Ranger', 'Rogue',
		'Shadow Knight', 'Shaman', 'Warrior', 'Wizard'
	];

	// Weapon types as they appear in the "Skill:" line / categories.
	var WEAPON_TYPES = [
		'1H Blunt', '2H Blunt', '1H Slashing', '2H Slashing',
		'Piercing', '2H Piercing', 'Archery', 'Throwing',
		'Instrument', 'Hand to Hand'
	];

	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md
	// (name -> average mob level). STATIC data (eqltools-derived). This doubles as the
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone
	// filter options, and the "Zone Avg Lvl" column source.
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.
	var ZONE_AVG_LEVEL = {
		'The Feerrott': 4,
		'Toxxulia Forest': 4,
		'Misty Thicket': 5,
		'Qeynos Hills': 6,
		'The Qeynos Aqueduct System': 6,
		'Innothule Swamp': 6,
		'The Steamfont Mountains': 6,
		'The Greater Faydark': 7,
		'Butcherblock Mountains': 7,
		'Nektulos Forest': 7,
		'East Commonlands': 8,
		'Everfrost Peaks': 8,
		'The Northern Desert of Ro': 9,
		'Blackburrow': 10,
		'The Southern Desert of Ro': 10,
		'West Commonlands': 10,
		'The Western Plains of Karana': 10,
		'Erud\'s Crossing': 11,
		'The Warrens': 11,
		'Clan Crushbone': 12,
		'Befallen': 13,
		'The Lavastorm Mountains': 13,
		'Dagnor\'s Cauldron': 13,
		'The Gorge of King Xorbb': 14,
		'The Oasis of Marr': 14,
		'The Eastern Plains of Karana': 14,
		'Highpass Hold': 15,
		'The Lesser Faydark': 15,
		'The Northern Plains of Karana': 15,
		'Lake Rathetear': 17,
		'The Rathe Mountains': 17,
		'Najena': 19,
		'The Liberated Citadel of Runnyeye': 19,
		'The City of Guk': 20,
		'The Southern Plains of Karana': 21,
		'The Stonebrunt Mountains': 21,
		'The Ocean of Tears': 22,
		'The Estate of Unrest': 23,
		'High Keep': 25,
		'Solusek\'s Eye': 25,
		'Temple of Cazic-Thule': 26,
		'Permafrost Keep': 26,
		'Kithicor Forest': 30,
		'The Castle of Mistmoore': 31,
		'The Lair of the Splitpaw': 31,
		'The Temple of Solusek Ro': 35,
		'The Ruins of Old Guk': 36,
		'Kedge Keep': 40,
		'Nagafen\'s Lair': 40,
		'The Ruins of Old Paineel': 45,
		'The Plane of Fear': 50,
		'The Plane of Hate': 52,
		'The Plane of Sky': 54
	};

	// Wiki zone names that don't normalize to a ZONE_AVG_LEVEL key by prefix-stripping
	// alone. Extend as item->zone enrichment surfaces more mismatches.
	var ZONE_ALIASES = {
		'Lower Guk': 'The Ruins of Old Guk',
		'Upper Guk': 'The City of Guk',
		'Guk': 'The City of Guk',
		'Paineel': 'The Ruins of Old Paineel',
		'Runnyeye': 'The Liberated Citadel of Runnyeye',
		'Runnyeye Citadel': 'The Liberated Citadel of Runnyeye',
		'Castle Mistmoore': 'The Castle of Mistmoore',
		'Mistmoore': 'The Castle of Mistmoore',
		'Splitpaw': 'The Lair of the Splitpaw',
		'Unrest': 'The Estate of Unrest',
		'Cazic Thule': 'Temple of Cazic-Thule',
		'Cazic-Thule': 'Temple of Cazic-Thule',
		'Oasis of Marr': 'The Oasis of Marr',
		'Solusek A': 'Solusek\'s Eye',
		'Solusek B': 'Nagafen\'s Lair'
	};

	function zoneKey( name ) {
		return String( name || '' )
			.toLowerCase()
			.replace( /^the\s+/, '' )
			.replace( /[^a-z0-9]+/g, ' ' )
			.trim();
	}

	var ZONE_INDEX = ( function () {
		var idx = {};
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {
			idx[ zoneKey( name ) ] = name;
		} );
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];
		} );
		return idx;
	}() );

	function canonicalZone( name ) {
		if ( !name ) {
			return null;
		}
		return ZONE_INDEX[ zoneKey( name ) ] || null;
	}

	// enrich:true columns are populated by the enrichment phase (rendered '—' until then).
	var COLUMNS = ( function () {
		var lead = [
			{ key: 'name', label: 'Name', always: true },
			{ key: 'score', label: 'Item Score', always: true },
			{ key: 'slot', label: 'Slot', columnDefault: true },
			{ key: 'type', label: 'Type', columnDefault: true },
			{ key: 'focus', label: 'Focus', enrich: true, columnDefault: false },
			{ key: 'worn', label: 'Worn', columnDefault: true },
			{ key: 'click', label: 'Click', columnDefault: true },
			{ key: 'combat', label: 'Combat', columnDefault: true },
			{ key: 'ratio', label: 'Ratio', columnDefault: true },
			{ key: 'DMG', label: 'DMG', columnDefault: true },
			{ key: 'DELAY', label: 'Delay', columnDefault: true }
		];
		var statCols = STATS.filter( function ( s ) {
			// DMG and DELAY are placed with the weapon columns above.
			return s.column && s.key !== 'DMG' && s.key !== 'DELAY';
		} ).map( function ( s ) {
			return { key: s.key, label: s.label, columnDefault: s.columnDefault };
		} );
		var trail = [
			{ key: 'svVoid', label: 'SV Void', columnDefault: true },
			{ key: 'source', label: 'Source', columnDefault: true },
			{ key: 'level', label: 'Level', enrich: true, columnDefault: false },
			{ key: 'zone', label: 'Zone', enrich: true, columnDefault: false },
			{ key: 'zoneAvg', label: 'Zone Avg Lvl', enrich: true, columnDefault: false }
		];
		return lead.concat( statCols ).concat( trail );
	}() );

	// ---- state / persistence ------------------------------------------------

	function defaultState() {
		var comps = {};
		var weights = {};
		SCORE_COMPONENTS.forEach( function ( c ) {
			comps[ c.key ] = !!c.scoreDefault;
			weights[ c.key ] = c.weight;
		} );
		var cols = {};
		COLUMNS.forEach( function ( c ) {
			cols[ c.key ] = c.always ? true : !!c.columnDefault;
		} );
		var effectBonus = {};
		EFFECT_TYPES.forEach( function ( t ) {
			effectBonus[ t ] = EFFECTS[ t ].bonus;
		} );
		return {
			level: 0,
			classes: [ '', '', '' ],
			slots: [ 'Primary' ],
			weaponTypes: [],
			zones: [],
			minLevel: CONFIG.LEVEL_MIN,
			maxLevel: CONFIG.LEVEL_MAX,
			components: comps,
			componentWeights: weights,
			effectBonus: effectBonus,
			columns: cols,
			columnOrder: COLUMNS.map( function ( c ) { return c.key; } )
		};
	}

	function loadState() {
		try {
			var raw = window.localStorage.getItem( STORAGE_KEY );
			if ( raw ) {
				return $.extend( true, defaultState(), JSON.parse( raw ) );
			}
		} catch ( e ) {}
		return defaultState();
	}

	function saveState( state ) {
		try {
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );
		} catch ( e ) {}
	}

	// ---- DataSource abstraction --------------------------------------------

	function classSlotEquipUrl( classes, slot ) {
		var base = ( mw.config.get( 'wgScript' ) || '/index.php' );
		return base + '?title=Special:ClassSlotEquip&eqlBuilder=1' +
			'&classA=' + encodeURIComponent( classes[ 0 ] || '' ) +
			'&classB=' + encodeURIComponent( classes[ 1 ] || '' ) +
			'&classC=' + encodeURIComponent( classes[ 2 ] || '' ) +
			'&slot=' + encodeURIComponent( slot );
	}

	function parseFragment( html, slot ) {
		var $frag = $( '<div>' ).html( html );
		var items = [];
		$frag.find( 'table.eoTable tr, table.sortable tr' ).each( function () {
			var $tr = $( this );
			if ( $tr.find( 'th' ).length ) {
				return; // header
			}
			var $cells = $tr.children( 'td' );
			if ( !$cells.length ) {
				return;
			}
			var $nameCell = $cells.eq( 0 );
			var $link = $nameCell.find( 'a' ).first();
			var $hb = $nameCell.find( '.hb' ).first();
			var $itemdata = $nameCell.find( '.itemdata' ).first();
			if ( !$itemdata.length ) {
				return;
			}
			var fields = EQLIS.parseItemData( $itemdata.text() );
			// Source: the "Drops From" column (3rd, index 2).
			var $src = $cells.eq( 2 );
			var srcText = $.trim( $src.text() );
			var sourceType = /quest/i.test( srcText ) ? 'quest' : 'drop';

			items.push( {
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),
				href: $link.attr( 'href' ) || null,
				popoverHtml: $hb.length ? $hb.prop( 'outerHTML' ) : '',
				fields: fields,
				slot: slot,
				source: { type: sourceType, text: srcText, html: $src.html() },
				// enrichment placeholders. available: true|false|null (null = unknown)
				enrich: { level: null, zone: null, available: null }
			} );
		} );
		return items;
	}

	// ---- enrichment: zone + availability (item categories) ----

	function titleFromHref( href, name ) {
		if ( href ) {
			var t = href.replace( /^https?:\/\/[^/]+/, '' ).replace( /^\/(wiki\/)?/, '' ).split( '#' )[ 0 ];
			try { t = decodeURIComponent( t ); } catch ( e ) {}
			if ( t ) { return t.replace( /_/g, ' ' ); }
		}
		return name || '';
	}

	function resolveFinalTitle( title, alias ) {
		var seen = {};
		while ( alias[ title ] && !seen[ title ] ) {
			seen[ title ] = 1;
			title = alias[ title ];
		}
		return title;
	}

	// Resolves to { cats: {title:[categoryName,...]}, alias: {from:to} }.
	function fetchCategories( titles ) {
		var result = {};
		var alias = {};
		var batches = [];
		var i;
		for ( i = 0; i < titles.length; i += 50 ) {
			batches.push( titles.slice( i, i + 50 ) );
		}

		return mw.loader.using( [ 'mediawiki.api' ] ).then( function () {
			var api = new mw.Api();

			function runBatch( batch, cont ) {
				var params = {
					action: 'query',
					prop: 'categories',
					titles: batch.join( '|' ),
					cllimit: 'max',
					redirects: 1,
					format: 'json'
				};
				if ( cont ) {
					params.clcontinue = cont;
				}
				return api.get( params ).then( function ( data ) {
					var q = data.query || {};
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );
					var pages = q.pages || {};
					Object.keys( pages ).forEach( function ( pid ) {
						var pg = pages[ pid ];
						if ( !pg.title ) { return; }
						var cats = ( pg.categories || [] ).map( function ( c ) {
							return c.title.replace( /^Category:/, '' );
						} );
						result[ pg.title ] = ( result[ pg.title ] || [] ).concat( cats );
					} );
					if ( data.continue && data.continue.clcontinue ) {
						return runBatch( batch, data.continue.clcontinue );
					}
				} );
			}

			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );
		} ).then( function () {
			return { cats: result, alias: alias };
		} );
	}

	// Availability (Requirement #1): a drop is in-scope if a zone category is in
	// ZONE_AVG_LEVEL. Quest items have no zone category — left unresolved here for
	// enrichLevels to settle from the quest page.
	function enrichZones( items ) {
		var titles = [];
		var seen = {};
		items.forEach( function ( it ) {
			it.title = titleFromHref( it.href, it.name );
			if ( it.title && !seen[ it.title ] ) {
				seen[ it.title ] = 1;
				titles.push( it.title );
			}
		} );
		if ( !titles.length ) {
			return $.Deferred().resolve( items ).promise();
		}
		return fetchCategories( titles ).then( function ( res ) {
			items.forEach( function ( it ) {
				var cats = res.cats[ resolveFinalTitle( it.title, res.alias ) ] ||
					res.cats[ it.title ] || [];
				var zones = [];
				cats.forEach( function ( cat ) {
					var cz = canonicalZone( cat );
					if ( cz && zones.indexOf( cz ) === -1 ) {
						zones.push( cz );
					}
				} );
				var isQuest = it.source.type === 'quest' ||
					cats.indexOf( 'Quest Items' ) !== -1;

				if ( zones.length === 1 ) {
					it.enrich.zone = zones[ 0 ];
					it.enrich.available = true;
				} else if ( zones.length > 1 ) {
					it.enrich.zone = 'multiple';
					it.enrich.available = true;
				} else if ( isQuest ) {
					it.enrich.zone = 'quest';   // Phase 2 resolves the actual start zone
					it.enrich.available = null; // unknown; keep for now
				} else {
					it.enrich.zone = null;
					it.enrich.available = false; // no in-scope zone -> out of scope
				}
			} );
			return items;
		}, function () {
			return items; // enrichment failed -> keep everything, unfiltered
		} );
	}

	// ---- enrichment: level (mob median for drops, quest min-level for quests) ----

	function median( nums ) {
		if ( !nums.length ) {
			return null;
		}
		var s = nums.slice().sort( function ( a, b ) { return a - b; } );
		var m = Math.floor( s.length / 2 );
		return s.length % 2 ? s[ m ] : Math.round( ( s[ m - 1 ] + s[ m ] ) / 2 );
	}

	// Mob/quest pages transclude the item ({{:Item}}), so transcludedin maps an
	// item to the pages that drop or reward it.
	function fetchTranscludedIn( titles ) {
		var map = {};
		var alias = {};
		var batches = [];
		var i;
		for ( i = 0; i < titles.length; i += 50 ) {
			batches.push( titles.slice( i, i + 50 ) );
		}
		return mw.loader.using( [ 'mediawiki.api' ] ).then( function () {
			var api = new mw.Api();
			function runBatch( batch, cont ) {
				var params = {
					action: 'query', prop: 'transcludedin', titles: batch.join( '|' ),
					tinamespace: 0, tilimit: 'max', redirects: 1, format: 'json'
				};
				if ( cont ) { params.ticontinue = cont; }
				return api.get( params ).then( function ( data ) {
					var q = data.query || {};
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );
					var pages = q.pages || {};
					Object.keys( pages ).forEach( function ( pid ) {
						var pg = pages[ pid ];
						if ( !pg.title ) { return; }
						var arr = map[ pg.title ] = map[ pg.title ] || [];
						( pg.transcludedin || [] ).forEach( function ( t ) { arr.push( t.title ); } );
					} );
					if ( data.continue && data.continue.ticontinue ) {
						return runBatch( batch, data.continue.ticontinue );
					}
				} );
			}
			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );
		} ).then( function () { return { map: map, alias: alias }; } );
	}

	function fetchWikitext( titles ) {
		var content = {};
		var batches = [];
		var i;
		for ( i = 0; i < titles.length; i += 50 ) {
			batches.push( titles.slice( i, i + 50 ) );
		}
		return mw.loader.using( [ 'mediawiki.api' ] ).then( function () {
			var api = new mw.Api();
			return $.when.apply( $, batches.map( function ( batch ) {
				return api.get( {
					action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main',
					titles: batch.join( '|' ), format: 'json'
				} ).then( function ( data ) {
					var pages = ( data.query || {} ).pages || {};
					Object.keys( pages ).forEach( function ( pid ) {
						var pg = pages[ pid ];
						var rev = pg.revisions && pg.revisions[ 0 ];
						var main = rev && rev.slots && rev.slots.main;
						if ( pg.title && main ) { content[ pg.title ] = main[ '*' ] || ''; }
					} );
				} );
			} ) );
		} ).then( function () { return content; } );
	}

	// A page transcluding an item is its mob ({{Namedmobpage}}, has | level =) or
	// its quest (questTopTable, with Minimum Level / Start Zone).
	function parseSourcePage( text ) {
		var out = { kind: null, level: null, zone: null };
		var lvl = text.match( /\|\s*level\s*=\s*(\d+)/i );
		if ( /Namedmobpage/i.test( text ) || lvl ) {
			out.kind = 'mob';
			if ( lvl ) { out.level = parseInt( lvl[ 1 ], 10 ); }
			var z = text.match( /\|\s*zone\s*=\s*\[\[\s*([^\]|]+)/i );
			if ( z ) { out.zone = z[ 1 ].trim(); }
			return out;
		}
		if ( /Minimum Level:|Start Zone:|questTopTable/i.test( text ) ) {
			out.kind = 'quest';
			var ml = text.match( /Minimum Level:[\s\S]*?\|\s*(\d+)/i );
			if ( ml ) { out.level = parseInt( ml[ 1 ], 10 ); }
			var sz = text.match( /Start Zone:[\s\S]*?\|\s*\[\[\s*([^\]|]+)/i );
			if ( sz ) { out.zone = sz[ 1 ].trim(); }
		}
		return out;
	}

	function applyLevelInfo( it, infos ) {
		var mobLevels = infos
			.filter( function ( x ) { return x.kind === 'mob' && x.level != null; } )
			.map( function ( x ) { return x.level; } );
		if ( mobLevels.length ) {
			it.enrich.level = median( mobLevels );
			return;
		}
		var quest = infos.filter( function ( x ) { return x.kind === 'quest'; } )[ 0 ];
		if ( quest ) {
			it.enrich.level = quest.level != null ? quest.level : 'quest';
			if ( quest.zone ) {
				var cz = canonicalZone( quest.zone );
				it.enrich.zone = cz || quest.zone;
				// hide a quest whose listed start zone is out of scope; keep in-scope
				// (or unlisted) quests.
				it.enrich.available = cz ? true : false;
			}
		}
	}

	function enrichLevels( items ) {
		var titles = [];
		items.forEach( function ( it ) {
			if ( it.title && titles.indexOf( it.title ) === -1 ) { titles.push( it.title ); }
		} );
		if ( !titles.length ) {
			return $.Deferred().resolve( items ).promise();
		}
		return fetchTranscludedIn( titles ).then( function ( ti ) {
			var pageSet = {};
			Object.keys( ti.map ).forEach( function ( t ) {
				ti.map[ t ].forEach( function ( p ) { pageSet[ p ] = true; } );
			} );
			var pages = Object.keys( pageSet );
			if ( !pages.length ) {
				return items;
			}
			return fetchWikitext( pages ).then( function ( wt ) {
				var info = {};
				pages.forEach( function ( p ) { info[ p ] = parseSourcePage( wt[ p ] || '' ); } );
				items.forEach( function ( it ) {
					var srcTitle = ti.alias[ it.title ] || it.title;
					var infos = ( ti.map[ srcTitle ] || ti.map[ it.title ] || [] )
						.map( function ( p ) { return info[ p ]; } )
						.filter( Boolean );
					applyLevelInfo( it, infos );
				} );
				return items;
			} );
		}, function () { return items; } );
	}

	var ClientDataSource = {
		fetchItems: function ( query ) {
			var slots = query.slots && query.slots.length ? query.slots : [ 'Primary' ];
			var requests = slots.map( function ( slot ) {
				return $.ajax( {
					url: classSlotEquipUrl( query.classes, slot ),
					dataType: 'html'
				} ).then( function ( html ) {
					return parseFragment( html, slot );
				}, function () {
					return []; // one slot failing shouldn't kill the whole run
				} );
			} );

			return $.when.apply( $, requests ).then( function () {
				var merged = [];
				var seen = {};
				var lists = Array.prototype.slice.call( arguments );
				// $.when with 1 request passes the value directly, not in an array-of-args.
				if ( requests.length === 1 ) {
					lists = [ arguments[ 0 ] ];
				}
				lists.forEach( function ( list ) {
					( list || [] ).forEach( function ( it ) {
						var k = it.name + '|' + it.slot;
						if ( !seen[ k ] ) {
							seen[ k ] = true;
							merged.push( it );
						}
					} );
				} );
				return merged;
			} ).then( enrichZones ).then( enrichLevels );
		}
	};

	// Stub for the eventual server-backed path (kept so the flip is one line).
	var SpecialPageDataSource = {
		fetchItems: function ( query ) {
			var base = ( mw.config.get( 'wgScript' ) || '/index.php' );
			var url = base + '?title=Special:ItemScoreTable&format=json' +
				'&classA=' + encodeURIComponent( query.classes[ 0 ] || '' ) +
				'&classB=' + encodeURIComponent( query.classes[ 1 ] || '' ) +
				'&classC=' + encodeURIComponent( query.classes[ 2 ] || '' ) +
				'&slots=' + encodeURIComponent( ( query.slots || [] ).join( '|' ) );
			return $.getJSON( url ).then( function ( data ) {
				return data.items || [];
			} );
		}
	};

	var ACTIVE_DATASOURCE = ClientDataSource; // <-- flip to SpecialPageDataSource later

	// ---- filtering + scoring ------------------------------------------------

	function normalizeType( skill ) {
		if ( !skill ) {
			return 'armor';
		}
		return skill;
	}

	function passesFilters( item, state ) {
		// Availability (Requirement #1): drop into a currently-available zone.
		// available===false means "has drop zone(s), none currently available" -> hide.
		// null (unknown, e.g. unresolved quest items) is kept.
		if ( item.enrich.available === false ) {
			return false;
		}
		if ( state.weaponTypes.length ) {
			var t = normalizeType( item.fields.type );
			if ( state.weaponTypes.indexOf( t ) === -1 ) {
				return false;
			}
		}
		// Quest items may carry level 'quest' (a string) rather than a number.
		var lvl = item.enrich.level;
		if ( typeof lvl === 'number' ) {
			if ( lvl < state.minLevel || lvl > state.maxLevel ) {
				return false;
			}
		}
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name
		// variants ("The …", Guk aliases) line up with the ZONE_AVG_LEVEL keys.
		if ( state.zones.length && item.enrich.zone != null ) {
			var itemZone = canonicalZone( item.enrich.zone );
			var matched = item.enrich.zone === 'multiple' || state.zones.some( function ( sel ) {
				return canonicalZone( sel ) === itemZone;
			} );
			if ( !matched ) {
				return false;
			}
		}
		return true;
	}

	// ---- rendering ----------------------------------------------------------

	function el( tag, attrs, children ) {
		var $e = $( '<' + tag + '>' );
		if ( attrs ) {
			$e.attr( attrs );
		}
		if ( children != null ) {
			$e.append( children );
		}
		return $e;
	}

	function statCell( item, key, level ) {
		if ( key === 'ratio' ) {
			var r = EQLIS.weaponRatio( item.fields, level );
			return r == null ? '' : r.toFixed( 3 );
		}
		if ( key === 'svVoid' ) {
			var v = EQLIS.svVoidBonus( item.fields, level );
			return v ? '+' + v : '';
		}
		// scalable/parsed stat key (DMG / DELAY / AC / STR / SV_*).
		// DELAY has scaling 'none', so scaledStat returns the raw delay.
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {
			return EQLIS.scaledStat( item.fields, key, level );
		}
		return '';
	}

	function effectCell( item, type ) {
		var names = item.fields.effects
			.filter( function ( e ) { return e.type === type; } )
			.map( function ( e ) { return e.name; } );
		return names.join( ', ' );
	}

	function columnByKey( key ) {
		for ( var i = 0; i < COLUMNS.length; i++ ) {
			if ( COLUMNS[ i ].key === key ) {
				return COLUMNS[ i ];
			}
		}
		return null;
	}

	// Migration: reconcile a saved columnOrder against the current COLUMNS.
	function normalizeColumnOrder( state ) {
		var seen = {};
		var order = ( state.columnOrder || [] ).filter( function ( k ) {
			var ok = columnByKey( k ) && !seen[ k ];
			seen[ k ] = true;
			return ok;
		} );
		COLUMNS.forEach( function ( c ) {
			if ( !seen[ c.key ] ) {
				order.push( c.key );
				seen[ c.key ] = true;
			}
		} );
		state.columnOrder = order;
	}

	function buildResultsTable( items, state ) {
		var level = state.level;
		var visibleCols = state.columnOrder
			.map( columnByKey )
			.filter( function ( c ) {
				return c && ( c.always || state.columns[ c.key ] );
			} );

		var $table = el( 'table', { 'class': 'eoTable sortable eqlis-results' } );
		var $thead = el( 'thead' );
		var $htr = el( 'tr' );
		visibleCols.forEach( function ( c ) {
			$htr.append( el( 'th', null, document.createTextNode( c.label ) ) );
		} );
		$thead.append( $htr );
		$table.append( $thead );

		var $tbody = el( 'tbody' );

		var opts = {
			level: state.level,
			components: state.components,
			weights: state.componentWeights,
			effectBonus: state.effectBonus
		};
		var rows = items
			.filter( function ( it ) { return passesFilters( it, state ); } )
			.map( function ( it ) {
				it._score = EQLIS.scoreItem( it.fields, opts ).score;
				return it;
			} )
			.sort( function ( a, b ) { return b._score - a._score; } );

		rows.forEach( function ( it ) {
			var $tr = el( 'tr' );
			visibleCols.forEach( function ( c ) {
				var $td = el( 'td' );
				switch ( c.key ) {
					case 'name':
						// embed the popover markup so the wiki's hover CSS works
						$td.html(
							'<div class="hbdiv">' +
							( it.href ? '<a href="' + it.href + '">' + it.name + '</a>' : it.name ) +
							( it.popoverHtml || '' ) +
							'</div>'
						);
						break;
					case 'score':
						$td.addClass( 'eqlis-score' ).text( it._score );
						break;
					case 'slot':
						$td.text( it.slot );
						break;
					case 'type':
						$td.text( normalizeType( it.fields.type ) );
						break;
					case 'focus': case 'worn': case 'click': case 'combat':
						$td.text( effectCell( it, c.key ) );
						break;
					case 'source':
						$td.html( it.source.html || it.source.text || '' );
						break;
					case 'level':
						$td.text( it.enrich.level == null ? '—' : it.enrich.level );
						break;
					case 'zone':
						$td.text( it.enrich.zone == null ? '—' : it.enrich.zone );
						break;
					case 'zoneAvg':
						var cz = canonicalZone( it.enrich.zone );
						$td.text( cz ? ZONE_AVG_LEVEL[ cz ] : '—' );
						break;
					default:
						$td.text( statCell( it, c.key, level ) );
				}
				$tr.append( $td );
			} );
			$tbody.append( $tr );
		} );

		$table.append( $tbody );
		return { $table: $table, count: rows.length };
	}

	// ---- UI shell -----------------------------------------------------------

	function multiSelect( id, values, selected, labelFor ) {
		var $sel = el( 'select', { id: id, multiple: 'multiple', size: Math.min( 8, values.length ), 'class': 'eqlis-multi' } );
		values.forEach( function ( v ) {
			var $o = el( 'option', { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || '— None —' ) ) );
			if ( selected.indexOf( v ) !== -1 ) {
				$o.prop( 'selected', true );
			}
			$sel.append( $o );
		} );
		return $sel;
	}

	function readMulti( $sel ) {
		return ( $sel.val() || [] ).slice();
	}

	function buildUI( $root, state ) {
		$root.empty().addClass( 'eqlis-app' );

		$root.append( el( 'div', { 'class': 'eqlis-title' }, 'Equipment Analyzer' ) );
		$root.append( el( 'div', { 'class': 'eqlis-subtitle' },
			'Pick classes/slots and scoring options, then press List Results. ' +
			'Out-of-scope zones are filtered out. The first List Results is slower — ' +
			'it resolves each item\'s zone and mob/quest level via the API.' ) );

		var $controls = el( 'div', { 'class': 'eqlis-controls' } );
		var $col1 = el( 'div', { 'class': 'eqlis-col' } );
		var $col2 = el( 'div', { 'class': 'eqlis-col' } );
		var $col3 = el( 'div', { 'class': 'eqlis-col' } );
		var $col4 = el( 'div', { 'class': 'eqlis-col' } );
		$controls.append( $col1 ).append( $col2 ).append( $col3 ).append( $col4 );

		// ---- Column 1: Classes / Slots / Weapon Type ----
		var $clsRow = el( 'div', { 'class': 'eqlis-field' } );
		$clsRow.append( el( 'label', null, 'Classes (up to 3): ' ) );
		[ 0, 1, 2 ].forEach( function ( i ) {
			var $s = el( 'select', { id: 'eqlis-class-' + i, 'class': 'eqlis-single' } );
			CLASSES.forEach( function ( c ) {
				var $o = el( 'option', { value: c }, document.createTextNode( c || '— None —' ) );
				if ( state.classes[ i ] === c ) { $o.prop( 'selected', true ); }
				$s.append( $o );
			} );
			$clsRow.append( $s );
		} );
		$col1.append( $clsRow );

		var $slotWrap = el( 'div', { 'class': 'eqlis-field' } );
		$slotWrap.append( el( 'label', null, 'Slots: ' ) );
		$slotWrap.append( multiSelect( 'eqlis-slots', SLOTS, state.slots ) );
		$col1.append( $slotWrap );

		var $wtWrap = el( 'div', { 'class': 'eqlis-field' } );
		$wtWrap.append( el( 'label', null, 'Weapon Type: ' ) );
		$wtWrap.append( multiSelect( 'eqlis-wtypes', WEAPON_TYPES, state.weaponTypes ) );
		$col1.append( $wtWrap );

		// ---- Column 2: Upgrade Level / Level min-max / Zone ----
		var $lvlWrap = el( 'div', { 'class': 'eqlis-field' } );
		$lvlWrap.append( el( 'label', null, 'Upgrade Level: ' ) );
		var $lvl = el( 'input', { type: 'range', min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: 'eqlis-level' } );
		var $lvlOut = el( 'span', { 'class': 'eqlis-level-out' }, String( state.level ) );
		$lvl.on( 'input', function () { $lvlOut.text( this.value ); } );
		$lvlWrap.append( $lvl ).append( $lvlOut );
		$col2.append( $lvlWrap );

		var $lr = el( 'div', { 'class': 'eqlis-field' } );
		$lr.append( el( 'label', null, 'Level min/max: ' ) );
		$lr.append( el( 'input', { type: 'number', id: 'eqlis-minlvl', min: 1, max: 100, value: state.minLevel, 'class': 'eqlis-num' } ) );
		$lr.append( el( 'input', { type: 'number', id: 'eqlis-maxlvl', min: 1, max: 100, value: state.maxLevel, 'class': 'eqlis-num' } ) );
		$col2.append( $lr );

		var $zoneWrap = el( 'div', { 'class': 'eqlis-field' } );
		$zoneWrap.append( el( 'label', null, 'Zone: ' ) );
		$zoneWrap.append( multiSelect( 'eqlis-zones', Object.keys( ZONE_AVG_LEVEL ).sort(), state.zones ) );
		$col2.append( $zoneWrap );

		// ---- Column 3: Item Score components (check to include; box = weight) ----
		var $score = el( 'div', { 'class': 'eqlis-field eqlis-scorebox' } );
		$score.append( el( 'div', { 'class': 'eqlis-legend' }, 'Item Score (check = include, box = weight)' ) );

		function scoreRow( c, extraClass ) {
			var $row = el( 'label', { 'class': 'eqlis-check' + ( extraClass ? ' ' + extraClass : '' ) } );
			var $cb = el( 'input', { type: 'checkbox', 'data-comp': c.key } );
			if ( state.components[ c.key ] ) { $cb.prop( 'checked', true ); }
			var $wt = el( 'input', {
				type: 'number', step: 0.5, min: 0, 'data-weight': c.key,
				value: state.componentWeights[ c.key ], 'class': 'eqlis-wt'
			} );
			$row.append( $cb )
				.append( document.createTextNode( ' ' + c.label + ' ' ) )
				.append( $wt );
			return $row;
		}

		// Saves + SV Void render indented under Total SV.
		var $totalCb = null;
		SCORE_COMPONENTS.forEach( function ( c ) {
			var indent = ( c.group === 'save' && c.special !== 'totalSV' );
			var $row = scoreRow( c, indent ? 'eqlis-sv' : '' );
			$score.append( $row );
			if ( c.special === 'totalSV' ) {
				$totalCb = $row.find( 'input[data-comp]' );
			}
		} );

		// disable individual saves (not SV Void) when Total SV is on
		function syncSvDisabled() {
			if ( !$totalCb ) { return; }
			var on = $totalCb.prop( 'checked' );
			$score.find( '.eqlis-sv input[data-comp]' ).each( function () {
				if ( $( this ).attr( 'data-comp' ) !== 'svVoid' ) {
					$( this ).prop( 'disabled', on );
				}
			} );
		}
		if ( $totalCb ) {
			$totalCb.on( 'change', syncSvDisabled );
		}
		syncSvDisabled();

		var $eff = el( 'div', { 'class': 'eqlis-field' } );
		$eff.append( el( 'label', null, 'Effect score bonus (flat): ' ) );
		EFFECT_TYPES.forEach( function ( t ) {
			$eff.append( document.createTextNode( ' ' + t + ' ' ) );
			$eff.append( el( 'input', { type: 'number', 'data-effbonus': t, value: state.effectBonus[ t ], 'class': 'eqlis-num' } ) );
		} );
		$score.append( $eff );
		$col3.append( $score );

		// ---- Column 4: Columns — dual-list chooser (Available -> Shown + order) ----
		// Column changes apply on the next List Results (no live re-render).
		normalizeColumnOrder( state );
		var $cols = el( 'div', { 'class': 'eqlis-field eqlis-colbox' } );
		$cols.append( el( 'div', { 'class': 'eqlis-legend' }, 'Columns' ) );

		var $avail = el( 'select', { multiple: 'multiple', size: 10, 'class': 'eqlis-dual-list' } );
		var $shown = el( 'select', { multiple: 'multiple', size: 10, 'class': 'eqlis-dual-list' } );

		function dualBtn( glyph, title ) {
			return el( 'button', { type: 'button', 'class': 'eqlis-dual-btn', title: title }, glyph );
		}
		var $addBtn = dualBtn( '▶', 'Show selected' );
		var $remBtn = dualBtn( '◀', 'Hide selected' );
		var $upBtn = dualBtn( '▲', 'Move up' );
		var $downBtn = dualBtn( '▼', 'Move down' );

		var $availSide = el( 'div', { 'class': 'eqlis-dual-side' } )
			.append( el( 'div', { 'class': 'eqlis-dual-cap' }, 'Available' ) )
			.append( $avail );
		var $mid = el( 'div', { 'class': 'eqlis-dual-mid' } ).append( $addBtn ).append( $remBtn );
		var $shownSide = el( 'div', { 'class': 'eqlis-dual-side' } )
			.append( el( 'div', { 'class': 'eqlis-dual-cap' }, 'Shown (top = left)' ) )
			.append( $shown )
			.append( el( 'div', { 'class': 'eqlis-dual-ord' } ).append( $upBtn ).append( $downBtn ) );

		$cols.append(
			el( 'div', { 'class': 'eqlis-dual' } ).append( $availSide ).append( $mid ).append( $shownSide )
		);
		$col4.append( $cols );

		function isShown( c ) {
			return c.always || state.columns[ c.key ];
		}

		function renderColumnChooser( keepSel ) {
			$avail.empty();
			$shown.empty();
			state.columnOrder.forEach( function ( key ) {
				var c = columnByKey( key );
				if ( !c ) { return; }
				var $o = el( 'option', { value: key }, document.createTextNode( c.label ) );
				( isShown( c ) ? $shown : $avail ).append( $o );
			} );
			if ( keepSel ) {
				$shown.val( keepSel );
			}
		}

		$addBtn.on( 'click', function () {
			( $avail.val() || [] ).forEach( function ( key ) {
				state.columns[ key ] = true;
				var i = state.columnOrder.indexOf( key ); // move to end so it lands last
				if ( i !== -1 ) {
					state.columnOrder.splice( i, 1 );
					state.columnOrder.push( key );
				}
			} );
			saveState( state );
			renderColumnChooser();
		} );

		$remBtn.on( 'click', function () {
			( $shown.val() || [] ).forEach( function ( key ) {
				var c = columnByKey( key );
				if ( c && !c.always ) {
					state.columns[ key ] = false;
				}
			} );
			saveState( state );
			renderColumnChooser();
		} );

		function moveShown( dir ) {
			var sel = $shown.val() || [];
			if ( sel.length !== 1 ) {
				return;
			}
			var key = sel[ 0 ];
			var shownKeys = state.columnOrder.filter( function ( k ) {
				var c = columnByKey( k );
				return c && isShown( c );
			} );
			var si = shownKeys.indexOf( key );
			if ( si + dir < 0 || si + dir >= shownKeys.length ) {
				return;
			}
			var otherKey = shownKeys[ si + dir ];
			var a = state.columnOrder.indexOf( key );
			var b = state.columnOrder.indexOf( otherKey );
			state.columnOrder[ a ] = otherKey;
			state.columnOrder[ b ] = key;
			saveState( state );
			renderColumnChooser( [ key ] );
		}
		$upBtn.on( 'click', function () { moveShown( -1 ); } );
		$downBtn.on( 'click', function () { moveShown( 1 ); } );

		renderColumnChooser();

		$root.append( $controls );

		var $btn = el( 'button', { type: 'button', 'class': 'eqlis-run' }, 'List Results' );
		var $status = el( 'div', { 'class': 'eqlis-status' } );
		var $results = el( 'div', { 'class': 'eqlis-results' } );
		$root.append( $btn ).append( $status ).append( $results );

		function collect() {
			state.level = parseInt( $lvl.val(), 10 ) || 0;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {
				return $( '#eqlis-class-' + i ).val() || '';
			} );
			state.slots = readMulti( $( '#eqlis-slots' ) );
			state.weaponTypes = readMulti( $( '#eqlis-wtypes' ) );
			state.zones = readMulti( $( '#eqlis-zones' ) );
			state.minLevel = parseInt( $( '#eqlis-minlvl' ).val(), 10 ) || 1;
			state.maxLevel = parseInt( $( '#eqlis-maxlvl' ).val(), 10 ) || 100;
			$score.find( 'input[data-comp]' ).each( function () {
				state.components[ $( this ).attr( 'data-comp' ) ] = $( this ).prop( 'checked' );
			} );
			$score.find( 'input[data-weight]' ).each( function () {
				var v = parseFloat( $( this ).val() );
				state.componentWeights[ $( this ).attr( 'data-weight' ) ] = isNaN( v ) ? 1 : v;
			} );
			$eff.find( 'input[data-effbonus]' ).each( function () {
				state.effectBonus[ $( this ).attr( 'data-effbonus' ) ] = parseFloat( $( this ).val() ) || 0;
			} );
			// Column show/hide + order is managed directly by the dual-list chooser.
			saveState( state );
		}

		// persist on any change WITHOUT fetching (List Results gates fetch)
		$controls.on( 'change input', function () { collect(); } );

		var lastItems = null;

		function render() {
			if ( !lastItems ) { return; }
			var built = buildResultsTable( lastItems, state );
			$results.empty().append(
				el( 'div', { 'class': 'eqlis-count' }, built.count + ' items (of ' + lastItems.length + ' fetched) — sorted by score' )
			).append( built.$table );
			// Seed the wiki's ItemLevelSlider default so popover sliders initialize at
			// the upgrade level chosen here (its storage key is 'ils-default-level-v2').
			try {
				window.localStorage.setItem( 'ils-default-level-v2',
					JSON.stringify( { full: state.level, fraction: 0 } ) );
			} catch ( e ) {}
			// let the wiki instrument popovers + item-level sliders inside results
			if ( mw.hook ) { mw.hook( 'wikipage.content' ).fire( $results ); }
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }
			if ( mw.loader ) {
				mw.loader.using( [ 'jquery.tablesorter' ] ).then( function () {
					try { $results.find( 'table.sortable' ).tablesorter(); } catch ( e ) {}
				} );
			}
		}

		$btn.on( 'click', function () {
			collect();
			var sel = state.classes.filter( Boolean );
			if ( !sel.length ) {
				$status.text( 'Select at least one class.' ).addClass( 'eqlis-err' );
				return;
			}
			$status.removeClass( 'eqlis-err' ).text( 'Loading…' );
			$btn.prop( 'disabled', true );
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )
				.then( function ( items ) {
					lastItems = items;
					$status.text( '' );
					render();
				}, function ( e ) {
					$status.text( 'Load failed: ' + ( e && e.message ? e.message : 'unknown error' ) ).addClass( 'eqlis-err' );
				} )
				.always( function () { $btn.prop( 'disabled', false ); } );
		} );

		// NOTE: intentionally no live re-render on control changes. Per spec, the
		// table only (re)builds when List Results is pressed — moving the upgrade
		// slider or toggling score/columns changes persisted state but does not
		// touch the results until the next List Results click.
	}

	// ---- mount --------------------------------------------------------------

	function init( root ) {
		var host = ( root || document ).querySelector ?
			( root || document ).querySelector( '#' + MOUNT_ID ) :
			document.getElementById( MOUNT_ID );
		if ( !host || host.getAttribute( 'data-eqlis-ready' ) === '1' ) {
			return;
		}
		host.setAttribute( 'data-eqlis-ready', '1' );
		buildUI( $( host ), loadState() );
	}

	$( function () { init( document ); } );
	if ( mw.hook ) {
		mw.hook( 'wikipage.content' ).add( function ( $c ) {
			// don't recurse into our own results
			if ( $c && $c.closest && $c.closest( '.eqlis-results' ).length ) { return; }
			init( document );
		} );
	}

	window.eqlItemScoreInit = init;

}( jQuery, mediaWiki ) );