<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://eqlwiki.com/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Chonx</id>
	<title>EverQuest Legends Wiki - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://eqlwiki.com/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Chonx"/>
	<link rel="alternate" type="text/html" href="https://eqlwiki.com/Special:Contributions/Chonx"/>
	<updated>2026-08-25T20:17:57Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.45.3</generator>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=162224</id>
		<title>User:Chonx/eqlItemScore.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=162224"/>
		<updated>2026-07-24T23:31:03Z</updated>

		<summary type="html">&lt;p&gt;Chonx: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/**&lt;br /&gt;
 * ext.itemScoreTable — EQL Equipment Analyzer / Item Score guide.&lt;br /&gt;
 *&lt;br /&gt;
 * Mounts into &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;. All data access goes through&lt;br /&gt;
 * ACTIVE_DATASOURCE so a server-side Special:ItemScoreTable can later replace&lt;br /&gt;
 * ClientDataSource with a one-line change.&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	// Styles are injected from JS — the module has no separate CSS channel.&lt;br /&gt;
	( function () {&lt;br /&gt;
		var s = document.createElement( &#039;style&#039; );&lt;br /&gt;
		s.setAttribute( &#039;data-eqlis&#039;, &#039;1&#039; );&lt;br /&gt;
		s.textContent = &amp;quot;/* EQL Item Score Table \u2014 styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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 &amp;gt; 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&amp;quot;;&lt;br /&gt;
		( document.head || document.documentElement ).appendChild( s );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
	// Only truly-global tunables live here. Anything specific to a stat, a score&lt;br /&gt;
	// component, or an effect lives ON that stat/component/effect (STATS, the&lt;br /&gt;
	// SCORE_COMPONENTS entries, EFFECTS) — the same way DMG&#039;s params live on its row.&lt;br /&gt;
	var CONFIG = {&lt;br /&gt;
		UPGRADE_MAX: 10,   // item upgrade slider range 0..UPGRADE_MAX&lt;br /&gt;
		LEVEL_MIN: 1,      // Level min/max filter bounds&lt;br /&gt;
		LEVEL_MAX: 100,&lt;br /&gt;
		SCORE_ROUND: 1000  // score rounded to 1/SCORE_ROUND&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = CONFIG.UPGRADE_MAX;&lt;br /&gt;
&lt;br /&gt;
	/*&lt;br /&gt;
	 * SINGLE SOURCE OF TRUTH for every stat. Each entry owns everything about that&lt;br /&gt;
	 * stat: parse aliases, scaling class, SV-Void group, default score weight, and&lt;br /&gt;
	 * default enablement as a score component / table column. STAT_ALIASES,&lt;br /&gt;
	 * PRIMARY_STATS, SV_VOID_QUALIFIERS, the stat columns and stat score-components&lt;br /&gt;
	 * are all DERIVED from this — nothing about a stat is listed twice.&lt;br /&gt;
	 *&lt;br /&gt;
	 * group:   attr | save | defense | weapon  (drives SV-Void qualification)&lt;br /&gt;
	 * scaling: primary | damage | none          (mirror of ext.itemLevelSlider rules)&lt;br /&gt;
	 */&lt;br /&gt;
	var STATS = [&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039;, aliases: [ &#039;DMG&#039;, &#039;DAMAGE&#039; ], group: &#039;weapon&#039;, scaling: &#039;damage&#039;, weight: 1, column: true, columnDefault: true, score: false, scoreDefault: false },&lt;br /&gt;
		// delay is parsed like DMG (alias &amp;quot;Atk Delay&amp;quot;); ItemLevelSlider leaves it&lt;br /&gt;
		// unchanged (scaling &#039;none&#039;). scoreScale is its scoring param (score = scale/delay).&lt;br /&gt;
		{ key: &#039;DELAY&#039;, label: &#039;Delay&#039;, aliases: [ &#039;ATK DELAY&#039; ], group: &#039;weapon&#039;, scaling: &#039;none&#039;, weight: 1, scoreScale: 100, column: true, columnDefault: true, score: false, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039;, aliases: [ &#039;AC&#039;, &#039;ARMOR CLASS&#039; ], group: &#039;defense&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039;, aliases: [ &#039;STR&#039;, &#039;STRENGTH&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039;, aliases: [ &#039;STA&#039;, &#039;STAMINA&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039;, aliases: [ &#039;AGI&#039;, &#039;AGILITY&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039;, aliases: [ &#039;DEX&#039;, &#039;DEXTERITY&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039;, aliases: [ &#039;INT&#039;, &#039;INTELLIGENCE&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039;, aliases: [ &#039;WIS&#039;, &#039;WISDOM&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039;, aliases: [ &#039;CHA&#039;, &#039;CHARISMA&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039;, aliases: [ &#039;SV MAGIC&#039;, &#039;MAGIC&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039;, aliases: [ &#039;SV FIRE&#039;, &#039;FIRE&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039;, aliases: [ &#039;SV COLD&#039;, &#039;COLD&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039;, aliases: [ &#039;SV POISON&#039;, &#039;POISON&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039;, aliases: [ &#039;SV DISEASE&#039;, &#039;DISEASE&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var STAT_BY_KEY = {};&lt;br /&gt;
	STATS.forEach( function ( s ) { STAT_BY_KEY[ s.key ] = s; } );&lt;br /&gt;
&lt;br /&gt;
	// Everything below is derived from STATS.&lt;br /&gt;
	var STAT_ALIASES = {};&lt;br /&gt;
	STATS.forEach( function ( s ) {&lt;br /&gt;
		s.aliases.forEach( function ( a ) { STAT_ALIASES[ a.toUpperCase() ] = s.key; } );&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	var PRIMARY_STATS = STATS.filter( function ( s ) {&lt;br /&gt;
		return s.scaling === &#039;primary&#039;;&lt;br /&gt;
	} ).map( function ( s ) { return s.key; } );&lt;br /&gt;
&lt;br /&gt;
	var SV_VOID_QUALIFIERS = STATS.filter( function ( s ) {&lt;br /&gt;
		return s.group === &#039;attr&#039; || s.group === &#039;save&#039;;&lt;br /&gt;
	} ).map( function ( s ) { return s.key; } );&lt;br /&gt;
&lt;br /&gt;
	// Effects that add a flat score bonus when present on an item. Each owns its&lt;br /&gt;
	// own default bonus (EFFECT_TYPES is derived for code that just needs the keys).&lt;br /&gt;
	var EFFECTS = {&lt;br /&gt;
		focus: { bonus: 25 },&lt;br /&gt;
		worn: { bonus: 25 },&lt;br /&gt;
		click: { bonus: 25 },&lt;br /&gt;
		combat: { bonus: 25 }&lt;br /&gt;
	};&lt;br /&gt;
	var EFFECT_TYPES = Object.keys( EFFECTS );&lt;br /&gt;
&lt;br /&gt;
	/* =====================================================================&lt;br /&gt;
	 * MIRROR of ext.itemLevelSlider.js — keep in sync until that module&lt;br /&gt;
	 * exposes these. excelRound / intFloor / scalePrimaryStat / scaleDamage&lt;br /&gt;
	 * are copied verbatim from its scaling logic. This is the ONLY copy of&lt;br /&gt;
	 * that math in this file; do not re-implement it elsewhere.&lt;br /&gt;
	 * ===================================================================== */&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas ----&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Callers must strip HTML first. focus_effect isn&#039;t in the stat block — enrichment sets it.&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number (incl. DELAY)&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Weapon skill / type only. The delay value itself is parsed as the DELAY&lt;br /&gt;
		// stat by the generic stat loop below (its alias is &amp;quot;Atk Delay&amp;quot;).&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		var stat = STAT_BY_KEY[ key ];&lt;br /&gt;
		if ( !stat ) {&lt;br /&gt;
			return base;&lt;br /&gt;
		}&lt;br /&gt;
		if ( stat.scaling === &#039;primary&#039; ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( stat.scaling === &#039;damage&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		var delay = item.stats.DELAY;&lt;br /&gt;
		if ( !delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Order here is the display order in the score panel.&lt;br /&gt;
	var SCORE_COMPONENTS = ( function () {&lt;br /&gt;
		var list = [];&lt;br /&gt;
&lt;br /&gt;
		// ratio is fully derived (no parsed stat), so it owns its own scale here.&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;ratio&#039;, label: &#039;Weapon Ratio&#039;, group: &#039;weapon&#039;,&lt;br /&gt;
			weight: 1, scoreDefault: false, scale: 25, // ratio score = (dmg/delay) * scale&lt;br /&gt;
			compute: function ( item, level ) {&lt;br /&gt;
				var dmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
				var delay = item.stats.DELAY;&lt;br /&gt;
				return ( delay &amp;gt; 0 &amp;amp;&amp;amp; dmg &amp;gt; 0 ) ? ( dmg / delay ) * this.scale : 0;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;DMG&#039;, label: &#039;Weapon Damage&#039;, group: &#039;weapon&#039;,&lt;br /&gt;
			weight: STAT_BY_KEY.DMG.weight, scoreDefault: false,&lt;br /&gt;
			compute: function ( item, level ) { return scaledStat( item, &#039;DMG&#039;, level ); }&lt;br /&gt;
		} );&lt;br /&gt;
		// delay&#039;s scoring scale lives on its STATS row (score = scoreScale / delay).&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;DELAY&#039;, label: &#039;Weapon Delay&#039;, group: &#039;weapon&#039;,&lt;br /&gt;
			weight: STAT_BY_KEY.DELAY.weight, scoreDefault: false,&lt;br /&gt;
			compute: function ( item ) {&lt;br /&gt;
				var delay = item.stats.DELAY;&lt;br /&gt;
				return delay &amp;gt; 0 ? STAT_BY_KEY.DELAY.scoreScale / delay : 0;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		STATS.forEach( function ( s ) {&lt;br /&gt;
			if ( s.score &amp;amp;&amp;amp; s.group !== &#039;save&#039; ) {&lt;br /&gt;
				list.push( {&lt;br /&gt;
					key: s.key, label: s.label, group: s.group,&lt;br /&gt;
					weight: s.weight, scoreDefault: s.scoreDefault,&lt;br /&gt;
					compute: function ( item, level ) { return scaledStat( item, s.key, level ); }&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;totalSV&#039;, label: &#039;Total SV&#039;, group: &#039;save&#039;, special: &#039;totalSV&#039;,&lt;br /&gt;
			weight: 0.5, scoreDefault: false,&lt;br /&gt;
			compute: function ( item, level ) {&lt;br /&gt;
				var total = 0;&lt;br /&gt;
				STATS.forEach( function ( s ) {&lt;br /&gt;
					if ( s.group === &#039;save&#039; ) { total += scaledStat( item, s.key, level ); }&lt;br /&gt;
				} );&lt;br /&gt;
				return total;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		STATS.forEach( function ( s ) {&lt;br /&gt;
			if ( s.score &amp;amp;&amp;amp; s.group === &#039;save&#039; ) {&lt;br /&gt;
				list.push( {&lt;br /&gt;
					key: s.key, label: s.label, group: &#039;save&#039;, saveStat: true,&lt;br /&gt;
					weight: s.weight, scoreDefault: s.scoreDefault,&lt;br /&gt;
					compute: function ( item, level ) { return scaledStat( item, s.key, level ); }&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;svVoid&#039;, label: &#039;SV Void&#039;, group: &#039;save&#039;, special: &#039;svVoid&#039;,&lt;br /&gt;
			weight: 0.5, scoreDefault: false,&lt;br /&gt;
			compute: function ( item, level ) { return svVoidBonus( item, level ); }&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		return list;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( CONFIG.UPGRADE_MAX, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || {};&lt;br /&gt;
		var weights = opts.weights || {};&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var totalSvOn = !!comp.totalSV;&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			if ( !comp[ c.key ] ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			if ( c.special === &#039;totalSV&#039; &amp;amp;&amp;amp; !totalSvOn ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			if ( c.saveStat &amp;amp;&amp;amp; totalSvOn ) {&lt;br /&gt;
				return; // individual saves are folded into Total SV&lt;br /&gt;
			}&lt;br /&gt;
			var raw = c.compute( item, level );&lt;br /&gt;
			var wt = weights[ c.key ];&lt;br /&gt;
			wt = ( wt == null || isNaN( wt ) ) ? c.weight : Number( wt );&lt;br /&gt;
			var weighted = raw * wt;&lt;br /&gt;
			if ( weighted ) {&lt;br /&gt;
				breakdown[ c.key ] = weighted;&lt;br /&gt;
				score += weighted;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				breakdown[ &#039;effect_&#039; + t ] = effectBonus[ t ];&lt;br /&gt;
				score += effectBonus[ t ];&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * CONFIG.SCORE_ROUND ) / CONFIG.SCORE_ROUND,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		STATS: STATS,&lt;br /&gt;
		SCORE_COMPONENTS: SCORE_COMPONENTS,&lt;br /&gt;
		EFFECT_TYPES: EFFECT_TYPES,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// ===================== app layer (UI / data / mount) =====================&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	/* MIRROR of ext.classSlotEquip.js `slots` / `classes` — keep in sync until that&lt;br /&gt;
	 * module exposes them. These are the exact slot/class values it accepts. */&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md&lt;br /&gt;
	// (name -&amp;gt; average mob level). STATIC data (eqltools-derived). This doubles as the&lt;br /&gt;
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone&lt;br /&gt;
	// filter options, and the &amp;quot;Zone Avg Lvl&amp;quot; column source.&lt;br /&gt;
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.&lt;br /&gt;
	var ZONE_AVG_LEVEL = {&lt;br /&gt;
		&#039;The Feerrott&#039;: 4,&lt;br /&gt;
		&#039;Toxxulia Forest&#039;: 4,&lt;br /&gt;
		&#039;Misty Thicket&#039;: 5,&lt;br /&gt;
		&#039;Qeynos Hills&#039;: 6,&lt;br /&gt;
		&#039;The Qeynos Aqueduct System&#039;: 6,&lt;br /&gt;
		&#039;Innothule Swamp&#039;: 6,&lt;br /&gt;
		&#039;The Steamfont Mountains&#039;: 6,&lt;br /&gt;
		&#039;The Greater Faydark&#039;: 7,&lt;br /&gt;
		&#039;Butcherblock Mountains&#039;: 7,&lt;br /&gt;
		&#039;Nektulos Forest&#039;: 7,&lt;br /&gt;
		&#039;East Commonlands&#039;: 8,&lt;br /&gt;
		&#039;Everfrost Peaks&#039;: 8,&lt;br /&gt;
		&#039;The Northern Desert of Ro&#039;: 9,&lt;br /&gt;
		&#039;Blackburrow&#039;: 10,&lt;br /&gt;
		&#039;The Southern Desert of Ro&#039;: 10,&lt;br /&gt;
		&#039;West Commonlands&#039;: 10,&lt;br /&gt;
		&#039;The Western Plains of Karana&#039;: 10,&lt;br /&gt;
		&#039;Erud\&#039;s Crossing&#039;: 11,&lt;br /&gt;
		&#039;The Warrens&#039;: 11,&lt;br /&gt;
		&#039;Clan Crushbone&#039;: 12,&lt;br /&gt;
		&#039;Befallen&#039;: 13,&lt;br /&gt;
		&#039;The Lavastorm Mountains&#039;: 13,&lt;br /&gt;
		&#039;Dagnor\&#039;s Cauldron&#039;: 13,&lt;br /&gt;
		&#039;The Gorge of King Xorbb&#039;: 14,&lt;br /&gt;
		&#039;The Oasis of Marr&#039;: 14,&lt;br /&gt;
		&#039;The Eastern Plains of Karana&#039;: 14,&lt;br /&gt;
		&#039;Highpass Hold&#039;: 15,&lt;br /&gt;
		&#039;The Lesser Faydark&#039;: 15,&lt;br /&gt;
		&#039;The Northern Plains of Karana&#039;: 15,&lt;br /&gt;
		&#039;Lake Rathetear&#039;: 17,&lt;br /&gt;
		&#039;The Rathe Mountains&#039;: 17,&lt;br /&gt;
		&#039;Najena&#039;: 19,&lt;br /&gt;
		&#039;The Liberated Citadel of Runnyeye&#039;: 19,&lt;br /&gt;
		&#039;The City of Guk&#039;: 20,&lt;br /&gt;
		&#039;The Southern Plains of Karana&#039;: 21,&lt;br /&gt;
		&#039;The Stonebrunt Mountains&#039;: 21,&lt;br /&gt;
		&#039;The Ocean of Tears&#039;: 22,&lt;br /&gt;
		&#039;The Estate of Unrest&#039;: 23,&lt;br /&gt;
		&#039;High Keep&#039;: 25,&lt;br /&gt;
		&#039;Solusek\&#039;s Eye&#039;: 25,&lt;br /&gt;
		&#039;Temple of Cazic-Thule&#039;: 26,&lt;br /&gt;
		&#039;Permafrost Keep&#039;: 26,&lt;br /&gt;
		&#039;Kithicor Forest&#039;: 30,&lt;br /&gt;
		&#039;The Castle of Mistmoore&#039;: 31,&lt;br /&gt;
		&#039;The Lair of the Splitpaw&#039;: 31,&lt;br /&gt;
		&#039;The Temple of Solusek Ro&#039;: 35,&lt;br /&gt;
		&#039;The Ruins of Old Guk&#039;: 36,&lt;br /&gt;
		&#039;Kedge Keep&#039;: 40,&lt;br /&gt;
		&#039;Nagafen\&#039;s Lair&#039;: 40,&lt;br /&gt;
		&#039;The Ruins of Old Paineel&#039;: 45,&lt;br /&gt;
		&#039;The Plane of Fear&#039;: 50,&lt;br /&gt;
		&#039;The Plane of Hate&#039;: 52,&lt;br /&gt;
		&#039;The Plane of Sky&#039;: 54&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Wiki zone names that don&#039;t normalize to a ZONE_AVG_LEVEL key by prefix-stripping&lt;br /&gt;
	// alone. Extend as item-&amp;gt;zone enrichment surfaces more mismatches.&lt;br /&gt;
	var ZONE_ALIASES = {&lt;br /&gt;
		&#039;Lower Guk&#039;: &#039;The Ruins of Old Guk&#039;,&lt;br /&gt;
		&#039;Upper Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Paineel&#039;: &#039;The Ruins of Old Paineel&#039;,&lt;br /&gt;
		&#039;Runnyeye&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Runnyeye Citadel&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Castle Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Splitpaw&#039;: &#039;The Lair of the Splitpaw&#039;,&lt;br /&gt;
		&#039;Unrest&#039;: &#039;The Estate of Unrest&#039;,&lt;br /&gt;
		&#039;Cazic Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Cazic-Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Oasis of Marr&#039;: &#039;The Oasis of Marr&#039;,&lt;br /&gt;
		&#039;Solusek A&#039;: &#039;Solusek\&#039;s Eye&#039;,&lt;br /&gt;
		&#039;Solusek B&#039;: &#039;Nagafen\&#039;s Lair&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	function zoneKey( name ) {&lt;br /&gt;
		return String( name || &#039;&#039; )&lt;br /&gt;
			.toLowerCase()&lt;br /&gt;
			.replace( /^the\s+/, &#039;&#039; )&lt;br /&gt;
			.replace( /[^a-z0-9]+/g, &#039; &#039; )&lt;br /&gt;
			.trim();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ZONE_INDEX = ( function () {&lt;br /&gt;
		var idx = {};&lt;br /&gt;
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {&lt;br /&gt;
			idx[ zoneKey( name ) ] = name;&lt;br /&gt;
		} );&lt;br /&gt;
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {&lt;br /&gt;
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];&lt;br /&gt;
		} );&lt;br /&gt;
		return idx;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	function canonicalZone( name ) {&lt;br /&gt;
		if ( !name ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return ZONE_INDEX[ zoneKey( name ) ] || null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// enrich:true columns are populated by the enrichment phase (rendered &#039;—&#039; until then).&lt;br /&gt;
	var COLUMNS = ( function () {&lt;br /&gt;
		var lead = [&lt;br /&gt;
			{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
			{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
			{ key: &#039;slot&#039;, label: &#039;Slot&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;type&#039;, label: &#039;Type&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true, columnDefault: false },&lt;br /&gt;
			{ key: &#039;worn&#039;, label: &#039;Worn&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;click&#039;, label: &#039;Click&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;combat&#039;, label: &#039;Combat&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;ratio&#039;, label: &#039;Ratio&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;DMG&#039;, label: &#039;DMG&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;DELAY&#039;, label: &#039;Delay&#039;, columnDefault: true }&lt;br /&gt;
		];&lt;br /&gt;
		var statCols = STATS.filter( function ( s ) {&lt;br /&gt;
			// DMG and DELAY are placed with the weapon columns above.&lt;br /&gt;
			return s.column &amp;amp;&amp;amp; s.key !== &#039;DMG&#039; &amp;amp;&amp;amp; s.key !== &#039;DELAY&#039;;&lt;br /&gt;
		} ).map( function ( s ) {&lt;br /&gt;
			return { key: s.key, label: s.label, columnDefault: s.columnDefault };&lt;br /&gt;
		} );&lt;br /&gt;
		var trail = [&lt;br /&gt;
			{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;source&#039;, label: &#039;Source&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true, columnDefault: false },&lt;br /&gt;
			{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true, columnDefault: false },&lt;br /&gt;
			{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true, columnDefault: false }&lt;br /&gt;
		];&lt;br /&gt;
		return lead.concat( statCols ).concat( trail );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		var weights = {};&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = !!c.scoreDefault;&lt;br /&gt;
			weights[ c.key ] = c.weight;&lt;br /&gt;
		} );&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = c.always ? true : !!c.columnDefault;&lt;br /&gt;
		} );&lt;br /&gt;
		var effectBonus = {};&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			effectBonus[ t ] = EFFECTS[ t ].bonus;&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: CONFIG.LEVEL_MIN,&lt;br /&gt;
			maxLevel: CONFIG.LEVEL_MAX,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			componentWeights: weights,&lt;br /&gt;
			effectBonus: effectBonus,&lt;br /&gt;
			columns: cols,&lt;br /&gt;
			columnOrder: COLUMNS.map( function ( c ) { return c.key; } )&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column. The live fragment header is&lt;br /&gt;
			// `Name | Drops From | AC | WT | STR | ...` — 18 columns, with NO&lt;br /&gt;
			// Damage/Delay column, so Drops From is index 1. (An older 19-column&lt;br /&gt;
			// layout with Damage/Delay is what the implementation plan documents;&lt;br /&gt;
			// reading index 2 here silently returned the AC value as the source.)&lt;br /&gt;
			var $src = $cells.eq( 1 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; :&lt;br /&gt;
				( /crafted/i.test( srcText ) ? &#039;crafted&#039; : &#039;drop&#039; );&lt;br /&gt;
&lt;br /&gt;
			// Mob pages linked from the source cell. Redlinks (.new) are skipped —&lt;br /&gt;
			// there is no page to export.&lt;br /&gt;
			var mobs = [];&lt;br /&gt;
			$src.find( &#039;a&#039; ).each( function () {&lt;br /&gt;
				var $a = $( this );&lt;br /&gt;
				if ( $a.hasClass( &#039;new&#039; ) ) {&lt;br /&gt;
					return;&lt;br /&gt;
				}&lt;br /&gt;
				var t = titleFromHref( $a.attr( &#039;href&#039; ), null );&lt;br /&gt;
				if ( t &amp;amp;&amp;amp; mobs.indexOf( t ) === -1 ) {&lt;br /&gt;
					mobs.push( t );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				sourceMobs: mobs,&lt;br /&gt;
				// enrichment placeholders. available: true|false|null (null = unknown)&lt;br /&gt;
				enrich: { level: null, zone: null, available: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment: zone + availability (item categories) ----&lt;br /&gt;
&lt;br /&gt;
	function titleFromHref( href, name ) {&lt;br /&gt;
		if ( href ) {&lt;br /&gt;
			// Redlinks and any /index.php URL carry the page in ?title=; pretty URLs&lt;br /&gt;
			// put it in the path.&lt;br /&gt;
			var q = /[?&amp;amp;]title=([^&amp;amp;#]+)/.exec( href );&lt;br /&gt;
			var t = q ? q[ 1 ] : href&lt;br /&gt;
				.replace( /^https?:\/\/[^/]+/, &#039;&#039; )&lt;br /&gt;
				.replace( /^\/(wiki\/)?/, &#039;&#039; )&lt;br /&gt;
				.split( &#039;#&#039; )[ 0 ]&lt;br /&gt;
				.split( &#039;?&#039; )[ 0 ];&lt;br /&gt;
			try { t = decodeURIComponent( t ); } catch ( e ) {}&lt;br /&gt;
			if ( t ) { return t.replace( /_/g, &#039; &#039; ); }&lt;br /&gt;
		}&lt;br /&gt;
		return name || &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function resolveFinalTitle( title, alias ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		while ( alias[ title ] &amp;amp;&amp;amp; !seen[ title ] ) {&lt;br /&gt;
			seen[ title ] = 1;&lt;br /&gt;
			title = alias[ title ];&lt;br /&gt;
		}&lt;br /&gt;
		return title;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/* ---- bulk wikitext via Special:Export ---------------------------------&lt;br /&gt;
	 *&lt;br /&gt;
	 * Special:Export accepts an arbitrary-length newline-separated page list in a&lt;br /&gt;
	 * single POST and is NOT bound by the API&#039;s 50-title cap (api.php&lt;br /&gt;
	 * prop=revisions). One query&#039;s worth of items or mobs therefore costs ONE&lt;br /&gt;
	 * request instead of ceil(n/50), which is what the old categories /&lt;br /&gt;
	 * transcludedin / revisions fan-out cost (~130 requests fired simultaneously&lt;br /&gt;
	 * for a large class+slot query).&lt;br /&gt;
	 *&lt;br /&gt;
	 * Two gotchas, both verified against the live wiki:&lt;br /&gt;
	 *  - `templates` must be OMITTED, not sent as 0. The form treats presence as&lt;br /&gt;
	 *    &amp;quot;checked&amp;quot;, so sending templates=0 pulls in every transcluded page:&lt;br /&gt;
	 *    987KB / 453 pages instead of 160KB / 61 pages for the same 61 mobs.&lt;br /&gt;
	 *  - Export does NOT follow redirects. A redirect exports as its literal&lt;br /&gt;
	 *    &amp;quot;#REDIRECT [[:Target]]&amp;quot; wikitext, so we read the target back out of the&lt;br /&gt;
	 *    response and re-export — bounded by EXPORT_REDIRECT_ROUNDS.&lt;br /&gt;
	 */&lt;br /&gt;
&lt;br /&gt;
	var EXPORT_CHUNK = 500;          // titles per POST&lt;br /&gt;
	var EXPORT_REDIRECT_ROUNDS = 2;  // redirect-following passes&lt;br /&gt;
&lt;br /&gt;
	var REDIRECT_RE = /^\s*#REDIRECT\s*\[\[\s*:?\s*([^\]|#]+?)\s*(?:\|[^\]]*)?\]\]/i;&lt;br /&gt;
	var LINK_RE = /\[\[\s*:?\s*([^\]|#]+?)\s*(?:\|[^\]]*)?\]\]/g;&lt;br /&gt;
&lt;br /&gt;
	function parseExportXml( xml ) {&lt;br /&gt;
		var out = {};&lt;br /&gt;
		var doc;&lt;br /&gt;
		try {&lt;br /&gt;
			doc = new window.DOMParser().parseFromString( xml, &#039;text/xml&#039; );&lt;br /&gt;
		} catch ( e ) {&lt;br /&gt;
			return out;&lt;br /&gt;
		}&lt;br /&gt;
		var pages = doc.getElementsByTagName( &#039;page&#039; );&lt;br /&gt;
		for ( var i = 0; i &amp;lt; pages.length; i++ ) {&lt;br /&gt;
			var t = pages[ i ].getElementsByTagName( &#039;title&#039; )[ 0 ];&lt;br /&gt;
			var x = pages[ i ].getElementsByTagName( &#039;text&#039; )[ 0 ];&lt;br /&gt;
			if ( t ) {&lt;br /&gt;
				out[ t.textContent ] = x ? x.textContent : &#039;&#039;;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return out;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function exportChunk( titles ) {&lt;br /&gt;
		return $.ajax( {&lt;br /&gt;
			url: ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; ) + &#039;?title=Special:Export&#039;,&lt;br /&gt;
			method: &#039;POST&#039;,&lt;br /&gt;
			dataType: &#039;text&#039;,&lt;br /&gt;
			// NOTE: no `templates` key — see the block comment above.&lt;br /&gt;
			data: { pages: titles.join( &#039;\n&#039; ), curonly: 1 }&lt;br /&gt;
		} ).then( parseExportXml, function () { return {}; } );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Runs thunks one at a time. Chunks are large, so serial keeps us to a couple&lt;br /&gt;
	// of in-flight requests rather than the old simultaneous burst.&lt;br /&gt;
	function runSequential( tasks ) {&lt;br /&gt;
		var results = [];&lt;br /&gt;
		var d = $.Deferred();&lt;br /&gt;
		function next( i ) {&lt;br /&gt;
			if ( i &amp;gt;= tasks.length ) {&lt;br /&gt;
				d.resolve( results );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			tasks[ i ]().then( function ( r ) {&lt;br /&gt;
				results.push( r );&lt;br /&gt;
				next( i + 1 );&lt;br /&gt;
			}, function () {&lt;br /&gt;
				next( i + 1 );&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		next( 0 );&lt;br /&gt;
		return d.promise();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Resolves to { content: {title: wikitext}, alias: {redirectFrom: to} }.&lt;br /&gt;
	function exportWikitext( titles ) {&lt;br /&gt;
		var content = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
&lt;br /&gt;
		function round( pending, depth ) {&lt;br /&gt;
			var todo = [];&lt;br /&gt;
			pending.forEach( function ( t ) {&lt;br /&gt;
				if ( t &amp;amp;&amp;amp; !Object.prototype.hasOwnProperty.call( content, t ) &amp;amp;&amp;amp;&lt;br /&gt;
					todo.indexOf( t ) === -1 ) {&lt;br /&gt;
					todo.push( t );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !todo.length ) {&lt;br /&gt;
				return $.Deferred().resolve().promise();&lt;br /&gt;
			}&lt;br /&gt;
			var chunks = [];&lt;br /&gt;
			for ( var i = 0; i &amp;lt; todo.length; i += EXPORT_CHUNK ) {&lt;br /&gt;
				chunks.push( todo.slice( i, i + EXPORT_CHUNK ) );&lt;br /&gt;
			}&lt;br /&gt;
			return runSequential( chunks.map( function ( c ) {&lt;br /&gt;
				return function () { return exportChunk( c ); };&lt;br /&gt;
			} ) ).then( function ( maps ) {&lt;br /&gt;
				var follow = [];&lt;br /&gt;
				maps.forEach( function ( m ) {&lt;br /&gt;
					Object.keys( m ).forEach( function ( t ) {&lt;br /&gt;
						content[ t ] = m[ t ];&lt;br /&gt;
						var rm = REDIRECT_RE.exec( m[ t ] || &#039;&#039; );&lt;br /&gt;
						if ( rm ) {&lt;br /&gt;
							var target = rm[ 1 ].trim().replace( /_/g, &#039; &#039; );&lt;br /&gt;
							alias[ t ] = target;&lt;br /&gt;
							follow.push( target );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				if ( follow.length &amp;amp;&amp;amp; depth &amp;lt; EXPORT_REDIRECT_ROUNDS ) {&lt;br /&gt;
					return round( follow, depth + 1 );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return round( titles, 0 ).then( function () {&lt;br /&gt;
			return { content: content, alias: alias };&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function exportText( bundle, title ) {&lt;br /&gt;
		var t = resolveFinalTitle( title, bundle.alias );&lt;br /&gt;
		return bundle.content[ t ] || bundle.content[ title ] || &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- wikitext field parsing --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Captures &amp;quot;|name = value&amp;quot; from a template call, stopping at the next&lt;br /&gt;
	// &amp;quot;|param =&amp;quot; line or the closing &amp;quot;}}&amp;quot;. Itempage / Namedmobpage both use the&lt;br /&gt;
	// one-param-per-line layout this relies on.&lt;br /&gt;
	function templateParam( text, name ) {&lt;br /&gt;
		var re = new RegExp(&lt;br /&gt;
			&#039;^[ \\t]*\\|[ \\t]*&#039; + name + &#039;[ \\t]*=([\\s\\S]*?)&#039; +&lt;br /&gt;
			&#039;(?=^[ \\t]*\\|[ \\t]*[A-Za-z_][A-Za-z0-9_ ]*[ \\t]*=|^[ \\t]*\\}\\})&#039;,&lt;br /&gt;
			&#039;im&#039;&lt;br /&gt;
		);&lt;br /&gt;
		var m = String( text || &#039;&#039; ).match( re );&lt;br /&gt;
		return m ? $.trim( m[ 1 ] ) : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function firstLink( text ) {&lt;br /&gt;
		LINK_RE.lastIndex = 0;&lt;br /&gt;
		var m = LINK_RE.exec( String( text || &#039;&#039; ) );&lt;br /&gt;
		return m ? m[ 1 ].trim() : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function stripLinks( text ) {&lt;br /&gt;
		return $.trim( String( text || &#039;&#039; ).replace( LINK_RE, &#039;$1&#039; ) );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// dropsfrom lists the zone as a bare link and each mob as a list item:&lt;br /&gt;
	//     [[Rathe Mountains]]&lt;br /&gt;
	//     * [[Quid Rilstone]]&lt;br /&gt;
	function parseDropsFrom( text ) {&lt;br /&gt;
		var zones = [];&lt;br /&gt;
		var mobs = [];&lt;br /&gt;
		String( text || &#039;&#039; ).split( /\r?\n/ ).forEach( function ( line ) {&lt;br /&gt;
			var bucket = /^\s*[*#:;]/.test( line ) ? mobs : zones;&lt;br /&gt;
			var m;&lt;br /&gt;
			LINK_RE.lastIndex = 0;&lt;br /&gt;
			while ( ( m = LINK_RE.exec( line ) ) !== null ) {&lt;br /&gt;
				var v = m[ 1 ].trim();&lt;br /&gt;
				if ( v &amp;amp;&amp;amp; bucket.indexOf( v ) === -1 ) {&lt;br /&gt;
					bucket.push( v );&lt;br /&gt;
				}&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		return { zones: zones, mobs: mobs };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function median( nums ) {&lt;br /&gt;
		if ( !nums.length ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		var s = nums.slice().sort( function ( a, b ) { return a - b; } );&lt;br /&gt;
		var m = Math.floor( s.length / 2 );&lt;br /&gt;
		return s.length % 2 ? s[ m ] : Math.round( ( s[ m - 1 ] + s[ m ] ) / 2 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// &amp;quot;47&amp;quot; -&amp;gt; 47; a range like &amp;quot;10-12&amp;quot; -&amp;gt; its median (Requirement: median level).&lt;br /&gt;
	function levelValue( raw ) {&lt;br /&gt;
		var nums = String( raw || &#039;&#039; ).match( /\d+/g );&lt;br /&gt;
		return nums ? median( nums.map( Number ) ) : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function parseQuestPage( text ) {&lt;br /&gt;
		var out = { level: null, zone: null };&lt;br /&gt;
		var ml = /Minimum Level:[\s\S]{0,200}?(\d+)/i.exec( text );&lt;br /&gt;
		if ( ml ) {&lt;br /&gt;
			out.level = parseInt( ml[ 1 ], 10 );&lt;br /&gt;
		}&lt;br /&gt;
		var sz = /Start Zone:[\s\S]{0,200}?\[\[\s*:?\s*([^\]|#]+)/i.exec( text );&lt;br /&gt;
		if ( sz ) {&lt;br /&gt;
			out.zone = sz[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
		return out;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment ---------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Zone + availability (Requirement #1). zonesRaw is every zone the item drops&lt;br /&gt;
	// in; zonesOk is the subset currently available. Dropping only into out-of-era&lt;br /&gt;
	// zones is what makes an item unavailable — no zone info at all leaves it&lt;br /&gt;
	// unknown (null) so it is kept rather than silently hidden.&lt;br /&gt;
	function applyZones( it, zonesRaw ) {&lt;br /&gt;
		var zonesOk = [];&lt;br /&gt;
		zonesRaw.forEach( function ( z ) {&lt;br /&gt;
			var cz = canonicalZone( z );&lt;br /&gt;
			if ( cz &amp;amp;&amp;amp; zonesOk.indexOf( cz ) === -1 ) {&lt;br /&gt;
				zonesOk.push( cz );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( zonesOk.length === 1 ) {&lt;br /&gt;
			it.enrich.zone = zonesOk[ 0 ];&lt;br /&gt;
			it.enrich.available = true;&lt;br /&gt;
		} else if ( zonesOk.length &amp;gt; 1 ) {&lt;br /&gt;
			it.enrich.zone = &#039;multiple&#039;;&lt;br /&gt;
			it.enrich.available = true;&lt;br /&gt;
		} else if ( zonesRaw.length ) {&lt;br /&gt;
			it.enrich.zone = null;&lt;br /&gt;
			it.enrich.available = false; // drops only in out-of-scope zones&lt;br /&gt;
		}&lt;br /&gt;
		it.enrich.zones = zonesOk;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Phase 1 — item pages: focus_effect + dropsfrom (zone list + mob list).&lt;br /&gt;
	function enrichFromItemPages( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			it.title = titleFromHref( it.href, it.name );&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; titles.indexOf( it.title ) === -1 ) {&lt;br /&gt;
				titles.push( it.title );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return exportWikitext( titles ).then( function ( bundle ) {&lt;br /&gt;
			items.forEach( function ( it ) {&lt;br /&gt;
				var text = exportText( bundle, it.title );&lt;br /&gt;
&lt;br /&gt;
				// focus_effect is an Itempage PARAM and never appears inside&lt;br /&gt;
				// statsblock, which is why parsing the popover text alone always&lt;br /&gt;
				// left the Focus column blank.&lt;br /&gt;
				var focus = stripLinks( templateParam( text, &#039;focus_effect&#039; ) );&lt;br /&gt;
				if ( focus ) {&lt;br /&gt;
					it.fields.effects.push( { type: &#039;focus&#039;, name: focus } );&lt;br /&gt;
					it.fields.effectTypes.focus = true;&lt;br /&gt;
				}&lt;br /&gt;
&lt;br /&gt;
				var drops = parseDropsFrom( templateParam( text, &#039;dropsfrom&#039; ) );&lt;br /&gt;
				drops.mobs.forEach( function ( m ) {&lt;br /&gt;
					if ( it.sourceMobs.indexOf( m ) === -1 ) {&lt;br /&gt;
						it.sourceMobs.push( m );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				applyZones( it, drops.zones );&lt;br /&gt;
			} );&lt;br /&gt;
			return items;&lt;br /&gt;
		}, function () {&lt;br /&gt;
			return items; // enrichment failed -&amp;gt; keep everything, unfiltered&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Phase 2 — mob pages: level (median across every mob that drops the item).&lt;br /&gt;
	function enrichFromMobPages( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			it.sourceMobs.forEach( function ( m ) {&lt;br /&gt;
				if ( titles.indexOf( m ) === -1 ) {&lt;br /&gt;
					titles.push( m );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return exportWikitext( titles ).then( function ( bundle ) {&lt;br /&gt;
			var info = {};&lt;br /&gt;
			titles.forEach( function ( t ) {&lt;br /&gt;
				var text = exportText( bundle, t );&lt;br /&gt;
				info[ t ] = {&lt;br /&gt;
					level: levelValue( templateParam( text, &#039;level&#039; ) ),&lt;br /&gt;
					zone: firstLink( templateParam( text, &#039;zone&#039; ) )&lt;br /&gt;
				};&lt;br /&gt;
			} );&lt;br /&gt;
			items.forEach( function ( it ) {&lt;br /&gt;
				var levels = [];&lt;br /&gt;
				var zones = [];&lt;br /&gt;
				it.sourceMobs.forEach( function ( m ) {&lt;br /&gt;
					var i = info[ m ];&lt;br /&gt;
					if ( !i ) {&lt;br /&gt;
						return;&lt;br /&gt;
					}&lt;br /&gt;
					if ( i.level != null ) {&lt;br /&gt;
						levels.push( i.level );&lt;br /&gt;
					}&lt;br /&gt;
					if ( i.zone &amp;amp;&amp;amp; zones.indexOf( i.zone ) === -1 ) {&lt;br /&gt;
						zones.push( i.zone );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				if ( levels.length ) {&lt;br /&gt;
					it.enrich.level = median( levels );&lt;br /&gt;
				}&lt;br /&gt;
				// Only mobs know the zone when the item page omitted dropsfrom.&lt;br /&gt;
				if ( it.enrich.available == null &amp;amp;&amp;amp; zones.length ) {&lt;br /&gt;
					applyZones( it, zones );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			return items;&lt;br /&gt;
		}, function () {&lt;br /&gt;
			return items;&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Phase 3 — quest pages, for &amp;quot;Quested&amp;quot; items only. Quest pages transclude the&lt;br /&gt;
	// item, so transcludedin maps item -&amp;gt; quest. This is the one place the 50-title&lt;br /&gt;
	// API cap still applies, but it runs over a small subset (quested items only)&lt;br /&gt;
	// rather than the whole result set.&lt;br /&gt;
	function fetchQuestPages( titles ) {&lt;br /&gt;
		var map = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
			return runSequential( batches.map( function ( batch ) {&lt;br /&gt;
				return function () {&lt;br /&gt;
					return api.get( {&lt;br /&gt;
						action: &#039;query&#039;, prop: &#039;transcludedin&#039;, titles: batch.join( &#039;|&#039; ),&lt;br /&gt;
						tinamespace: 0, tilimit: &#039;max&#039;, redirects: 1, format: &#039;json&#039;&lt;br /&gt;
					} ).then( function ( data ) {&lt;br /&gt;
						var q = data.query || {};&lt;br /&gt;
						( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );&lt;br /&gt;
						( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );&lt;br /&gt;
						var pages = q.pages || {};&lt;br /&gt;
						Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
							var pg = pages[ pid ];&lt;br /&gt;
							if ( !pg.title ) { return; }&lt;br /&gt;
							var arr = map[ pg.title ] = map[ pg.title ] || [];&lt;br /&gt;
							( pg.transcludedin || [] ).forEach( function ( t ) {&lt;br /&gt;
								if ( arr.indexOf( t.title ) === -1 ) { arr.push( t.title ); }&lt;br /&gt;
							} );&lt;br /&gt;
						} );&lt;br /&gt;
					}, function () {} );&lt;br /&gt;
				};&lt;br /&gt;
			} ) );&lt;br /&gt;
		} ).then( function () { return { map: map, alias: alias }; } );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function enrichQuests( items ) {&lt;br /&gt;
		var quested = items.filter( function ( it ) {&lt;br /&gt;
			return it.source.type === &#039;quest&#039;;&lt;br /&gt;
		} );&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		quested.forEach( function ( it ) {&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; titles.indexOf( it.title ) === -1 ) {&lt;br /&gt;
				titles.push( it.title );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return fetchQuestPages( titles ).then( function ( ti ) {&lt;br /&gt;
			var pageSet = {};&lt;br /&gt;
			Object.keys( ti.map ).forEach( function ( t ) {&lt;br /&gt;
				ti.map[ t ].forEach( function ( p ) { pageSet[ p ] = true; } );&lt;br /&gt;
			} );&lt;br /&gt;
			var pages = Object.keys( pageSet );&lt;br /&gt;
			if ( !pages.length ) {&lt;br /&gt;
				return items;&lt;br /&gt;
			}&lt;br /&gt;
			return exportWikitext( pages ).then( function ( bundle ) {&lt;br /&gt;
				var info = {};&lt;br /&gt;
				pages.forEach( function ( p ) {&lt;br /&gt;
					var text = exportText( bundle, p );&lt;br /&gt;
					// Only pages that actually look like quests; transcludedin also&lt;br /&gt;
					// returns the mob pages that list the item as loot.&lt;br /&gt;
					if ( /Minimum Level:|Start Zone:|questTopTable/i.test( text ) ) {&lt;br /&gt;
						info[ p ] = parseQuestPage( text );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				quested.forEach( function ( it ) {&lt;br /&gt;
					var srcTitle = resolveFinalTitle( it.title, ti.alias );&lt;br /&gt;
					var found = ( ti.map[ srcTitle ] || ti.map[ it.title ] || [] )&lt;br /&gt;
						.map( function ( p ) { return info[ p ]; } )&lt;br /&gt;
						.filter( Boolean );&lt;br /&gt;
					if ( !found.length ) {&lt;br /&gt;
						if ( it.enrich.level == null ) { it.enrich.level = &#039;quest&#039;; }&lt;br /&gt;
						return;&lt;br /&gt;
					}&lt;br /&gt;
					// Several quests can reward the same item — use the lowest&lt;br /&gt;
					// minimum level, and that quest&#039;s start zone.&lt;br /&gt;
					var withLevel = found.filter( function ( q ) { return q.level != null; } )&lt;br /&gt;
						.sort( function ( a, b ) { return a.level - b.level; } );&lt;br /&gt;
					var chosen = withLevel[ 0 ] || found[ 0 ];&lt;br /&gt;
					it.enrich.level = chosen.level != null ? chosen.level : &#039;quest&#039;;&lt;br /&gt;
					if ( chosen.zone ) {&lt;br /&gt;
						applyZones( it, [ chosen.zone ] );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				return items;&lt;br /&gt;
			} );&lt;br /&gt;
		}, function () { return items; } );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} ).then( enrichFromItemPages ).then( enrichFromMobPages ).then( enrichQuests );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Availability (Requirement #1): drop into a currently-available zone.&lt;br /&gt;
		// available===false means &amp;quot;has drop zone(s), none currently available&amp;quot; -&amp;gt; hide.&lt;br /&gt;
		// null (unknown, e.g. unresolved quest items) is kept.&lt;br /&gt;
		if ( item.enrich.available === false ) {&lt;br /&gt;
			return false;&lt;br /&gt;
		}&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Quest items may carry level &#039;quest&#039; (a string) rather than a number.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( typeof lvl === &#039;number&#039; ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name&lt;br /&gt;
		// variants (&amp;quot;The …&amp;quot;, Guk aliases) line up with the ZONE_AVG_LEVEL keys.&lt;br /&gt;
		// A multi-zone drop matches if ANY of its zones is selected, so selecting&lt;br /&gt;
		// one zone still surfaces the generic drops available there.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			var itemZones = ( item.enrich.zones &amp;amp;&amp;amp; item.enrich.zones.length ) ?&lt;br /&gt;
				item.enrich.zones : [ item.enrich.zone ];&lt;br /&gt;
			var matched = state.zones.some( function ( sel ) {&lt;br /&gt;
				var cz = canonicalZone( sel );&lt;br /&gt;
				return itemZones.some( function ( z ) {&lt;br /&gt;
					return canonicalZone( z ) === cz;&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !matched ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable/parsed stat key (DMG / DELAY / AC / STR / SV_*).&lt;br /&gt;
		// DELAY has scaling &#039;none&#039;, so scaledStat returns the raw delay.&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function pageUrl( title ) {&lt;br /&gt;
		if ( mw.util &amp;amp;&amp;amp; mw.util.getUrl ) {&lt;br /&gt;
			return mw.util.getUrl( title );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;/&#039; + encodeURIComponent( String( title ).replace( / /g, &#039;_&#039; ) );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Effects render as the wiki&#039;s own hover-helper markup:&lt;br /&gt;
	//   &amp;lt;a href=&amp;quot;/X&amp;quot; data-eql-original-title=&amp;quot;X&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;itemeff&amp;quot;&amp;gt;X&amp;lt;/span&amp;gt;&amp;lt;/a&amp;gt;&lt;br /&gt;
	// AjaxHoverHelper instruments these on the wikipage.content hook that render()&lt;br /&gt;
	// already fires, then lazily fetches the popover body on first hover via&lt;br /&gt;
	// action=parse&amp;amp;text={{SpellHoverLink|X}} and marks the span ready so it only&lt;br /&gt;
	// fires once per element. Nothing extra to fetch at render time.&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var $wrap = $( &#039;&amp;lt;span&amp;gt;&#039; );&lt;br /&gt;
		item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.forEach( function ( e, i ) {&lt;br /&gt;
				if ( i ) {&lt;br /&gt;
					$wrap.append( document.createTextNode( &#039;, &#039; ) );&lt;br /&gt;
				}&lt;br /&gt;
				$wrap.append(&lt;br /&gt;
					el( &#039;a&#039;, { href: pageUrl( e.name ), &#039;data-eql-original-title&#039;: e.name },&lt;br /&gt;
						el( &#039;span&#039;, { &#039;class&#039;: &#039;itemeff&#039; }, document.createTextNode( e.name ) ) )&lt;br /&gt;
				);&lt;br /&gt;
			} );&lt;br /&gt;
		return $wrap;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function columnByKey( key ) {&lt;br /&gt;
		for ( var i = 0; i &amp;lt; COLUMNS.length; i++ ) {&lt;br /&gt;
			if ( COLUMNS[ i ].key === key ) {&lt;br /&gt;
				return COLUMNS[ i ];&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Migration: reconcile a saved columnOrder against the current COLUMNS.&lt;br /&gt;
	function normalizeColumnOrder( state ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		var order = ( state.columnOrder || [] ).filter( function ( k ) {&lt;br /&gt;
			var ok = columnByKey( k ) &amp;amp;&amp;amp; !seen[ k ];&lt;br /&gt;
			seen[ k ] = true;&lt;br /&gt;
			return ok;&lt;br /&gt;
		} );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( !seen[ c.key ] ) {&lt;br /&gt;
				order.push( c.key );&lt;br /&gt;
				seen[ c.key ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		state.columnOrder = order;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = state.columnOrder&lt;br /&gt;
			.map( columnByKey )&lt;br /&gt;
			.filter( function ( c ) {&lt;br /&gt;
				return c &amp;amp;&amp;amp; ( c.always || state.columns[ c.key ] );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		var opts = {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			weights: state.componentWeights,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, opts ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;: case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.append( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						var cz = canonicalZone( it.enrich.zone );&lt;br /&gt;
						$td.text( cz ? ZONE_AVG_LEVEL[ cz ] : &#039;—&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Out-of-scope zones are filtered out. Each run costs one request per &#039; +&lt;br /&gt;
			&#039;selected slot plus a couple of bulk Special:Export fetches that resolve &#039; +&lt;br /&gt;
			&#039;zone, focus effect and mob/quest level.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
		var $col1 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col2 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col3 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col4 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		$controls.append( $col1 ).append( $col2 ).append( $col3 ).append( $col4 );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 1: Classes / Slots / Weapon Type ----&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$col1.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$col1.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$col1.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 2: Upgrade Level / Level min-max / Zone ----&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$col2.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$col2.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		var $zoneWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$zoneWrap.append( el( &#039;label&#039;, null, &#039;Zone: &#039; ) );&lt;br /&gt;
		$zoneWrap.append( multiSelect( &#039;eqlis-zones&#039;, Object.keys( ZONE_AVG_LEVEL ).sort(), state.zones ) );&lt;br /&gt;
		$col2.append( $zoneWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 3: Item Score components (check to include; box = weight) ----&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score (check = include, box = weight)&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		function scoreRow( c, extraClass ) {&lt;br /&gt;
			var $row = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; + ( extraClass ? &#039; &#039; + extraClass : &#039;&#039; ) } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			var $wt = el( &#039;input&#039;, {&lt;br /&gt;
				type: &#039;number&#039;, step: 0.5, min: 0, &#039;data-weight&#039;: c.key,&lt;br /&gt;
				value: state.componentWeights[ c.key ], &#039;class&#039;: &#039;eqlis-wt&#039;&lt;br /&gt;
			} );&lt;br /&gt;
			$row.append( $cb )&lt;br /&gt;
				.append( document.createTextNode( &#039; &#039; + c.label + &#039; &#039; ) )&lt;br /&gt;
				.append( $wt );&lt;br /&gt;
			return $row;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Saves + SV Void render indented under Total SV.&lt;br /&gt;
		var $totalCb = null;&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var indent = ( c.group === &#039;save&#039; &amp;amp;&amp;amp; c.special !== &#039;totalSV&#039; );&lt;br /&gt;
			var $row = scoreRow( c, indent ? &#039;eqlis-sv&#039; : &#039;&#039; );&lt;br /&gt;
			$score.append( $row );&lt;br /&gt;
			if ( c.special === &#039;totalSV&#039; ) {&lt;br /&gt;
				$totalCb = $row.find( &#039;input[data-comp]&#039; );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// disable individual saves (not SV Void) when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			if ( !$totalCb ) { return; }&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		if ( $totalCb ) {&lt;br /&gt;
			$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		}&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$col3.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 4: Columns — dual-list chooser (Available -&amp;gt; Shown + order) ----&lt;br /&gt;
		// Column changes apply on the next List Results (no live re-render).&lt;br /&gt;
		normalizeColumnOrder( state );&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $avail = el( &#039;select&#039;, { multiple: &#039;multiple&#039;, size: 10, &#039;class&#039;: &#039;eqlis-dual-list&#039; } );&lt;br /&gt;
		var $shown = el( &#039;select&#039;, { multiple: &#039;multiple&#039;, size: 10, &#039;class&#039;: &#039;eqlis-dual-list&#039; } );&lt;br /&gt;
&lt;br /&gt;
		function dualBtn( glyph, title ) {&lt;br /&gt;
			return el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-dual-btn&#039;, title: title }, glyph );&lt;br /&gt;
		}&lt;br /&gt;
		var $addBtn = dualBtn( &#039;▶&#039;, &#039;Show selected&#039; );&lt;br /&gt;
		var $remBtn = dualBtn( &#039;◀&#039;, &#039;Hide selected&#039; );&lt;br /&gt;
		var $upBtn = dualBtn( &#039;▲&#039;, &#039;Move up&#039; );&lt;br /&gt;
		var $downBtn = dualBtn( &#039;▼&#039;, &#039;Move down&#039; );&lt;br /&gt;
&lt;br /&gt;
		var $availSide = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-side&#039; } )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-cap&#039; }, &#039;Available&#039; ) )&lt;br /&gt;
			.append( $avail );&lt;br /&gt;
		var $mid = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-mid&#039; } ).append( $addBtn ).append( $remBtn );&lt;br /&gt;
		var $shownSide = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-side&#039; } )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-cap&#039; }, &#039;Shown (top = left)&#039; ) )&lt;br /&gt;
			.append( $shown )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-ord&#039; } ).append( $upBtn ).append( $downBtn ) );&lt;br /&gt;
&lt;br /&gt;
		$cols.append(&lt;br /&gt;
			el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual&#039; } ).append( $availSide ).append( $mid ).append( $shownSide )&lt;br /&gt;
		);&lt;br /&gt;
		$col4.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		function isShown( c ) {&lt;br /&gt;
			return c.always || state.columns[ c.key ];&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		function renderColumnChooser( keepSel ) {&lt;br /&gt;
			$avail.empty();&lt;br /&gt;
			$shown.empty();&lt;br /&gt;
			state.columnOrder.forEach( function ( key ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( !c ) { return; }&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: key }, document.createTextNode( c.label ) );&lt;br /&gt;
				( isShown( c ) ? $shown : $avail ).append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			if ( keepSel ) {&lt;br /&gt;
				$shown.val( keepSel );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$addBtn.on( &#039;click&#039;, function () {&lt;br /&gt;
			( $avail.val() || [] ).forEach( function ( key ) {&lt;br /&gt;
				state.columns[ key ] = true;&lt;br /&gt;
				var i = state.columnOrder.indexOf( key ); // move to end so it lands last&lt;br /&gt;
				if ( i !== -1 ) {&lt;br /&gt;
					state.columnOrder.splice( i, 1 );&lt;br /&gt;
					state.columnOrder.push( key );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser();&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$remBtn.on( &#039;click&#039;, function () {&lt;br /&gt;
			( $shown.val() || [] ).forEach( function ( key ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( c &amp;amp;&amp;amp; !c.always ) {&lt;br /&gt;
					state.columns[ key ] = false;&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser();&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		function moveShown( dir ) {&lt;br /&gt;
			var sel = $shown.val() || [];&lt;br /&gt;
			if ( sel.length !== 1 ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var key = sel[ 0 ];&lt;br /&gt;
			var shownKeys = state.columnOrder.filter( function ( k ) {&lt;br /&gt;
				var c = columnByKey( k );&lt;br /&gt;
				return c &amp;amp;&amp;amp; isShown( c );&lt;br /&gt;
			} );&lt;br /&gt;
			var si = shownKeys.indexOf( key );&lt;br /&gt;
			if ( si + dir &amp;lt; 0 || si + dir &amp;gt;= shownKeys.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var otherKey = shownKeys[ si + dir ];&lt;br /&gt;
			var a = state.columnOrder.indexOf( key );&lt;br /&gt;
			var b = state.columnOrder.indexOf( otherKey );&lt;br /&gt;
			state.columnOrder[ a ] = otherKey;&lt;br /&gt;
			state.columnOrder[ b ] = key;&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser( [ key ] );&lt;br /&gt;
		}&lt;br /&gt;
		$upBtn.on( &#039;click&#039;, function () { moveShown( -1 ); } );&lt;br /&gt;
		$downBtn.on( &#039;click&#039;, function () { moveShown( 1 ); } );&lt;br /&gt;
&lt;br /&gt;
		renderColumnChooser();&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.zones = readMulti( $( &#039;#eqlis-zones&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$score.find( &#039;input[data-weight]&#039; ).each( function () {&lt;br /&gt;
				var v = parseFloat( $( this ).val() );&lt;br /&gt;
				state.componentWeights[ $( this ).attr( &#039;data-weight&#039; ) ] = isNaN( v ) ? 1 : v;&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			// Column show/hide + order is managed directly by the dual-list chooser.&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// Seed the wiki&#039;s ItemLevelSlider default so popover sliders initialize at&lt;br /&gt;
			// the upgrade level chosen here (its storage key is &#039;ils-default-level-v2&#039;).&lt;br /&gt;
			try {&lt;br /&gt;
				window.localStorage.setItem( &#039;ils-default-level-v2&#039;,&lt;br /&gt;
					JSON.stringify( { full: state.level, fraction: 0 } ) );&lt;br /&gt;
			} catch ( e ) {}&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// NOTE: intentionally no live re-render on control changes. Per spec, the&lt;br /&gt;
		// table only (re)builds when List Results is pressed — moving the upgrade&lt;br /&gt;
		// slider or toggling score/columns changes persisted state but does not&lt;br /&gt;
		// touch the results until the next List Results click.&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		// mw.util.getUrl builds the effect-link hrefs; pageUrl falls back if absent.&lt;br /&gt;
		if ( mw.loader ) {&lt;br /&gt;
			mw.loader.using( [ &#039;mediawiki.util&#039; ] );&lt;br /&gt;
		}&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=162216</id>
		<title>User:Chonx/eqlItemScore.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=162216"/>
		<updated>2026-07-24T21:30:34Z</updated>

		<summary type="html">&lt;p&gt;Chonx: revamps&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/**&lt;br /&gt;
 * ext.itemScoreTable — EQL Equipment Analyzer / Item Score guide.&lt;br /&gt;
 *&lt;br /&gt;
 * Mounts into &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;. All data access goes through&lt;br /&gt;
 * ACTIVE_DATASOURCE so a server-side Special:ItemScoreTable can later replace&lt;br /&gt;
 * ClientDataSource with a one-line change.&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	// Styles are injected from JS — the module has no separate CSS channel.&lt;br /&gt;
	( function () {&lt;br /&gt;
		var s = document.createElement( &#039;style&#039; );&lt;br /&gt;
		s.setAttribute( &#039;data-eqlis&#039;, &#039;1&#039; );&lt;br /&gt;
		s.textContent = &amp;quot;/* EQL Item Score Table \u2014 styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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 &amp;gt; 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&amp;quot;;&lt;br /&gt;
		( document.head || document.documentElement ).appendChild( s );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
	// Only truly-global tunables live here. Anything specific to a stat, a score&lt;br /&gt;
	// component, or an effect lives ON that stat/component/effect (STATS, the&lt;br /&gt;
	// SCORE_COMPONENTS entries, EFFECTS) — the same way DMG&#039;s params live on its row.&lt;br /&gt;
	var CONFIG = {&lt;br /&gt;
		UPGRADE_MAX: 10,   // item upgrade slider range 0..UPGRADE_MAX&lt;br /&gt;
		LEVEL_MIN: 1,      // Level min/max filter bounds&lt;br /&gt;
		LEVEL_MAX: 100,&lt;br /&gt;
		SCORE_ROUND: 1000  // score rounded to 1/SCORE_ROUND&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = CONFIG.UPGRADE_MAX;&lt;br /&gt;
&lt;br /&gt;
	/*&lt;br /&gt;
	 * SINGLE SOURCE OF TRUTH for every stat. Each entry owns everything about that&lt;br /&gt;
	 * stat: parse aliases, scaling class, SV-Void group, default score weight, and&lt;br /&gt;
	 * default enablement as a score component / table column. STAT_ALIASES,&lt;br /&gt;
	 * PRIMARY_STATS, SV_VOID_QUALIFIERS, the stat columns and stat score-components&lt;br /&gt;
	 * are all DERIVED from this — nothing about a stat is listed twice.&lt;br /&gt;
	 *&lt;br /&gt;
	 * group:   attr | save | defense | weapon  (drives SV-Void qualification)&lt;br /&gt;
	 * scaling: primary | damage | none          (mirror of ext.itemLevelSlider rules)&lt;br /&gt;
	 */&lt;br /&gt;
	var STATS = [&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039;, aliases: [ &#039;DMG&#039;, &#039;DAMAGE&#039; ], group: &#039;weapon&#039;, scaling: &#039;damage&#039;, weight: 1, column: true, columnDefault: true, score: false, scoreDefault: false },&lt;br /&gt;
		// delay is parsed like DMG (alias &amp;quot;Atk Delay&amp;quot;); ItemLevelSlider leaves it&lt;br /&gt;
		// unchanged (scaling &#039;none&#039;). scoreScale is its scoring param (score = scale/delay).&lt;br /&gt;
		{ key: &#039;DELAY&#039;, label: &#039;Delay&#039;, aliases: [ &#039;ATK DELAY&#039; ], group: &#039;weapon&#039;, scaling: &#039;none&#039;, weight: 1, scoreScale: 100, column: true, columnDefault: true, score: false, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039;, aliases: [ &#039;AC&#039;, &#039;ARMOR CLASS&#039; ], group: &#039;defense&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039;, aliases: [ &#039;STR&#039;, &#039;STRENGTH&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039;, aliases: [ &#039;STA&#039;, &#039;STAMINA&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039;, aliases: [ &#039;AGI&#039;, &#039;AGILITY&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039;, aliases: [ &#039;DEX&#039;, &#039;DEXTERITY&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039;, aliases: [ &#039;INT&#039;, &#039;INTELLIGENCE&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039;, aliases: [ &#039;WIS&#039;, &#039;WISDOM&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039;, aliases: [ &#039;CHA&#039;, &#039;CHARISMA&#039; ], group: &#039;attr&#039;, scaling: &#039;primary&#039;, weight: 1, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039;, aliases: [ &#039;SV MAGIC&#039;, &#039;MAGIC&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039;, aliases: [ &#039;SV FIRE&#039;, &#039;FIRE&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039;, aliases: [ &#039;SV COLD&#039;, &#039;COLD&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039;, aliases: [ &#039;SV POISON&#039;, &#039;POISON&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039;, aliases: [ &#039;SV DISEASE&#039;, &#039;DISEASE&#039; ], group: &#039;save&#039;, scaling: &#039;primary&#039;, weight: 0.5, column: true, columnDefault: true, score: true, scoreDefault: false }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var STAT_BY_KEY = {};&lt;br /&gt;
	STATS.forEach( function ( s ) { STAT_BY_KEY[ s.key ] = s; } );&lt;br /&gt;
&lt;br /&gt;
	// Everything below is derived from STATS.&lt;br /&gt;
	var STAT_ALIASES = {};&lt;br /&gt;
	STATS.forEach( function ( s ) {&lt;br /&gt;
		s.aliases.forEach( function ( a ) { STAT_ALIASES[ a.toUpperCase() ] = s.key; } );&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	var PRIMARY_STATS = STATS.filter( function ( s ) {&lt;br /&gt;
		return s.scaling === &#039;primary&#039;;&lt;br /&gt;
	} ).map( function ( s ) { return s.key; } );&lt;br /&gt;
&lt;br /&gt;
	var SV_VOID_QUALIFIERS = STATS.filter( function ( s ) {&lt;br /&gt;
		return s.group === &#039;attr&#039; || s.group === &#039;save&#039;;&lt;br /&gt;
	} ).map( function ( s ) { return s.key; } );&lt;br /&gt;
&lt;br /&gt;
	// Effects that add a flat score bonus when present on an item. Each owns its&lt;br /&gt;
	// own default bonus (EFFECT_TYPES is derived for code that just needs the keys).&lt;br /&gt;
	var EFFECTS = {&lt;br /&gt;
		focus: { bonus: 25 },&lt;br /&gt;
		worn: { bonus: 25 },&lt;br /&gt;
		click: { bonus: 25 },&lt;br /&gt;
		combat: { bonus: 25 }&lt;br /&gt;
	};&lt;br /&gt;
	var EFFECT_TYPES = Object.keys( EFFECTS );&lt;br /&gt;
&lt;br /&gt;
	/* =====================================================================&lt;br /&gt;
	 * MIRROR of ext.itemLevelSlider.js — keep in sync until that module&lt;br /&gt;
	 * exposes these. excelRound / intFloor / scalePrimaryStat / scaleDamage&lt;br /&gt;
	 * are copied verbatim from its scaling logic. This is the ONLY copy of&lt;br /&gt;
	 * that math in this file; do not re-implement it elsewhere.&lt;br /&gt;
	 * ===================================================================== */&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas ----&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Callers must strip HTML first. focus_effect isn&#039;t in the stat block — enrichment sets it.&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number (incl. DELAY)&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Weapon skill / type only. The delay value itself is parsed as the DELAY&lt;br /&gt;
		// stat by the generic stat loop below (its alias is &amp;quot;Atk Delay&amp;quot;).&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		var stat = STAT_BY_KEY[ key ];&lt;br /&gt;
		if ( !stat ) {&lt;br /&gt;
			return base;&lt;br /&gt;
		}&lt;br /&gt;
		if ( stat.scaling === &#039;primary&#039; ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( stat.scaling === &#039;damage&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		var delay = item.stats.DELAY;&lt;br /&gt;
		if ( !delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Order here is the display order in the score panel.&lt;br /&gt;
	var SCORE_COMPONENTS = ( function () {&lt;br /&gt;
		var list = [];&lt;br /&gt;
&lt;br /&gt;
		// ratio is fully derived (no parsed stat), so it owns its own scale here.&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;ratio&#039;, label: &#039;Weapon Ratio&#039;, group: &#039;weapon&#039;,&lt;br /&gt;
			weight: 1, scoreDefault: false, scale: 25, // ratio score = (dmg/delay) * scale&lt;br /&gt;
			compute: function ( item, level ) {&lt;br /&gt;
				var dmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
				var delay = item.stats.DELAY;&lt;br /&gt;
				return ( delay &amp;gt; 0 &amp;amp;&amp;amp; dmg &amp;gt; 0 ) ? ( dmg / delay ) * this.scale : 0;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;DMG&#039;, label: &#039;Weapon Damage&#039;, group: &#039;weapon&#039;,&lt;br /&gt;
			weight: STAT_BY_KEY.DMG.weight, scoreDefault: false,&lt;br /&gt;
			compute: function ( item, level ) { return scaledStat( item, &#039;DMG&#039;, level ); }&lt;br /&gt;
		} );&lt;br /&gt;
		// delay&#039;s scoring scale lives on its STATS row (score = scoreScale / delay).&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;DELAY&#039;, label: &#039;Weapon Delay&#039;, group: &#039;weapon&#039;,&lt;br /&gt;
			weight: STAT_BY_KEY.DELAY.weight, scoreDefault: false,&lt;br /&gt;
			compute: function ( item ) {&lt;br /&gt;
				var delay = item.stats.DELAY;&lt;br /&gt;
				return delay &amp;gt; 0 ? STAT_BY_KEY.DELAY.scoreScale / delay : 0;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		STATS.forEach( function ( s ) {&lt;br /&gt;
			if ( s.score &amp;amp;&amp;amp; s.group !== &#039;save&#039; ) {&lt;br /&gt;
				list.push( {&lt;br /&gt;
					key: s.key, label: s.label, group: s.group,&lt;br /&gt;
					weight: s.weight, scoreDefault: s.scoreDefault,&lt;br /&gt;
					compute: function ( item, level ) { return scaledStat( item, s.key, level ); }&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;totalSV&#039;, label: &#039;Total SV&#039;, group: &#039;save&#039;, special: &#039;totalSV&#039;,&lt;br /&gt;
			weight: 0.5, scoreDefault: false,&lt;br /&gt;
			compute: function ( item, level ) {&lt;br /&gt;
				var total = 0;&lt;br /&gt;
				STATS.forEach( function ( s ) {&lt;br /&gt;
					if ( s.group === &#039;save&#039; ) { total += scaledStat( item, s.key, level ); }&lt;br /&gt;
				} );&lt;br /&gt;
				return total;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		STATS.forEach( function ( s ) {&lt;br /&gt;
			if ( s.score &amp;amp;&amp;amp; s.group === &#039;save&#039; ) {&lt;br /&gt;
				list.push( {&lt;br /&gt;
					key: s.key, label: s.label, group: &#039;save&#039;, saveStat: true,&lt;br /&gt;
					weight: s.weight, scoreDefault: s.scoreDefault,&lt;br /&gt;
					compute: function ( item, level ) { return scaledStat( item, s.key, level ); }&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		list.push( {&lt;br /&gt;
			key: &#039;svVoid&#039;, label: &#039;SV Void&#039;, group: &#039;save&#039;, special: &#039;svVoid&#039;,&lt;br /&gt;
			weight: 0.5, scoreDefault: false,&lt;br /&gt;
			compute: function ( item, level ) { return svVoidBonus( item, level ); }&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		return list;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( CONFIG.UPGRADE_MAX, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || {};&lt;br /&gt;
		var weights = opts.weights || {};&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var totalSvOn = !!comp.totalSV;&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			if ( !comp[ c.key ] ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			if ( c.special === &#039;totalSV&#039; &amp;amp;&amp;amp; !totalSvOn ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			if ( c.saveStat &amp;amp;&amp;amp; totalSvOn ) {&lt;br /&gt;
				return; // individual saves are folded into Total SV&lt;br /&gt;
			}&lt;br /&gt;
			var raw = c.compute( item, level );&lt;br /&gt;
			var wt = weights[ c.key ];&lt;br /&gt;
			wt = ( wt == null || isNaN( wt ) ) ? c.weight : Number( wt );&lt;br /&gt;
			var weighted = raw * wt;&lt;br /&gt;
			if ( weighted ) {&lt;br /&gt;
				breakdown[ c.key ] = weighted;&lt;br /&gt;
				score += weighted;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				breakdown[ &#039;effect_&#039; + t ] = effectBonus[ t ];&lt;br /&gt;
				score += effectBonus[ t ];&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * CONFIG.SCORE_ROUND ) / CONFIG.SCORE_ROUND,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		STATS: STATS,&lt;br /&gt;
		SCORE_COMPONENTS: SCORE_COMPONENTS,&lt;br /&gt;
		EFFECT_TYPES: EFFECT_TYPES,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// ===================== app layer (UI / data / mount) =====================&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	/* MIRROR of ext.classSlotEquip.js `slots` / `classes` — keep in sync until that&lt;br /&gt;
	 * module exposes them. These are the exact slot/class values it accepts. */&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md&lt;br /&gt;
	// (name -&amp;gt; average mob level). STATIC data (eqltools-derived). This doubles as the&lt;br /&gt;
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone&lt;br /&gt;
	// filter options, and the &amp;quot;Zone Avg Lvl&amp;quot; column source.&lt;br /&gt;
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.&lt;br /&gt;
	var ZONE_AVG_LEVEL = {&lt;br /&gt;
		&#039;The Feerrott&#039;: 4,&lt;br /&gt;
		&#039;Toxxulia Forest&#039;: 4,&lt;br /&gt;
		&#039;Misty Thicket&#039;: 5,&lt;br /&gt;
		&#039;Qeynos Hills&#039;: 6,&lt;br /&gt;
		&#039;The Qeynos Aqueduct System&#039;: 6,&lt;br /&gt;
		&#039;Innothule Swamp&#039;: 6,&lt;br /&gt;
		&#039;The Steamfont Mountains&#039;: 6,&lt;br /&gt;
		&#039;The Greater Faydark&#039;: 7,&lt;br /&gt;
		&#039;Butcherblock Mountains&#039;: 7,&lt;br /&gt;
		&#039;Nektulos Forest&#039;: 7,&lt;br /&gt;
		&#039;East Commonlands&#039;: 8,&lt;br /&gt;
		&#039;Everfrost Peaks&#039;: 8,&lt;br /&gt;
		&#039;The Northern Desert of Ro&#039;: 9,&lt;br /&gt;
		&#039;Blackburrow&#039;: 10,&lt;br /&gt;
		&#039;The Southern Desert of Ro&#039;: 10,&lt;br /&gt;
		&#039;West Commonlands&#039;: 10,&lt;br /&gt;
		&#039;The Western Plains of Karana&#039;: 10,&lt;br /&gt;
		&#039;Erud\&#039;s Crossing&#039;: 11,&lt;br /&gt;
		&#039;The Warrens&#039;: 11,&lt;br /&gt;
		&#039;Clan Crushbone&#039;: 12,&lt;br /&gt;
		&#039;Befallen&#039;: 13,&lt;br /&gt;
		&#039;The Lavastorm Mountains&#039;: 13,&lt;br /&gt;
		&#039;Dagnor\&#039;s Cauldron&#039;: 13,&lt;br /&gt;
		&#039;The Gorge of King Xorbb&#039;: 14,&lt;br /&gt;
		&#039;The Oasis of Marr&#039;: 14,&lt;br /&gt;
		&#039;The Eastern Plains of Karana&#039;: 14,&lt;br /&gt;
		&#039;Highpass Hold&#039;: 15,&lt;br /&gt;
		&#039;The Lesser Faydark&#039;: 15,&lt;br /&gt;
		&#039;The Northern Plains of Karana&#039;: 15,&lt;br /&gt;
		&#039;Lake Rathetear&#039;: 17,&lt;br /&gt;
		&#039;The Rathe Mountains&#039;: 17,&lt;br /&gt;
		&#039;Najena&#039;: 19,&lt;br /&gt;
		&#039;The Liberated Citadel of Runnyeye&#039;: 19,&lt;br /&gt;
		&#039;The City of Guk&#039;: 20,&lt;br /&gt;
		&#039;The Southern Plains of Karana&#039;: 21,&lt;br /&gt;
		&#039;The Stonebrunt Mountains&#039;: 21,&lt;br /&gt;
		&#039;The Ocean of Tears&#039;: 22,&lt;br /&gt;
		&#039;The Estate of Unrest&#039;: 23,&lt;br /&gt;
		&#039;High Keep&#039;: 25,&lt;br /&gt;
		&#039;Solusek\&#039;s Eye&#039;: 25,&lt;br /&gt;
		&#039;Temple of Cazic-Thule&#039;: 26,&lt;br /&gt;
		&#039;Permafrost Keep&#039;: 26,&lt;br /&gt;
		&#039;Kithicor Forest&#039;: 30,&lt;br /&gt;
		&#039;The Castle of Mistmoore&#039;: 31,&lt;br /&gt;
		&#039;The Lair of the Splitpaw&#039;: 31,&lt;br /&gt;
		&#039;The Temple of Solusek Ro&#039;: 35,&lt;br /&gt;
		&#039;The Ruins of Old Guk&#039;: 36,&lt;br /&gt;
		&#039;Kedge Keep&#039;: 40,&lt;br /&gt;
		&#039;Nagafen\&#039;s Lair&#039;: 40,&lt;br /&gt;
		&#039;The Ruins of Old Paineel&#039;: 45,&lt;br /&gt;
		&#039;The Plane of Fear&#039;: 50,&lt;br /&gt;
		&#039;The Plane of Hate&#039;: 52,&lt;br /&gt;
		&#039;The Plane of Sky&#039;: 54&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Wiki zone names that don&#039;t normalize to a ZONE_AVG_LEVEL key by prefix-stripping&lt;br /&gt;
	// alone. Extend as item-&amp;gt;zone enrichment surfaces more mismatches.&lt;br /&gt;
	var ZONE_ALIASES = {&lt;br /&gt;
		&#039;Lower Guk&#039;: &#039;The Ruins of Old Guk&#039;,&lt;br /&gt;
		&#039;Upper Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Paineel&#039;: &#039;The Ruins of Old Paineel&#039;,&lt;br /&gt;
		&#039;Runnyeye&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Runnyeye Citadel&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Castle Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Splitpaw&#039;: &#039;The Lair of the Splitpaw&#039;,&lt;br /&gt;
		&#039;Unrest&#039;: &#039;The Estate of Unrest&#039;,&lt;br /&gt;
		&#039;Cazic Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Cazic-Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Oasis of Marr&#039;: &#039;The Oasis of Marr&#039;,&lt;br /&gt;
		&#039;Solusek A&#039;: &#039;Solusek\&#039;s Eye&#039;,&lt;br /&gt;
		&#039;Solusek B&#039;: &#039;Nagafen\&#039;s Lair&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	function zoneKey( name ) {&lt;br /&gt;
		return String( name || &#039;&#039; )&lt;br /&gt;
			.toLowerCase()&lt;br /&gt;
			.replace( /^the\s+/, &#039;&#039; )&lt;br /&gt;
			.replace( /[^a-z0-9]+/g, &#039; &#039; )&lt;br /&gt;
			.trim();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ZONE_INDEX = ( function () {&lt;br /&gt;
		var idx = {};&lt;br /&gt;
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {&lt;br /&gt;
			idx[ zoneKey( name ) ] = name;&lt;br /&gt;
		} );&lt;br /&gt;
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {&lt;br /&gt;
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];&lt;br /&gt;
		} );&lt;br /&gt;
		return idx;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	function canonicalZone( name ) {&lt;br /&gt;
		if ( !name ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return ZONE_INDEX[ zoneKey( name ) ] || null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// enrich:true columns are populated by the enrichment phase (rendered &#039;—&#039; until then).&lt;br /&gt;
	var COLUMNS = ( function () {&lt;br /&gt;
		var lead = [&lt;br /&gt;
			{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
			{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
			{ key: &#039;slot&#039;, label: &#039;Slot&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;type&#039;, label: &#039;Type&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true, columnDefault: false },&lt;br /&gt;
			{ key: &#039;worn&#039;, label: &#039;Worn&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;click&#039;, label: &#039;Click&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;combat&#039;, label: &#039;Combat&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;ratio&#039;, label: &#039;Ratio&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;DMG&#039;, label: &#039;DMG&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;DELAY&#039;, label: &#039;Delay&#039;, columnDefault: true }&lt;br /&gt;
		];&lt;br /&gt;
		var statCols = STATS.filter( function ( s ) {&lt;br /&gt;
			// DMG and DELAY are placed with the weapon columns above.&lt;br /&gt;
			return s.column &amp;amp;&amp;amp; s.key !== &#039;DMG&#039; &amp;amp;&amp;amp; s.key !== &#039;DELAY&#039;;&lt;br /&gt;
		} ).map( function ( s ) {&lt;br /&gt;
			return { key: s.key, label: s.label, columnDefault: s.columnDefault };&lt;br /&gt;
		} );&lt;br /&gt;
		var trail = [&lt;br /&gt;
			{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;source&#039;, label: &#039;Source&#039;, columnDefault: true },&lt;br /&gt;
			{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true, columnDefault: false },&lt;br /&gt;
			{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true, columnDefault: false },&lt;br /&gt;
			{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true, columnDefault: false }&lt;br /&gt;
		];&lt;br /&gt;
		return lead.concat( statCols ).concat( trail );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		var weights = {};&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = !!c.scoreDefault;&lt;br /&gt;
			weights[ c.key ] = c.weight;&lt;br /&gt;
		} );&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = c.always ? true : !!c.columnDefault;&lt;br /&gt;
		} );&lt;br /&gt;
		var effectBonus = {};&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			effectBonus[ t ] = EFFECTS[ t ].bonus;&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: CONFIG.LEVEL_MIN,&lt;br /&gt;
			maxLevel: CONFIG.LEVEL_MAX,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			componentWeights: weights,&lt;br /&gt;
			effectBonus: effectBonus,&lt;br /&gt;
			columns: cols,&lt;br /&gt;
			columnOrder: COLUMNS.map( function ( c ) { return c.key; } )&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column (3rd, index 2).&lt;br /&gt;
			var $src = $cells.eq( 2 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; : &#039;drop&#039;;&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				// enrichment placeholders. available: true|false|null (null = unknown)&lt;br /&gt;
				enrich: { level: null, zone: null, available: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment: zone + availability (item categories) ----&lt;br /&gt;
&lt;br /&gt;
	function titleFromHref( href, name ) {&lt;br /&gt;
		if ( href ) {&lt;br /&gt;
			var t = href.replace( /^https?:\/\/[^/]+/, &#039;&#039; ).replace( /^\/(wiki\/)?/, &#039;&#039; ).split( &#039;#&#039; )[ 0 ];&lt;br /&gt;
			try { t = decodeURIComponent( t ); } catch ( e ) {}&lt;br /&gt;
			if ( t ) { return t.replace( /_/g, &#039; &#039; ); }&lt;br /&gt;
		}&lt;br /&gt;
		return name || &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function resolveFinalTitle( title, alias ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		while ( alias[ title ] &amp;amp;&amp;amp; !seen[ title ] ) {&lt;br /&gt;
			seen[ title ] = 1;&lt;br /&gt;
			title = alias[ title ];&lt;br /&gt;
		}&lt;br /&gt;
		return title;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Resolves to { cats: {title:[categoryName,...]}, alias: {from:to} }.&lt;br /&gt;
	function fetchCategories( titles ) {&lt;br /&gt;
		var result = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
&lt;br /&gt;
			function runBatch( batch, cont ) {&lt;br /&gt;
				var params = {&lt;br /&gt;
					action: &#039;query&#039;,&lt;br /&gt;
					prop: &#039;categories&#039;,&lt;br /&gt;
					titles: batch.join( &#039;|&#039; ),&lt;br /&gt;
					cllimit: &#039;max&#039;,&lt;br /&gt;
					redirects: 1,&lt;br /&gt;
					format: &#039;json&#039;&lt;br /&gt;
				};&lt;br /&gt;
				if ( cont ) {&lt;br /&gt;
					params.clcontinue = cont;&lt;br /&gt;
				}&lt;br /&gt;
				return api.get( params ).then( function ( data ) {&lt;br /&gt;
					var q = data.query || {};&lt;br /&gt;
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );&lt;br /&gt;
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );&lt;br /&gt;
					var pages = q.pages || {};&lt;br /&gt;
					Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
						var pg = pages[ pid ];&lt;br /&gt;
						if ( !pg.title ) { return; }&lt;br /&gt;
						var cats = ( pg.categories || [] ).map( function ( c ) {&lt;br /&gt;
							return c.title.replace( /^Category:/, &#039;&#039; );&lt;br /&gt;
						} );&lt;br /&gt;
						result[ pg.title ] = ( result[ pg.title ] || [] ).concat( cats );&lt;br /&gt;
					} );&lt;br /&gt;
					if ( data.continue &amp;amp;&amp;amp; data.continue.clcontinue ) {&lt;br /&gt;
						return runBatch( batch, data.continue.clcontinue );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );&lt;br /&gt;
		} ).then( function () {&lt;br /&gt;
			return { cats: result, alias: alias };&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Availability (Requirement #1): a drop is in-scope if a zone category is in&lt;br /&gt;
	// ZONE_AVG_LEVEL. Quest items have no zone category — left unresolved here for&lt;br /&gt;
	// enrichLevels to settle from the quest page.&lt;br /&gt;
	function enrichZones( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			it.title = titleFromHref( it.href, it.name );&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; !seen[ it.title ] ) {&lt;br /&gt;
				seen[ it.title ] = 1;&lt;br /&gt;
				titles.push( it.title );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return fetchCategories( titles ).then( function ( res ) {&lt;br /&gt;
			items.forEach( function ( it ) {&lt;br /&gt;
				var cats = res.cats[ resolveFinalTitle( it.title, res.alias ) ] ||&lt;br /&gt;
					res.cats[ it.title ] || [];&lt;br /&gt;
				var zones = [];&lt;br /&gt;
				cats.forEach( function ( cat ) {&lt;br /&gt;
					var cz = canonicalZone( cat );&lt;br /&gt;
					if ( cz &amp;amp;&amp;amp; zones.indexOf( cz ) === -1 ) {&lt;br /&gt;
						zones.push( cz );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				var isQuest = it.source.type === &#039;quest&#039; ||&lt;br /&gt;
					cats.indexOf( &#039;Quest Items&#039; ) !== -1;&lt;br /&gt;
&lt;br /&gt;
				if ( zones.length === 1 ) {&lt;br /&gt;
					it.enrich.zone = zones[ 0 ];&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( zones.length &amp;gt; 1 ) {&lt;br /&gt;
					it.enrich.zone = &#039;multiple&#039;;&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( isQuest ) {&lt;br /&gt;
					it.enrich.zone = &#039;quest&#039;;   // Phase 2 resolves the actual start zone&lt;br /&gt;
					it.enrich.available = null; // unknown; keep for now&lt;br /&gt;
				} else {&lt;br /&gt;
					it.enrich.zone = null;&lt;br /&gt;
					it.enrich.available = false; // no in-scope zone -&amp;gt; out of scope&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			return items;&lt;br /&gt;
		}, function () {&lt;br /&gt;
			return items; // enrichment failed -&amp;gt; keep everything, unfiltered&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment: level (mob median for drops, quest min-level for quests) ----&lt;br /&gt;
&lt;br /&gt;
	function median( nums ) {&lt;br /&gt;
		if ( !nums.length ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		var s = nums.slice().sort( function ( a, b ) { return a - b; } );&lt;br /&gt;
		var m = Math.floor( s.length / 2 );&lt;br /&gt;
		return s.length % 2 ? s[ m ] : Math.round( ( s[ m - 1 ] + s[ m ] ) / 2 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Mob/quest pages transclude the item ({{:Item}}), so transcludedin maps an&lt;br /&gt;
	// item to the pages that drop or reward it.&lt;br /&gt;
	function fetchTranscludedIn( titles ) {&lt;br /&gt;
		var map = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
			function runBatch( batch, cont ) {&lt;br /&gt;
				var params = {&lt;br /&gt;
					action: &#039;query&#039;, prop: &#039;transcludedin&#039;, titles: batch.join( &#039;|&#039; ),&lt;br /&gt;
					tinamespace: 0, tilimit: &#039;max&#039;, redirects: 1, format: &#039;json&#039;&lt;br /&gt;
				};&lt;br /&gt;
				if ( cont ) { params.ticontinue = cont; }&lt;br /&gt;
				return api.get( params ).then( function ( data ) {&lt;br /&gt;
					var q = data.query || {};&lt;br /&gt;
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );&lt;br /&gt;
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );&lt;br /&gt;
					var pages = q.pages || {};&lt;br /&gt;
					Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
						var pg = pages[ pid ];&lt;br /&gt;
						if ( !pg.title ) { return; }&lt;br /&gt;
						var arr = map[ pg.title ] = map[ pg.title ] || [];&lt;br /&gt;
						( pg.transcludedin || [] ).forEach( function ( t ) { arr.push( t.title ); } );&lt;br /&gt;
					} );&lt;br /&gt;
					if ( data.continue &amp;amp;&amp;amp; data.continue.ticontinue ) {&lt;br /&gt;
						return runBatch( batch, data.continue.ticontinue );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );&lt;br /&gt;
		} ).then( function () { return { map: map, alias: alias }; } );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function fetchWikitext( titles ) {&lt;br /&gt;
		var content = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
			return $.when.apply( $, batches.map( function ( batch ) {&lt;br /&gt;
				return api.get( {&lt;br /&gt;
					action: &#039;query&#039;, prop: &#039;revisions&#039;, rvprop: &#039;content&#039;, rvslots: &#039;main&#039;,&lt;br /&gt;
					titles: batch.join( &#039;|&#039; ), format: &#039;json&#039;&lt;br /&gt;
				} ).then( function ( data ) {&lt;br /&gt;
					var pages = ( data.query || {} ).pages || {};&lt;br /&gt;
					Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
						var pg = pages[ pid ];&lt;br /&gt;
						var rev = pg.revisions &amp;amp;&amp;amp; pg.revisions[ 0 ];&lt;br /&gt;
						var main = rev &amp;amp;&amp;amp; rev.slots &amp;amp;&amp;amp; rev.slots.main;&lt;br /&gt;
						if ( pg.title &amp;amp;&amp;amp; main ) { content[ pg.title ] = main[ &#039;*&#039; ] || &#039;&#039;; }&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
			} ) );&lt;br /&gt;
		} ).then( function () { return content; } );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// A page transcluding an item is its mob ({{Namedmobpage}}, has | level =) or&lt;br /&gt;
	// its quest (questTopTable, with Minimum Level / Start Zone).&lt;br /&gt;
	function parseSourcePage( text ) {&lt;br /&gt;
		var out = { kind: null, level: null, zone: null };&lt;br /&gt;
		var lvl = text.match( /\|\s*level\s*=\s*(\d+)/i );&lt;br /&gt;
		if ( /Namedmobpage/i.test( text ) || lvl ) {&lt;br /&gt;
			out.kind = &#039;mob&#039;;&lt;br /&gt;
			if ( lvl ) { out.level = parseInt( lvl[ 1 ], 10 ); }&lt;br /&gt;
			var z = text.match( /\|\s*zone\s*=\s*\[\[\s*([^\]|]+)/i );&lt;br /&gt;
			if ( z ) { out.zone = z[ 1 ].trim(); }&lt;br /&gt;
			return out;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /Minimum Level:|Start Zone:|questTopTable/i.test( text ) ) {&lt;br /&gt;
			out.kind = &#039;quest&#039;;&lt;br /&gt;
			var ml = text.match( /Minimum Level:[\s\S]*?\|\s*(\d+)/i );&lt;br /&gt;
			if ( ml ) { out.level = parseInt( ml[ 1 ], 10 ); }&lt;br /&gt;
			var sz = text.match( /Start Zone:[\s\S]*?\|\s*\[\[\s*([^\]|]+)/i );&lt;br /&gt;
			if ( sz ) { out.zone = sz[ 1 ].trim(); }&lt;br /&gt;
		}&lt;br /&gt;
		return out;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function applyLevelInfo( it, infos ) {&lt;br /&gt;
		var mobLevels = infos&lt;br /&gt;
			.filter( function ( x ) { return x.kind === &#039;mob&#039; &amp;amp;&amp;amp; x.level != null; } )&lt;br /&gt;
			.map( function ( x ) { return x.level; } );&lt;br /&gt;
		if ( mobLevels.length ) {&lt;br /&gt;
			it.enrich.level = median( mobLevels );&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		var quest = infos.filter( function ( x ) { return x.kind === &#039;quest&#039;; } )[ 0 ];&lt;br /&gt;
		if ( quest ) {&lt;br /&gt;
			it.enrich.level = quest.level != null ? quest.level : &#039;quest&#039;;&lt;br /&gt;
			if ( quest.zone ) {&lt;br /&gt;
				var cz = canonicalZone( quest.zone );&lt;br /&gt;
				it.enrich.zone = cz || quest.zone;&lt;br /&gt;
				// hide a quest whose listed start zone is out of scope; keep in-scope&lt;br /&gt;
				// (or unlisted) quests.&lt;br /&gt;
				it.enrich.available = cz ? true : false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function enrichLevels( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; titles.indexOf( it.title ) === -1 ) { titles.push( it.title ); }&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return fetchTranscludedIn( titles ).then( function ( ti ) {&lt;br /&gt;
			var pageSet = {};&lt;br /&gt;
			Object.keys( ti.map ).forEach( function ( t ) {&lt;br /&gt;
				ti.map[ t ].forEach( function ( p ) { pageSet[ p ] = true; } );&lt;br /&gt;
			} );&lt;br /&gt;
			var pages = Object.keys( pageSet );&lt;br /&gt;
			if ( !pages.length ) {&lt;br /&gt;
				return items;&lt;br /&gt;
			}&lt;br /&gt;
			return fetchWikitext( pages ).then( function ( wt ) {&lt;br /&gt;
				var info = {};&lt;br /&gt;
				pages.forEach( function ( p ) { info[ p ] = parseSourcePage( wt[ p ] || &#039;&#039; ); } );&lt;br /&gt;
				items.forEach( function ( it ) {&lt;br /&gt;
					var srcTitle = ti.alias[ it.title ] || it.title;&lt;br /&gt;
					var infos = ( ti.map[ srcTitle ] || ti.map[ it.title ] || [] )&lt;br /&gt;
						.map( function ( p ) { return info[ p ]; } )&lt;br /&gt;
						.filter( Boolean );&lt;br /&gt;
					applyLevelInfo( it, infos );&lt;br /&gt;
				} );&lt;br /&gt;
				return items;&lt;br /&gt;
			} );&lt;br /&gt;
		}, function () { return items; } );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} ).then( enrichZones ).then( enrichLevels );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Availability (Requirement #1): drop into a currently-available zone.&lt;br /&gt;
		// available===false means &amp;quot;has drop zone(s), none currently available&amp;quot; -&amp;gt; hide.&lt;br /&gt;
		// null (unknown, e.g. unresolved quest items) is kept.&lt;br /&gt;
		if ( item.enrich.available === false ) {&lt;br /&gt;
			return false;&lt;br /&gt;
		}&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Quest items may carry level &#039;quest&#039; (a string) rather than a number.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( typeof lvl === &#039;number&#039; ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name&lt;br /&gt;
		// variants (&amp;quot;The …&amp;quot;, Guk aliases) line up with the ZONE_AVG_LEVEL keys.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			var itemZone = canonicalZone( item.enrich.zone );&lt;br /&gt;
			var matched = item.enrich.zone === &#039;multiple&#039; || state.zones.some( function ( sel ) {&lt;br /&gt;
				return canonicalZone( sel ) === itemZone;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !matched ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable/parsed stat key (DMG / DELAY / AC / STR / SV_*).&lt;br /&gt;
		// DELAY has scaling &#039;none&#039;, so scaledStat returns the raw delay.&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var names = item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.map( function ( e ) { return e.name; } );&lt;br /&gt;
		return names.join( &#039;, &#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function columnByKey( key ) {&lt;br /&gt;
		for ( var i = 0; i &amp;lt; COLUMNS.length; i++ ) {&lt;br /&gt;
			if ( COLUMNS[ i ].key === key ) {&lt;br /&gt;
				return COLUMNS[ i ];&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Migration: reconcile a saved columnOrder against the current COLUMNS.&lt;br /&gt;
	function normalizeColumnOrder( state ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		var order = ( state.columnOrder || [] ).filter( function ( k ) {&lt;br /&gt;
			var ok = columnByKey( k ) &amp;amp;&amp;amp; !seen[ k ];&lt;br /&gt;
			seen[ k ] = true;&lt;br /&gt;
			return ok;&lt;br /&gt;
		} );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( !seen[ c.key ] ) {&lt;br /&gt;
				order.push( c.key );&lt;br /&gt;
				seen[ c.key ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		state.columnOrder = order;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = state.columnOrder&lt;br /&gt;
			.map( columnByKey )&lt;br /&gt;
			.filter( function ( c ) {&lt;br /&gt;
				return c &amp;amp;&amp;amp; ( c.always || state.columns[ c.key ] );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		var opts = {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			weights: state.componentWeights,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, opts ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;: case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.text( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						var cz = canonicalZone( it.enrich.zone );&lt;br /&gt;
						$td.text( cz ? ZONE_AVG_LEVEL[ cz ] : &#039;—&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Out-of-scope zones are filtered out. The first List Results is slower — &#039; +&lt;br /&gt;
			&#039;it resolves each item\&#039;s zone and mob/quest level via the API.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
		var $col1 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col2 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col3 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col4 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		$controls.append( $col1 ).append( $col2 ).append( $col3 ).append( $col4 );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 1: Classes / Slots / Weapon Type ----&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$col1.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$col1.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$col1.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 2: Upgrade Level / Level min-max / Zone ----&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$col2.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$col2.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		var $zoneWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$zoneWrap.append( el( &#039;label&#039;, null, &#039;Zone: &#039; ) );&lt;br /&gt;
		$zoneWrap.append( multiSelect( &#039;eqlis-zones&#039;, Object.keys( ZONE_AVG_LEVEL ).sort(), state.zones ) );&lt;br /&gt;
		$col2.append( $zoneWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 3: Item Score components (check to include; box = weight) ----&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score (check = include, box = weight)&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		function scoreRow( c, extraClass ) {&lt;br /&gt;
			var $row = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; + ( extraClass ? &#039; &#039; + extraClass : &#039;&#039; ) } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			var $wt = el( &#039;input&#039;, {&lt;br /&gt;
				type: &#039;number&#039;, step: 0.5, min: 0, &#039;data-weight&#039;: c.key,&lt;br /&gt;
				value: state.componentWeights[ c.key ], &#039;class&#039;: &#039;eqlis-wt&#039;&lt;br /&gt;
			} );&lt;br /&gt;
			$row.append( $cb )&lt;br /&gt;
				.append( document.createTextNode( &#039; &#039; + c.label + &#039; &#039; ) )&lt;br /&gt;
				.append( $wt );&lt;br /&gt;
			return $row;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Saves + SV Void render indented under Total SV.&lt;br /&gt;
		var $totalCb = null;&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var indent = ( c.group === &#039;save&#039; &amp;amp;&amp;amp; c.special !== &#039;totalSV&#039; );&lt;br /&gt;
			var $row = scoreRow( c, indent ? &#039;eqlis-sv&#039; : &#039;&#039; );&lt;br /&gt;
			$score.append( $row );&lt;br /&gt;
			if ( c.special === &#039;totalSV&#039; ) {&lt;br /&gt;
				$totalCb = $row.find( &#039;input[data-comp]&#039; );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// disable individual saves (not SV Void) when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			if ( !$totalCb ) { return; }&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		if ( $totalCb ) {&lt;br /&gt;
			$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		}&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$col3.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 4: Columns — dual-list chooser (Available -&amp;gt; Shown + order) ----&lt;br /&gt;
		// Column changes apply on the next List Results (no live re-render).&lt;br /&gt;
		normalizeColumnOrder( state );&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $avail = el( &#039;select&#039;, { multiple: &#039;multiple&#039;, size: 10, &#039;class&#039;: &#039;eqlis-dual-list&#039; } );&lt;br /&gt;
		var $shown = el( &#039;select&#039;, { multiple: &#039;multiple&#039;, size: 10, &#039;class&#039;: &#039;eqlis-dual-list&#039; } );&lt;br /&gt;
&lt;br /&gt;
		function dualBtn( glyph, title ) {&lt;br /&gt;
			return el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-dual-btn&#039;, title: title }, glyph );&lt;br /&gt;
		}&lt;br /&gt;
		var $addBtn = dualBtn( &#039;▶&#039;, &#039;Show selected&#039; );&lt;br /&gt;
		var $remBtn = dualBtn( &#039;◀&#039;, &#039;Hide selected&#039; );&lt;br /&gt;
		var $upBtn = dualBtn( &#039;▲&#039;, &#039;Move up&#039; );&lt;br /&gt;
		var $downBtn = dualBtn( &#039;▼&#039;, &#039;Move down&#039; );&lt;br /&gt;
&lt;br /&gt;
		var $availSide = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-side&#039; } )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-cap&#039; }, &#039;Available&#039; ) )&lt;br /&gt;
			.append( $avail );&lt;br /&gt;
		var $mid = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-mid&#039; } ).append( $addBtn ).append( $remBtn );&lt;br /&gt;
		var $shownSide = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-side&#039; } )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-cap&#039; }, &#039;Shown (top = left)&#039; ) )&lt;br /&gt;
			.append( $shown )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-ord&#039; } ).append( $upBtn ).append( $downBtn ) );&lt;br /&gt;
&lt;br /&gt;
		$cols.append(&lt;br /&gt;
			el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual&#039; } ).append( $availSide ).append( $mid ).append( $shownSide )&lt;br /&gt;
		);&lt;br /&gt;
		$col4.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		function isShown( c ) {&lt;br /&gt;
			return c.always || state.columns[ c.key ];&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		function renderColumnChooser( keepSel ) {&lt;br /&gt;
			$avail.empty();&lt;br /&gt;
			$shown.empty();&lt;br /&gt;
			state.columnOrder.forEach( function ( key ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( !c ) { return; }&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: key }, document.createTextNode( c.label ) );&lt;br /&gt;
				( isShown( c ) ? $shown : $avail ).append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			if ( keepSel ) {&lt;br /&gt;
				$shown.val( keepSel );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$addBtn.on( &#039;click&#039;, function () {&lt;br /&gt;
			( $avail.val() || [] ).forEach( function ( key ) {&lt;br /&gt;
				state.columns[ key ] = true;&lt;br /&gt;
				var i = state.columnOrder.indexOf( key ); // move to end so it lands last&lt;br /&gt;
				if ( i !== -1 ) {&lt;br /&gt;
					state.columnOrder.splice( i, 1 );&lt;br /&gt;
					state.columnOrder.push( key );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser();&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$remBtn.on( &#039;click&#039;, function () {&lt;br /&gt;
			( $shown.val() || [] ).forEach( function ( key ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( c &amp;amp;&amp;amp; !c.always ) {&lt;br /&gt;
					state.columns[ key ] = false;&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser();&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		function moveShown( dir ) {&lt;br /&gt;
			var sel = $shown.val() || [];&lt;br /&gt;
			if ( sel.length !== 1 ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var key = sel[ 0 ];&lt;br /&gt;
			var shownKeys = state.columnOrder.filter( function ( k ) {&lt;br /&gt;
				var c = columnByKey( k );&lt;br /&gt;
				return c &amp;amp;&amp;amp; isShown( c );&lt;br /&gt;
			} );&lt;br /&gt;
			var si = shownKeys.indexOf( key );&lt;br /&gt;
			if ( si + dir &amp;lt; 0 || si + dir &amp;gt;= shownKeys.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var otherKey = shownKeys[ si + dir ];&lt;br /&gt;
			var a = state.columnOrder.indexOf( key );&lt;br /&gt;
			var b = state.columnOrder.indexOf( otherKey );&lt;br /&gt;
			state.columnOrder[ a ] = otherKey;&lt;br /&gt;
			state.columnOrder[ b ] = key;&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser( [ key ] );&lt;br /&gt;
		}&lt;br /&gt;
		$upBtn.on( &#039;click&#039;, function () { moveShown( -1 ); } );&lt;br /&gt;
		$downBtn.on( &#039;click&#039;, function () { moveShown( 1 ); } );&lt;br /&gt;
&lt;br /&gt;
		renderColumnChooser();&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.zones = readMulti( $( &#039;#eqlis-zones&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$score.find( &#039;input[data-weight]&#039; ).each( function () {&lt;br /&gt;
				var v = parseFloat( $( this ).val() );&lt;br /&gt;
				state.componentWeights[ $( this ).attr( &#039;data-weight&#039; ) ] = isNaN( v ) ? 1 : v;&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			// Column show/hide + order is managed directly by the dual-list chooser.&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// Seed the wiki&#039;s ItemLevelSlider default so popover sliders initialize at&lt;br /&gt;
			// the upgrade level chosen here (its storage key is &#039;ils-default-level-v2&#039;).&lt;br /&gt;
			try {&lt;br /&gt;
				window.localStorage.setItem( &#039;ils-default-level-v2&#039;,&lt;br /&gt;
					JSON.stringify( { full: state.level, fraction: 0 } ) );&lt;br /&gt;
			} catch ( e ) {}&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// NOTE: intentionally no live re-render on control changes. Per spec, the&lt;br /&gt;
		// table only (re)builds when List Results is pressed — moving the upgrade&lt;br /&gt;
		// slider or toggling score/columns changes persisted state but does not&lt;br /&gt;
		// touch the results until the next List Results click.&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=161121</id>
		<title>User:Chonx/eqlItemScore.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=161121"/>
		<updated>2026-07-24T15:10:20Z</updated>

		<summary type="html">&lt;p&gt;Chonx: column fixes&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/**&lt;br /&gt;
 * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.&lt;br /&gt;
 *&lt;br /&gt;
 * Single self-contained module (same shape as ext.itemLevelSlider /&lt;br /&gt;
 * ext.classSlotEquip). Paste into User:&amp;lt;you&amp;gt;/common.js, then create a page&lt;br /&gt;
 * containing:  &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
 *&lt;br /&gt;
 * Data access goes through ACTIVE_DATASOURCE so a future server-side&lt;br /&gt;
 * Special:ItemScoreTable can replace ClientDataSource with a one-line change.&lt;br /&gt;
 *&lt;br /&gt;
 * RELOADING AFTER AN EDIT: MediaWiki serves the user JS bundle from a server-side&lt;br /&gt;
 * ResourceLoader cache keyed by a version hash. After you save common.js the hash&lt;br /&gt;
 * can take a few minutes to recompute, so a browser hard-refresh (Ctrl+Shift+R)&lt;br /&gt;
 * alone may still run the OLD code. Reliable ways to force the new version:&lt;br /&gt;
 *   1. Append &amp;amp;debug=true to the page URL (bypasses the RL cache/minifier), or&lt;br /&gt;
 *   2. Visit  index.php?title=User:Chonx/common.js&amp;amp;action=raw  once, then reload, or&lt;br /&gt;
 *   3. Run this in the console to eval the freshest copy immediately:&lt;br /&gt;
 *      fetch(&#039;/index.php?title=User:Chonx/common.js&amp;amp;action=raw&amp;amp;_=&#039;+Date.now())&lt;br /&gt;
 *        .then(r=&amp;gt;r.text()).then(t=&amp;gt;eval(t));&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ---- styles (injected; common.js has no separate CSS channel) ----&lt;br /&gt;
	( function () {&lt;br /&gt;
		var s = document.createElement( &#039;style&#039; );&lt;br /&gt;
		s.setAttribute( &#039;data-eqlis&#039;, &#039;1&#039; );&lt;br /&gt;
		s.textContent = &amp;quot;/* EQL Item Score Table \u2014 styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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 &amp;gt; 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&amp;quot;;&lt;br /&gt;
		( document.head || document.documentElement ).appendChild( s );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = 10;&lt;br /&gt;
&lt;br /&gt;
	// Stats that scale via the &amp;quot;primary spreadsheet&amp;quot; rule (incl. saves).&lt;br /&gt;
	var PRIMARY_STATS = [&lt;br /&gt;
		&#039;AC&#039;, &#039;HP&#039;, &#039;MP&#039;, &#039;END&#039;,&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;WIS&#039;, &#039;INT&#039;, &#039;CHA&#039;,&lt;br /&gt;
		&#039;SV_MAGIC&#039;, &#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Stats that, when &amp;gt;=2 are present, grant SV VOID: +level.&lt;br /&gt;
	var SV_VOID_QUALIFIERS = [&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;INT&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;CHA&#039;, &#039;WIS&#039;,&lt;br /&gt;
		&#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_MAGIC&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// All stat labels we recognize in a stat block, longest-first for regex.&lt;br /&gt;
	var STAT_ALIASES = {&lt;br /&gt;
		&#039;DAMAGE&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;DMG&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;MANA&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;MP&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;ENDUR&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;END&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;SV MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;SV FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;SV COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;SV POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;SV DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;HP REGEN&#039;: &#039;HP_REGEN&#039;,&lt;br /&gt;
		&#039;WEIGHT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;WT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;AC&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;ARMOR CLASS&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;HP&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;HIT POINTS&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;STR&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STRENGTH&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;STAMINA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;AGI&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;AGILITY&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;DEX&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;DEXTERITY&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;WIS&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;WISDOM&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;INT&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;INTELLIGENCE&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;CHA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;CHARISMA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;HASTE&#039;: &#039;HASTE&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas (ported from ext.itemLevelSlider.js) --------------&lt;br /&gt;
	// state here is just an integer effective level (0..10).&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Parse a stat block (plain text; callers should strip HTML first).&lt;br /&gt;
	 * Returns a normalized item fields object. focus_effect is NOT present in&lt;br /&gt;
	 * stat blocks — it is set later by the data-source enrichment step.&lt;br /&gt;
	 */&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			delay: 0,&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
			item.delay = parseFloat( skillMatch[ 2 ] );&lt;br /&gt;
		} else {&lt;br /&gt;
			var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
			if ( delayOnly ) {&lt;br /&gt;
				item.delay = parseFloat( delayOnly[ 1 ] );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Stats: for each known label, capture &amp;quot;LABEL: [+-]?num&amp;quot;&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			// DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Default score component weights (all 1). A component contributes its&lt;br /&gt;
	// scaled stat value when enabled.&lt;br /&gt;
	function defaultComponents() {&lt;br /&gt;
		return {&lt;br /&gt;
			ratio: false, dmg: false, delay: false,&lt;br /&gt;
			ac: false, str: false, sta: false, agi: false, dex: false,&lt;br /&gt;
			int: false, wis: false, cha: false,&lt;br /&gt;
			totalSV: false,&lt;br /&gt;
			svMagic: false, svFire: false, svCold: false,&lt;br /&gt;
			svPoison: false, svDisease: false, svVoid: false&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Compute the scaled value of one stat key at a given level.&lt;br /&gt;
	 */&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;DMG&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * scoreItem(item, opts)&lt;br /&gt;
	 *   opts.level        integer 0..10&lt;br /&gt;
	 *   opts.components    map of enabled score components (see defaultComponents)&lt;br /&gt;
	 *   opts.effectBonus  { focus, worn, click, combat } flat points per present effect&lt;br /&gt;
	 */&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || defaultComponents();&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		var weights = opts.weights || {};&lt;br /&gt;
		function w( key ) {&lt;br /&gt;
			var x = weights[ key ];&lt;br /&gt;
			return ( x == null || isNaN( x ) ) ? 1 : Number( x );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// value is the raw scaled stat; it is multiplied by the component weight.&lt;br /&gt;
		function add( name, value ) {&lt;br /&gt;
			var weighted = value * w( name );&lt;br /&gt;
			if ( weighted ) {&lt;br /&gt;
				breakdown[ name ] = weighted;&lt;br /&gt;
				score += weighted;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var svKeys = {&lt;br /&gt;
			svMagic: &#039;SV_MAGIC&#039;, svFire: &#039;SV_FIRE&#039;, svCold: &#039;SV_COLD&#039;,&lt;br /&gt;
			svPoison: &#039;SV_POISON&#039;, svDisease: &#039;SV_DISEASE&#039;&lt;br /&gt;
		};&lt;br /&gt;
		var simpleKeys = {&lt;br /&gt;
			ac: &#039;AC&#039;, str: &#039;STR&#039;, sta: &#039;STA&#039;, agi: &#039;AGI&#039;, dex: &#039;DEX&#039;,&lt;br /&gt;
			int: &#039;INT&#039;, wis: &#039;WIS&#039;, cha: &#039;CHA&#039;&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var scaledDmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.ratio &amp;amp;&amp;amp; item.delay &amp;gt; 0 &amp;amp;&amp;amp; scaledDmg &amp;gt; 0 ) {&lt;br /&gt;
			// Ratio score = (damage / delay) * 10  (damage/delay per user; *10 to scale up)&lt;br /&gt;
			add( &#039;ratio&#039;, ( scaledDmg / item.delay ) * 10 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.dmg ) {&lt;br /&gt;
			add( &#039;dmg&#039;, scaledDmg );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.delay &amp;amp;&amp;amp; item.delay &amp;gt; 0 ) {&lt;br /&gt;
			// Lower delay is better; contribute inverse so faster weapons score higher.&lt;br /&gt;
			add( &#039;delay&#039;, 100 / item.delay );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		Object.keys( simpleKeys ).forEach( function ( ck ) {&lt;br /&gt;
			if ( comp[ ck ] ) {&lt;br /&gt;
				add( ck, scaledStat( item, simpleKeys[ ck ], level ) );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.totalSV ) {&lt;br /&gt;
			var svTotal = 0;&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				svTotal += scaledStat( item, svKeys[ sk ], level );&lt;br /&gt;
			} );&lt;br /&gt;
			add( &#039;totalSV&#039;, svTotal );&lt;br /&gt;
		} else {&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				if ( comp[ sk ] ) {&lt;br /&gt;
					add( sk, scaledStat( item, svKeys[ sk ], level ) );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		if ( comp.svVoid ) {&lt;br /&gt;
			add( &#039;svVoid&#039;, svVoidBonus( item, level ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effect flat bonuses (per present effect type).&lt;br /&gt;
		[ &#039;worn&#039;, &#039;combat&#039;, &#039;click&#039; ].forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				add( &#039;effect_&#039; + t, effectBonus[ t ] );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( item.effectTypes.focus &amp;amp;&amp;amp; effectBonus.focus ) {&lt;br /&gt;
			add( &#039;effect_focus&#039;, effectBonus.focus );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * 1000 ) / 1000,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		if ( !item.delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / item.delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		defaultComponents: defaultComponents,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// ===================== app layer (UI / data / mount) =====================&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md&lt;br /&gt;
	// (name -&amp;gt; average mob level). STATIC data (eqltools-derived). This doubles as the&lt;br /&gt;
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone&lt;br /&gt;
	// filter options, and the &amp;quot;Zone Avg Lvl&amp;quot; column source.&lt;br /&gt;
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.&lt;br /&gt;
	var ZONE_AVG_LEVEL = {&lt;br /&gt;
		&#039;The Feerrott&#039;: 4,&lt;br /&gt;
		&#039;Toxxulia Forest&#039;: 4,&lt;br /&gt;
		&#039;Misty Thicket&#039;: 5,&lt;br /&gt;
		&#039;Qeynos Hills&#039;: 6,&lt;br /&gt;
		&#039;The Qeynos Aqueduct System&#039;: 6,&lt;br /&gt;
		&#039;Innothule Swamp&#039;: 6,&lt;br /&gt;
		&#039;The Steamfont Mountains&#039;: 6,&lt;br /&gt;
		&#039;The Greater Faydark&#039;: 7,&lt;br /&gt;
		&#039;Butcherblock Mountains&#039;: 7,&lt;br /&gt;
		&#039;Nektulos Forest&#039;: 7,&lt;br /&gt;
		&#039;East Commonlands&#039;: 8,&lt;br /&gt;
		&#039;Everfrost Peaks&#039;: 8,&lt;br /&gt;
		&#039;The Northern Desert of Ro&#039;: 9,&lt;br /&gt;
		&#039;Blackburrow&#039;: 10,&lt;br /&gt;
		&#039;The Southern Desert of Ro&#039;: 10,&lt;br /&gt;
		&#039;West Commonlands&#039;: 10,&lt;br /&gt;
		&#039;The Western Plains of Karana&#039;: 10,&lt;br /&gt;
		&#039;Erud\&#039;s Crossing&#039;: 11,&lt;br /&gt;
		&#039;The Warrens&#039;: 11,&lt;br /&gt;
		&#039;Clan Crushbone&#039;: 12,&lt;br /&gt;
		&#039;Befallen&#039;: 13,&lt;br /&gt;
		&#039;The Lavastorm Mountains&#039;: 13,&lt;br /&gt;
		&#039;Dagnor\&#039;s Cauldron&#039;: 13,&lt;br /&gt;
		&#039;The Gorge of King Xorbb&#039;: 14,&lt;br /&gt;
		&#039;The Oasis of Marr&#039;: 14,&lt;br /&gt;
		&#039;The Eastern Plains of Karana&#039;: 14,&lt;br /&gt;
		&#039;Highpass Hold&#039;: 15,&lt;br /&gt;
		&#039;The Lesser Faydark&#039;: 15,&lt;br /&gt;
		&#039;The Northern Plains of Karana&#039;: 15,&lt;br /&gt;
		&#039;Lake Rathetear&#039;: 17,&lt;br /&gt;
		&#039;The Rathe Mountains&#039;: 17,&lt;br /&gt;
		&#039;Najena&#039;: 19,&lt;br /&gt;
		&#039;The Liberated Citadel of Runnyeye&#039;: 19,&lt;br /&gt;
		&#039;The City of Guk&#039;: 20,&lt;br /&gt;
		&#039;The Southern Plains of Karana&#039;: 21,&lt;br /&gt;
		&#039;The Stonebrunt Mountains&#039;: 21,&lt;br /&gt;
		&#039;The Ocean of Tears&#039;: 22,&lt;br /&gt;
		&#039;The Estate of Unrest&#039;: 23,&lt;br /&gt;
		&#039;High Keep&#039;: 25,&lt;br /&gt;
		&#039;Solusek\&#039;s Eye&#039;: 25,&lt;br /&gt;
		&#039;Temple of Cazic-Thule&#039;: 26,&lt;br /&gt;
		&#039;Permafrost Keep&#039;: 26,&lt;br /&gt;
		&#039;Kithicor Forest&#039;: 30,&lt;br /&gt;
		&#039;The Castle of Mistmoore&#039;: 31,&lt;br /&gt;
		&#039;The Lair of the Splitpaw&#039;: 31,&lt;br /&gt;
		&#039;The Temple of Solusek Ro&#039;: 35,&lt;br /&gt;
		&#039;The Ruins of Old Guk&#039;: 36,&lt;br /&gt;
		&#039;Kedge Keep&#039;: 40,&lt;br /&gt;
		&#039;Nagafen\&#039;s Lair&#039;: 40,&lt;br /&gt;
		&#039;The Ruins of Old Paineel&#039;: 45,&lt;br /&gt;
		&#039;The Plane of Fear&#039;: 50,&lt;br /&gt;
		&#039;The Plane of Hate&#039;: 52,&lt;br /&gt;
		&#039;The Plane of Sky&#039;: 54&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Wiki zone names that don&#039;t normalize to a ZONE_AVG_LEVEL key by prefix-stripping&lt;br /&gt;
	// alone. Extend as item-&amp;gt;zone enrichment surfaces more mismatches.&lt;br /&gt;
	var ZONE_ALIASES = {&lt;br /&gt;
		&#039;Lower Guk&#039;: &#039;The Ruins of Old Guk&#039;,&lt;br /&gt;
		&#039;Upper Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Paineel&#039;: &#039;The Ruins of Old Paineel&#039;,&lt;br /&gt;
		&#039;Runnyeye&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Runnyeye Citadel&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Castle Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Splitpaw&#039;: &#039;The Lair of the Splitpaw&#039;,&lt;br /&gt;
		&#039;Unrest&#039;: &#039;The Estate of Unrest&#039;,&lt;br /&gt;
		&#039;Cazic Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Cazic-Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Oasis of Marr&#039;: &#039;The Oasis of Marr&#039;,&lt;br /&gt;
		&#039;Solusek A&#039;: &#039;Solusek\&#039;s Eye&#039;,&lt;br /&gt;
		&#039;Solusek B&#039;: &#039;Nagafen\&#039;s Lair&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	function zoneKey( name ) {&lt;br /&gt;
		return String( name || &#039;&#039; )&lt;br /&gt;
			.toLowerCase()&lt;br /&gt;
			.replace( /^the\s+/, &#039;&#039; )&lt;br /&gt;
			.replace( /[^a-z0-9]+/g, &#039; &#039; )&lt;br /&gt;
			.trim();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ZONE_INDEX = ( function () {&lt;br /&gt;
		var idx = {};&lt;br /&gt;
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {&lt;br /&gt;
			idx[ zoneKey( name ) ] = name;&lt;br /&gt;
		} );&lt;br /&gt;
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {&lt;br /&gt;
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];&lt;br /&gt;
		} );&lt;br /&gt;
		return idx;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	// Canonical ZONE_AVG_LEVEL name for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function canonicalZone( name ) {&lt;br /&gt;
		if ( !name ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return ZONE_INDEX[ zoneKey( name ) ] || null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Average mob level for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function zoneAverageLevel( name ) {&lt;br /&gt;
		var canonical = canonicalZone( name );&lt;br /&gt;
		return canonical ? ZONE_AVG_LEVEL[ canonical ] : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function zoneDisplayNames() {&lt;br /&gt;
		return Object.keys( ZONE_AVG_LEVEL ).sort();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Score component checkboxes. `sv` marks the mutually-exclusive save group.&lt;br /&gt;
	var SCORE_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Weapon Ratio (dmg/delay)&#039; },&lt;br /&gt;
		{ key: &#039;dmg&#039;, label: &#039;Weapon Damage&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Weapon Delay&#039; },&lt;br /&gt;
		{ key: &#039;ac&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;str&#039;, label: &#039;Strength&#039; },&lt;br /&gt;
		{ key: &#039;dex&#039;, label: &#039;Dexterity&#039; },&lt;br /&gt;
		{ key: &#039;agi&#039;, label: &#039;Agility&#039; },&lt;br /&gt;
		{ key: &#039;sta&#039;, label: &#039;Stamina&#039; },&lt;br /&gt;
		{ key: &#039;int&#039;, label: &#039;Intelligence&#039; },&lt;br /&gt;
		{ key: &#039;wis&#039;, label: &#039;Wisdom&#039; },&lt;br /&gt;
		{ key: &#039;cha&#039;, label: &#039;Charisma&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var SV_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;svMagic&#039;, label: &#039;SV Magic&#039; },&lt;br /&gt;
		{ key: &#039;svFire&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;svCold&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;svPoison&#039;, label: &#039;SV Poison&#039; },&lt;br /&gt;
		{ key: &#039;svDisease&#039;, label: &#039;SV Disease&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var EFFECT_TYPES = [ &#039;focus&#039;, &#039;worn&#039;, &#039;click&#039;, &#039;combat&#039; ];&lt;br /&gt;
&lt;br /&gt;
	// Result columns. `enrich:true` = populated by the (future) enrichment phase.&lt;br /&gt;
	var COLUMNS = [&lt;br /&gt;
		{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
		{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
		{ key: &#039;slot&#039;, label: &#039;Slot&#039; },&lt;br /&gt;
		{ key: &#039;type&#039;, label: &#039;Type&#039; },&lt;br /&gt;
		{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;worn&#039;, label: &#039;Worn&#039; },&lt;br /&gt;
		{ key: &#039;click&#039;, label: &#039;Click&#039; },&lt;br /&gt;
		{ key: &#039;combat&#039;, label: &#039;Combat&#039; },&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Ratio&#039; },&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Delay&#039; },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039; },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039; },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039; },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039; },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039; },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039; },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039; },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039; },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039; },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; },&lt;br /&gt;
		{ key: &#039;source&#039;, label: &#039;Source&#039; },&lt;br /&gt;
		{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		var weights = {};&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
			weights[ c.key ] = 1;      // stat / weapon components default weight 1&lt;br /&gt;
		} );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
			weights[ c.key ] = 0.5;    // resist (SV) components default weight 0.5&lt;br /&gt;
		} );&lt;br /&gt;
		comps.totalSV = false;&lt;br /&gt;
		weights.totalSV = 0.5;         // Total SV behaves like a resist&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: 1,&lt;br /&gt;
			maxLevel: 100,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			componentWeights: weights,&lt;br /&gt;
			effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },&lt;br /&gt;
			columns: cols,&lt;br /&gt;
			columnOrder: COLUMNS.map( function ( c ) { return c.key; } )&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Parse one ClassSlotEquip HTML fragment into item records.&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column (3rd, index 2).&lt;br /&gt;
			var $src = $cells.eq( 2 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; : &#039;drop&#039;;&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				// enrichment placeholders. available: true|false|null (null = unknown)&lt;br /&gt;
				enrich: { focus: null, level: null, zone: null, available: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment: item -&amp;gt; zone + availability (Phase 1, via categories) ----&lt;br /&gt;
&lt;br /&gt;
	function titleFromHref( href, name ) {&lt;br /&gt;
		if ( href ) {&lt;br /&gt;
			var t = href.replace( /^https?:\/\/[^/]+/, &#039;&#039; ).replace( /^\/(wiki\/)?/, &#039;&#039; ).split( &#039;#&#039; )[ 0 ];&lt;br /&gt;
			try { t = decodeURIComponent( t ); } catch ( e ) {}&lt;br /&gt;
			if ( t ) { return t.replace( /_/g, &#039; &#039; ); }&lt;br /&gt;
		}&lt;br /&gt;
		return name || &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function resolveFinalTitle( title, alias ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		while ( alias[ title ] &amp;amp;&amp;amp; !seen[ title ] ) {&lt;br /&gt;
			seen[ title ] = 1;&lt;br /&gt;
			title = alias[ title ];&lt;br /&gt;
		}&lt;br /&gt;
		return title;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Batched prop=categories with redirect/normalize + continuation handling.&lt;br /&gt;
	// Resolves to { cats: {finalTitle:[categoryName,...]}, alias: {from:to} }.&lt;br /&gt;
	function fetchCategories( titles ) {&lt;br /&gt;
		var result = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
&lt;br /&gt;
			function runBatch( batch, cont ) {&lt;br /&gt;
				var params = {&lt;br /&gt;
					action: &#039;query&#039;,&lt;br /&gt;
					prop: &#039;categories&#039;,&lt;br /&gt;
					titles: batch.join( &#039;|&#039; ),&lt;br /&gt;
					cllimit: &#039;max&#039;,&lt;br /&gt;
					redirects: 1,&lt;br /&gt;
					format: &#039;json&#039;&lt;br /&gt;
				};&lt;br /&gt;
				if ( cont ) {&lt;br /&gt;
					params.clcontinue = cont;&lt;br /&gt;
				}&lt;br /&gt;
				return api.get( params ).then( function ( data ) {&lt;br /&gt;
					var q = data.query || {};&lt;br /&gt;
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );&lt;br /&gt;
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );&lt;br /&gt;
					var pages = q.pages || {};&lt;br /&gt;
					Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
						var pg = pages[ pid ];&lt;br /&gt;
						if ( !pg.title ) { return; }&lt;br /&gt;
						var cats = ( pg.categories || [] ).map( function ( c ) {&lt;br /&gt;
							return c.title.replace( /^Category:/, &#039;&#039; );&lt;br /&gt;
						} );&lt;br /&gt;
						result[ pg.title ] = ( result[ pg.title ] || [] ).concat( cats );&lt;br /&gt;
					} );&lt;br /&gt;
					if ( data.continue &amp;amp;&amp;amp; data.continue.clcontinue ) {&lt;br /&gt;
						return runBatch( batch, data.continue.clcontinue );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );&lt;br /&gt;
		} ).then( function () {&lt;br /&gt;
			return { cats: result, alias: alias };&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Set item.enrich.zone / .available from each item&#039;s categories.&lt;br /&gt;
	// Availability = &amp;quot;drops in a zone that is in ZONE_AVG_LEVEL&amp;quot; (Requirement #1).&lt;br /&gt;
	// Quest items carry no zone category (zone is on the quest page) — kept as&lt;br /&gt;
	// available:null pending Phase 2 quest resolution rather than excluded.&lt;br /&gt;
	function enrichZones( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			it.title = titleFromHref( it.href, it.name );&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; !seen[ it.title ] ) {&lt;br /&gt;
				seen[ it.title ] = 1;&lt;br /&gt;
				titles.push( it.title );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return fetchCategories( titles ).then( function ( res ) {&lt;br /&gt;
			items.forEach( function ( it ) {&lt;br /&gt;
				var cats = res.cats[ resolveFinalTitle( it.title, res.alias ) ] ||&lt;br /&gt;
					res.cats[ it.title ] || [];&lt;br /&gt;
				var zones = [];&lt;br /&gt;
				cats.forEach( function ( cat ) {&lt;br /&gt;
					var cz = canonicalZone( cat );&lt;br /&gt;
					if ( cz &amp;amp;&amp;amp; zones.indexOf( cz ) === -1 ) {&lt;br /&gt;
						zones.push( cz );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				var isQuest = it.source.type === &#039;quest&#039; ||&lt;br /&gt;
					cats.indexOf( &#039;Quest Items&#039; ) !== -1;&lt;br /&gt;
&lt;br /&gt;
				if ( zones.length === 1 ) {&lt;br /&gt;
					it.enrich.zone = zones[ 0 ];&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( zones.length &amp;gt; 1 ) {&lt;br /&gt;
					it.enrich.zone = &#039;multiple&#039;;&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( isQuest ) {&lt;br /&gt;
					it.enrich.zone = &#039;quest&#039;;   // Phase 2 resolves the actual start zone&lt;br /&gt;
					it.enrich.available = null; // unknown; keep for now&lt;br /&gt;
				} else {&lt;br /&gt;
					it.enrich.zone = null;&lt;br /&gt;
					it.enrich.available = false; // no in-scope zone -&amp;gt; out of scope&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			return items;&lt;br /&gt;
		}, function () {&lt;br /&gt;
			return items; // enrichment failed -&amp;gt; keep everything, unfiltered&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		/**&lt;br /&gt;
		 * fetchItems(query) -&amp;gt; Promise&amp;lt;Array&amp;lt;itemRecord&amp;gt;&amp;gt;&lt;br /&gt;
		 * query: { classes:[a,b,c], slots:[...] }&lt;br /&gt;
		 * Fans out one request per slot, merges, de-dupes by name+slot,&lt;br /&gt;
		 * then enriches each item with zone + availability.&lt;br /&gt;
		 */&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} ).then( enrichZones );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Availability (Requirement #1): drop into a currently-available zone.&lt;br /&gt;
		// available===false means &amp;quot;has drop zone(s), none currently available&amp;quot; -&amp;gt; hide.&lt;br /&gt;
		// null (unknown, e.g. unresolved quest items) is kept.&lt;br /&gt;
		if ( item.enrich.available === false ) {&lt;br /&gt;
			return false;&lt;br /&gt;
		}&lt;br /&gt;
		// Weapon type&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Level range (enrichment-dependent). In v1, item.enrich.level is null;&lt;br /&gt;
		// only filter when we actually have a level so we don&#039;t hide everything.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( lvl != null ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name&lt;br /&gt;
		// variants (&amp;quot;The …&amp;quot;, Guk aliases) line up with the ZONE_AVG_LEVEL keys.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			var itemZone = canonicalZone( item.enrich.zone );&lt;br /&gt;
			var matched = item.enrich.zone === &#039;multiple&#039; || state.zones.some( function ( sel ) {&lt;br /&gt;
				return canonicalZone( sel ) === itemZone;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !matched ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scoreOpts( state ) {&lt;br /&gt;
		return {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			weights: state.componentWeights,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;delay&#039; ) {&lt;br /&gt;
			return item.fields.delay || &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable stat key (DMG / AC / STR / SV_*)&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var names = item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.map( function ( e ) { return e.name; } );&lt;br /&gt;
		return names.join( &#039;, &#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function columnByKey( key ) {&lt;br /&gt;
		for ( var i = 0; i &amp;lt; COLUMNS.length; i++ ) {&lt;br /&gt;
			if ( COLUMNS[ i ].key === key ) {&lt;br /&gt;
				return COLUMNS[ i ];&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Reconcile a saved columnOrder with the current COLUMNS set: drop unknown/dupe&lt;br /&gt;
	// keys, append any columns missing from the saved order.&lt;br /&gt;
	function normalizeColumnOrder( state ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		var order = ( state.columnOrder || [] ).filter( function ( k ) {&lt;br /&gt;
			var ok = columnByKey( k ) &amp;amp;&amp;amp; !seen[ k ];&lt;br /&gt;
			seen[ k ] = true;&lt;br /&gt;
			return ok;&lt;br /&gt;
		} );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( !seen[ c.key ] ) {&lt;br /&gt;
				order.push( c.key );&lt;br /&gt;
				seen[ c.key ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		state.columnOrder = order;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = state.columnOrder&lt;br /&gt;
			.map( columnByKey )&lt;br /&gt;
			.filter( function ( c ) {&lt;br /&gt;
				return c &amp;amp;&amp;amp; ( c.always || state.columns[ c.key ] );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		// score + filter, then sort by score desc for a useful default&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;:&lt;br /&gt;
						$td.text( it.enrich.focus || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.text( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						var avg = zoneAverageLevel( it.enrich.zone );&lt;br /&gt;
						$td.text( avg == null ? &#039;—&#039; : avg );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Out-of-era drops are filtered out; quest items and the Level / Focus &#039; +&lt;br /&gt;
			&#039;columns are still being wired up.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
		var $col1 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col2 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col3 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col4 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		$controls.append( $col1 ).append( $col2 ).append( $col3 ).append( $col4 );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 1: Classes / Slots / Weapon Type ----&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$col1.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$col1.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$col1.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 2: Upgrade Level / Level min-max / Zone ----&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$col2.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$col2.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		var $zoneWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$zoneWrap.append( el( &#039;label&#039;, null, &#039;Zone: &#039; ) );&lt;br /&gt;
		$zoneWrap.append( multiSelect( &#039;eqlis-zones&#039;, zoneDisplayNames(), state.zones ) );&lt;br /&gt;
		$col2.append( $zoneWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 3: Item Score components (check to include; box = weight) ----&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score (check = include, box = weight)&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		function scoreRow( c, extraClass ) {&lt;br /&gt;
			var $row = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; + ( extraClass ? &#039; &#039; + extraClass : &#039;&#039; ) } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			var $wt = el( &#039;input&#039;, {&lt;br /&gt;
				type: &#039;number&#039;, step: 0.5, min: 0, &#039;data-weight&#039;: c.key,&lt;br /&gt;
				value: state.componentWeights[ c.key ], &#039;class&#039;: &#039;eqlis-wt&#039;&lt;br /&gt;
			} );&lt;br /&gt;
			$row.append( $cb )&lt;br /&gt;
				.append( document.createTextNode( &#039; &#039; + c.label + &#039; &#039; ) )&lt;br /&gt;
				.append( $wt );&lt;br /&gt;
			return $row;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) { $score.append( scoreRow( c ) ); } );&lt;br /&gt;
&lt;br /&gt;
		var $totalRow = scoreRow( { key: &#039;totalSV&#039;, label: &#039;Total SV (sum of saves)&#039; } );&lt;br /&gt;
		$score.append( $totalRow );&lt;br /&gt;
		var $totalCb = $totalRow.find( &#039;input[data-comp]&#039; );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) { $score.append( scoreRow( c, &#039;eqlis-sv&#039; ) ); } );&lt;br /&gt;
&lt;br /&gt;
		// disable individual saves when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$col3.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 4: Columns — dual-list chooser (Available -&amp;gt; Shown + order) ----&lt;br /&gt;
		// Column changes apply on the next List Results (no live re-render).&lt;br /&gt;
		normalizeColumnOrder( state );&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $avail = el( &#039;select&#039;, { multiple: &#039;multiple&#039;, size: 10, &#039;class&#039;: &#039;eqlis-dual-list&#039; } );&lt;br /&gt;
		var $shown = el( &#039;select&#039;, { multiple: &#039;multiple&#039;, size: 10, &#039;class&#039;: &#039;eqlis-dual-list&#039; } );&lt;br /&gt;
&lt;br /&gt;
		function dualBtn( glyph, title ) {&lt;br /&gt;
			return el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-dual-btn&#039;, title: title }, glyph );&lt;br /&gt;
		}&lt;br /&gt;
		var $addBtn = dualBtn( &#039;▶&#039;, &#039;Show selected&#039; );&lt;br /&gt;
		var $remBtn = dualBtn( &#039;◀&#039;, &#039;Hide selected&#039; );&lt;br /&gt;
		var $upBtn = dualBtn( &#039;▲&#039;, &#039;Move up&#039; );&lt;br /&gt;
		var $downBtn = dualBtn( &#039;▼&#039;, &#039;Move down&#039; );&lt;br /&gt;
&lt;br /&gt;
		var $availSide = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-side&#039; } )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-cap&#039; }, &#039;Available&#039; ) )&lt;br /&gt;
			.append( $avail );&lt;br /&gt;
		var $mid = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-mid&#039; } ).append( $addBtn ).append( $remBtn );&lt;br /&gt;
		var $shownSide = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-side&#039; } )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-cap&#039; }, &#039;Shown (top = left)&#039; ) )&lt;br /&gt;
			.append( $shown )&lt;br /&gt;
			.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual-ord&#039; } ).append( $upBtn ).append( $downBtn ) );&lt;br /&gt;
&lt;br /&gt;
		$cols.append(&lt;br /&gt;
			el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-dual&#039; } ).append( $availSide ).append( $mid ).append( $shownSide )&lt;br /&gt;
		);&lt;br /&gt;
		$col4.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		function isShown( c ) {&lt;br /&gt;
			return c.always || state.columns[ c.key ];&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		function renderColumnChooser( keepSel ) {&lt;br /&gt;
			$avail.empty();&lt;br /&gt;
			$shown.empty();&lt;br /&gt;
			state.columnOrder.forEach( function ( key ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( !c ) { return; }&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: key }, document.createTextNode( c.label ) );&lt;br /&gt;
				( isShown( c ) ? $shown : $avail ).append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			if ( keepSel ) {&lt;br /&gt;
				$shown.val( keepSel );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$addBtn.on( &#039;click&#039;, function () {&lt;br /&gt;
			( $avail.val() || [] ).forEach( function ( key ) {&lt;br /&gt;
				state.columns[ key ] = true;&lt;br /&gt;
				var i = state.columnOrder.indexOf( key ); // move to end so it lands last&lt;br /&gt;
				if ( i !== -1 ) {&lt;br /&gt;
					state.columnOrder.splice( i, 1 );&lt;br /&gt;
					state.columnOrder.push( key );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser();&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$remBtn.on( &#039;click&#039;, function () {&lt;br /&gt;
			( $shown.val() || [] ).forEach( function ( key ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( c &amp;amp;&amp;amp; !c.always ) {&lt;br /&gt;
					state.columns[ key ] = false;&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser();&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		function moveShown( dir ) {&lt;br /&gt;
			var sel = $shown.val() || [];&lt;br /&gt;
			if ( sel.length !== 1 ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var key = sel[ 0 ];&lt;br /&gt;
			var shownKeys = state.columnOrder.filter( function ( k ) {&lt;br /&gt;
				var c = columnByKey( k );&lt;br /&gt;
				return c &amp;amp;&amp;amp; isShown( c );&lt;br /&gt;
			} );&lt;br /&gt;
			var si = shownKeys.indexOf( key );&lt;br /&gt;
			if ( si + dir &amp;lt; 0 || si + dir &amp;gt;= shownKeys.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var otherKey = shownKeys[ si + dir ];&lt;br /&gt;
			var a = state.columnOrder.indexOf( key );&lt;br /&gt;
			var b = state.columnOrder.indexOf( otherKey );&lt;br /&gt;
			state.columnOrder[ a ] = otherKey;&lt;br /&gt;
			state.columnOrder[ b ] = key;&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnChooser( [ key ] );&lt;br /&gt;
		}&lt;br /&gt;
		$upBtn.on( &#039;click&#039;, function () { moveShown( -1 ); } );&lt;br /&gt;
		$downBtn.on( &#039;click&#039;, function () { moveShown( 1 ); } );&lt;br /&gt;
&lt;br /&gt;
		renderColumnChooser();&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		// List Results button + status + results container&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		// ---- read UI back into state ----&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.zones = readMulti( $( &#039;#eqlis-zones&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$score.find( &#039;input[data-weight]&#039; ).each( function () {&lt;br /&gt;
				var v = parseFloat( $( this ).val() );&lt;br /&gt;
				state.componentWeights[ $( this ).attr( &#039;data-weight&#039; ) ] = isNaN( v ) ? 1 : v;&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			// Column show/hide + order is managed directly by the dual-list chooser.&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// Seed the wiki&#039;s ItemLevelSlider default so popover sliders initialize at&lt;br /&gt;
			// the upgrade level chosen here (its storage key is &#039;ils-default-level-v2&#039;).&lt;br /&gt;
			try {&lt;br /&gt;
				window.localStorage.setItem( &#039;ils-default-level-v2&#039;,&lt;br /&gt;
					JSON.stringify( { full: state.level, fraction: 0 } ) );&lt;br /&gt;
			} catch ( e ) {}&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// NOTE: intentionally no live re-render on control changes. Per spec, the&lt;br /&gt;
		// table only (re)builds when List Results is pressed — moving the upgrade&lt;br /&gt;
		// slider or toggling score/columns changes persisted state but does not&lt;br /&gt;
		// touch the results until the next List Results click.&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160895</id>
		<title>User:Chonx/common.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160895"/>
		<updated>2026-07-24T01:35:29Z</updated>

		<summary type="html">&lt;p&gt;Chonx: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;window.CHONX_CJS_RAN = true;&lt;br /&gt;
mw.loader.load( mw.config.get( &#039;wgScript&#039; ) + &#039;?title=User:Chonx/eqlItemScore.js&amp;amp;action=raw&amp;amp;ctype=text/javascript&#039; );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160893</id>
		<title>User:Chonx/common.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160893"/>
		<updated>2026-07-24T01:29:25Z</updated>

		<summary type="html">&lt;p&gt;Chonx: Replaced content with &amp;quot;/* ============================================================    This is ALL that goes in  User:Chonx/common.js    ------------------------------------------------------------    It loads the real module RAW (action=raw = NOT minified), which    avoids the ResourceLoader minifier that breaks the pasted-inline    version. Bonus: to iterate you now only edit the module page and    hard-refresh — no re-pasting this loader.     Put the full ext.itemScoreTable.js cont...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* ============================================================&lt;br /&gt;
   This is ALL that goes in  User:Chonx/common.js&lt;br /&gt;
   ------------------------------------------------------------&lt;br /&gt;
   It loads the real module RAW (action=raw = NOT minified), which&lt;br /&gt;
   avoids the ResourceLoader minifier that breaks the pasted-inline&lt;br /&gt;
   version. Bonus: to iterate you now only edit the module page and&lt;br /&gt;
   hard-refresh — no re-pasting this loader.&lt;br /&gt;
&lt;br /&gt;
   Put the full ext.itemScoreTable.js contents in:&lt;br /&gt;
     User:Chonx/eqlItemScore.js&lt;br /&gt;
   ============================================================ */&lt;br /&gt;
mw.loader.load(&lt;br /&gt;
	mw.config.get( &#039;wgScript&#039; ) +&lt;br /&gt;
	&#039;?title=User:Chonx/eqlItemScore.js&amp;amp;action=raw&amp;amp;ctype=text/javascript&#039;&lt;br /&gt;
);&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=160892</id>
		<title>User:Chonx/eqlItemScore.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/eqlItemScore.js&amp;diff=160892"/>
		<updated>2026-07-24T01:28:58Z</updated>

		<summary type="html">&lt;p&gt;Chonx: Created page with &amp;quot;/**  * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.  *  * Single self-contained module (same shape as ext.itemLevelSlider /  * ext.classSlotEquip). Paste into User:&amp;lt;you&amp;gt;/common.js, then create a page  * containing:  &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;  *  * 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 th...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/**&lt;br /&gt;
 * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.&lt;br /&gt;
 *&lt;br /&gt;
 * Single self-contained module (same shape as ext.itemLevelSlider /&lt;br /&gt;
 * ext.classSlotEquip). Paste into User:&amp;lt;you&amp;gt;/common.js, then create a page&lt;br /&gt;
 * containing:  &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
 *&lt;br /&gt;
 * Data access goes through ACTIVE_DATASOURCE so a future server-side&lt;br /&gt;
 * Special:ItemScoreTable can replace ClientDataSource with a one-line change.&lt;br /&gt;
 *&lt;br /&gt;
 * RELOADING AFTER AN EDIT: MediaWiki serves the user JS bundle from a server-side&lt;br /&gt;
 * ResourceLoader cache keyed by a version hash. After you save common.js the hash&lt;br /&gt;
 * can take a few minutes to recompute, so a browser hard-refresh (Ctrl+Shift+R)&lt;br /&gt;
 * alone may still run the OLD code. Reliable ways to force the new version:&lt;br /&gt;
 *   1. Append &amp;amp;debug=true to the page URL (bypasses the RL cache/minifier), or&lt;br /&gt;
 *   2. Visit  index.php?title=User:Chonx/common.js&amp;amp;action=raw  once, then reload, or&lt;br /&gt;
 *   3. Run this in the console to eval the freshest copy immediately:&lt;br /&gt;
 *      fetch(&#039;/index.php?title=User:Chonx/common.js&amp;amp;action=raw&amp;amp;_=&#039;+Date.now())&lt;br /&gt;
 *        .then(r=&amp;gt;r.text()).then(t=&amp;gt;eval(t));&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ---- styles (injected; common.js has no separate CSS channel) ----&lt;br /&gt;
	( function () {&lt;br /&gt;
		var s = document.createElement( &#039;style&#039; );&lt;br /&gt;
		s.setAttribute( &#039;data-eqlis&#039;, &#039;1&#039; );&lt;br /&gt;
		s.textContent = &amp;quot;/* EQL Item Score Table \u2014 styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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 &amp;gt; 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-collist { display: flex; flex-direction: column; gap: 0.15rem; }\n.eqlis-colrow { display: flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; }\n.eqlis-colmove { background: #202044; color: #ffd700; border: 1px solid #6a5acd; border-radius: 3px; cursor: pointer; line-height: 1; padding: 1px 5px; margin-left: auto; }\n.eqlis-colmove:hover { background: #2c2c5c; }\n&amp;quot;;&lt;br /&gt;
		( document.head || document.documentElement ).appendChild( s );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = 10;&lt;br /&gt;
&lt;br /&gt;
	// Stats that scale via the &amp;quot;primary spreadsheet&amp;quot; rule (incl. saves).&lt;br /&gt;
	var PRIMARY_STATS = [&lt;br /&gt;
		&#039;AC&#039;, &#039;HP&#039;, &#039;MP&#039;, &#039;END&#039;,&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;WIS&#039;, &#039;INT&#039;, &#039;CHA&#039;,&lt;br /&gt;
		&#039;SV_MAGIC&#039;, &#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Stats that, when &amp;gt;=2 are present, grant SV VOID: +level.&lt;br /&gt;
	var SV_VOID_QUALIFIERS = [&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;INT&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;CHA&#039;, &#039;WIS&#039;,&lt;br /&gt;
		&#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_MAGIC&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// All stat labels we recognize in a stat block, longest-first for regex.&lt;br /&gt;
	var STAT_ALIASES = {&lt;br /&gt;
		&#039;DAMAGE&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;DMG&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;MANA&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;MP&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;ENDUR&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;END&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;SV MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;SV FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;SV COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;SV POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;SV DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;HP REGEN&#039;: &#039;HP_REGEN&#039;,&lt;br /&gt;
		&#039;WEIGHT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;WT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;AC&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;ARMOR CLASS&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;HP&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;HIT POINTS&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;STR&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STRENGTH&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;STAMINA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;AGI&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;AGILITY&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;DEX&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;DEXTERITY&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;WIS&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;WISDOM&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;INT&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;INTELLIGENCE&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;CHA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;CHARISMA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;HASTE&#039;: &#039;HASTE&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas (ported from ext.itemLevelSlider.js) --------------&lt;br /&gt;
	// state here is just an integer effective level (0..10).&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Parse a stat block (plain text; callers should strip HTML first).&lt;br /&gt;
	 * Returns a normalized item fields object. focus_effect is NOT present in&lt;br /&gt;
	 * stat blocks — it is set later by the data-source enrichment step.&lt;br /&gt;
	 */&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			delay: 0,&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
			item.delay = parseFloat( skillMatch[ 2 ] );&lt;br /&gt;
		} else {&lt;br /&gt;
			var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
			if ( delayOnly ) {&lt;br /&gt;
				item.delay = parseFloat( delayOnly[ 1 ] );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Stats: for each known label, capture &amp;quot;LABEL: [+-]?num&amp;quot;&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			// DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Default score component weights (all 1). A component contributes its&lt;br /&gt;
	// scaled stat value when enabled.&lt;br /&gt;
	function defaultComponents() {&lt;br /&gt;
		return {&lt;br /&gt;
			ratio: false, dmg: false, delay: false,&lt;br /&gt;
			ac: false, str: false, sta: false, agi: false, dex: false,&lt;br /&gt;
			int: false, wis: false, cha: false,&lt;br /&gt;
			totalSV: false,&lt;br /&gt;
			svMagic: false, svFire: false, svCold: false,&lt;br /&gt;
			svPoison: false, svDisease: false, svVoid: false&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Compute the scaled value of one stat key at a given level.&lt;br /&gt;
	 */&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;DMG&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * scoreItem(item, opts)&lt;br /&gt;
	 *   opts.level        integer 0..10&lt;br /&gt;
	 *   opts.components    map of enabled score components (see defaultComponents)&lt;br /&gt;
	 *   opts.effectBonus  { focus, worn, click, combat } flat points per present effect&lt;br /&gt;
	 */&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || defaultComponents();&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		var weights = opts.weights || {};&lt;br /&gt;
		function w( key ) {&lt;br /&gt;
			var x = weights[ key ];&lt;br /&gt;
			return ( x == null || isNaN( x ) ) ? 1 : Number( x );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// value is the raw scaled stat; it is multiplied by the component weight.&lt;br /&gt;
		function add( name, value ) {&lt;br /&gt;
			var weighted = value * w( name );&lt;br /&gt;
			if ( weighted ) {&lt;br /&gt;
				breakdown[ name ] = weighted;&lt;br /&gt;
				score += weighted;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var svKeys = {&lt;br /&gt;
			svMagic: &#039;SV_MAGIC&#039;, svFire: &#039;SV_FIRE&#039;, svCold: &#039;SV_COLD&#039;,&lt;br /&gt;
			svPoison: &#039;SV_POISON&#039;, svDisease: &#039;SV_DISEASE&#039;&lt;br /&gt;
		};&lt;br /&gt;
		var simpleKeys = {&lt;br /&gt;
			ac: &#039;AC&#039;, str: &#039;STR&#039;, sta: &#039;STA&#039;, agi: &#039;AGI&#039;, dex: &#039;DEX&#039;,&lt;br /&gt;
			int: &#039;INT&#039;, wis: &#039;WIS&#039;, cha: &#039;CHA&#039;&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var scaledDmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.ratio &amp;amp;&amp;amp; item.delay &amp;gt; 0 &amp;amp;&amp;amp; scaledDmg &amp;gt; 0 ) {&lt;br /&gt;
			// Ratio score = (damage / delay) * 10  (damage/delay per user; *10 to scale up)&lt;br /&gt;
			add( &#039;ratio&#039;, ( scaledDmg / item.delay ) * 10 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.dmg ) {&lt;br /&gt;
			add( &#039;dmg&#039;, scaledDmg );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.delay &amp;amp;&amp;amp; item.delay &amp;gt; 0 ) {&lt;br /&gt;
			// Lower delay is better; contribute inverse so faster weapons score higher.&lt;br /&gt;
			add( &#039;delay&#039;, 100 / item.delay );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		Object.keys( simpleKeys ).forEach( function ( ck ) {&lt;br /&gt;
			if ( comp[ ck ] ) {&lt;br /&gt;
				add( ck, scaledStat( item, simpleKeys[ ck ], level ) );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.totalSV ) {&lt;br /&gt;
			var svTotal = 0;&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				svTotal += scaledStat( item, svKeys[ sk ], level );&lt;br /&gt;
			} );&lt;br /&gt;
			add( &#039;totalSV&#039;, svTotal );&lt;br /&gt;
		} else {&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				if ( comp[ sk ] ) {&lt;br /&gt;
					add( sk, scaledStat( item, svKeys[ sk ], level ) );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		if ( comp.svVoid ) {&lt;br /&gt;
			add( &#039;svVoid&#039;, svVoidBonus( item, level ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effect flat bonuses (per present effect type).&lt;br /&gt;
		[ &#039;worn&#039;, &#039;combat&#039;, &#039;click&#039; ].forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				add( &#039;effect_&#039; + t, effectBonus[ t ] );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( item.effectTypes.focus &amp;amp;&amp;amp; effectBonus.focus ) {&lt;br /&gt;
			add( &#039;effect_focus&#039;, effectBonus.focus );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * 1000 ) / 1000,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		if ( !item.delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / item.delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		defaultComponents: defaultComponents,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// ===================== app layer (UI / data / mount) =====================&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md&lt;br /&gt;
	// (name -&amp;gt; average mob level). STATIC data (eqltools-derived). This doubles as the&lt;br /&gt;
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone&lt;br /&gt;
	// filter options, and the &amp;quot;Zone Avg Lvl&amp;quot; column source.&lt;br /&gt;
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.&lt;br /&gt;
	var ZONE_AVG_LEVEL = {&lt;br /&gt;
		&#039;The Feerrott&#039;: 4,&lt;br /&gt;
		&#039;Toxxulia Forest&#039;: 4,&lt;br /&gt;
		&#039;Misty Thicket&#039;: 5,&lt;br /&gt;
		&#039;Qeynos Hills&#039;: 6,&lt;br /&gt;
		&#039;The Qeynos Aqueduct System&#039;: 6,&lt;br /&gt;
		&#039;Innothule Swamp&#039;: 6,&lt;br /&gt;
		&#039;The Steamfont Mountains&#039;: 6,&lt;br /&gt;
		&#039;The Greater Faydark&#039;: 7,&lt;br /&gt;
		&#039;Butcherblock Mountains&#039;: 7,&lt;br /&gt;
		&#039;Nektulos Forest&#039;: 7,&lt;br /&gt;
		&#039;East Commonlands&#039;: 8,&lt;br /&gt;
		&#039;Everfrost Peaks&#039;: 8,&lt;br /&gt;
		&#039;The Northern Desert of Ro&#039;: 9,&lt;br /&gt;
		&#039;Blackburrow&#039;: 10,&lt;br /&gt;
		&#039;The Southern Desert of Ro&#039;: 10,&lt;br /&gt;
		&#039;West Commonlands&#039;: 10,&lt;br /&gt;
		&#039;The Western Plains of Karana&#039;: 10,&lt;br /&gt;
		&#039;Erud\&#039;s Crossing&#039;: 11,&lt;br /&gt;
		&#039;The Warrens&#039;: 11,&lt;br /&gt;
		&#039;Clan Crushbone&#039;: 12,&lt;br /&gt;
		&#039;Befallen&#039;: 13,&lt;br /&gt;
		&#039;The Lavastorm Mountains&#039;: 13,&lt;br /&gt;
		&#039;Dagnor\&#039;s Cauldron&#039;: 13,&lt;br /&gt;
		&#039;The Gorge of King Xorbb&#039;: 14,&lt;br /&gt;
		&#039;The Oasis of Marr&#039;: 14,&lt;br /&gt;
		&#039;The Eastern Plains of Karana&#039;: 14,&lt;br /&gt;
		&#039;Highpass Hold&#039;: 15,&lt;br /&gt;
		&#039;The Lesser Faydark&#039;: 15,&lt;br /&gt;
		&#039;The Northern Plains of Karana&#039;: 15,&lt;br /&gt;
		&#039;Lake Rathetear&#039;: 17,&lt;br /&gt;
		&#039;The Rathe Mountains&#039;: 17,&lt;br /&gt;
		&#039;Najena&#039;: 19,&lt;br /&gt;
		&#039;The Liberated Citadel of Runnyeye&#039;: 19,&lt;br /&gt;
		&#039;The City of Guk&#039;: 20,&lt;br /&gt;
		&#039;The Southern Plains of Karana&#039;: 21,&lt;br /&gt;
		&#039;The Stonebrunt Mountains&#039;: 21,&lt;br /&gt;
		&#039;The Ocean of Tears&#039;: 22,&lt;br /&gt;
		&#039;The Estate of Unrest&#039;: 23,&lt;br /&gt;
		&#039;High Keep&#039;: 25,&lt;br /&gt;
		&#039;Solusek\&#039;s Eye&#039;: 25,&lt;br /&gt;
		&#039;Temple of Cazic-Thule&#039;: 26,&lt;br /&gt;
		&#039;Permafrost Keep&#039;: 26,&lt;br /&gt;
		&#039;Kithicor Forest&#039;: 30,&lt;br /&gt;
		&#039;The Castle of Mistmoore&#039;: 31,&lt;br /&gt;
		&#039;The Lair of the Splitpaw&#039;: 31,&lt;br /&gt;
		&#039;The Temple of Solusek Ro&#039;: 35,&lt;br /&gt;
		&#039;The Ruins of Old Guk&#039;: 36,&lt;br /&gt;
		&#039;Kedge Keep&#039;: 40,&lt;br /&gt;
		&#039;Nagafen\&#039;s Lair&#039;: 40,&lt;br /&gt;
		&#039;The Ruins of Old Paineel&#039;: 45,&lt;br /&gt;
		&#039;The Plane of Fear&#039;: 50,&lt;br /&gt;
		&#039;The Plane of Hate&#039;: 52,&lt;br /&gt;
		&#039;The Plane of Sky&#039;: 54&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Wiki zone names that don&#039;t normalize to a ZONE_AVG_LEVEL key by prefix-stripping&lt;br /&gt;
	// alone. Extend as item-&amp;gt;zone enrichment surfaces more mismatches.&lt;br /&gt;
	var ZONE_ALIASES = {&lt;br /&gt;
		&#039;Lower Guk&#039;: &#039;The Ruins of Old Guk&#039;,&lt;br /&gt;
		&#039;Upper Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Paineel&#039;: &#039;The Ruins of Old Paineel&#039;,&lt;br /&gt;
		&#039;Runnyeye&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Runnyeye Citadel&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Castle Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Splitpaw&#039;: &#039;The Lair of the Splitpaw&#039;,&lt;br /&gt;
		&#039;Unrest&#039;: &#039;The Estate of Unrest&#039;,&lt;br /&gt;
		&#039;Cazic Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Cazic-Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Oasis of Marr&#039;: &#039;The Oasis of Marr&#039;,&lt;br /&gt;
		&#039;Solusek A&#039;: &#039;Solusek\&#039;s Eye&#039;,&lt;br /&gt;
		&#039;Solusek B&#039;: &#039;Nagafen\&#039;s Lair&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	function zoneKey( name ) {&lt;br /&gt;
		return String( name || &#039;&#039; )&lt;br /&gt;
			.toLowerCase()&lt;br /&gt;
			.replace( /^the\s+/, &#039;&#039; )&lt;br /&gt;
			.replace( /[^a-z0-9]+/g, &#039; &#039; )&lt;br /&gt;
			.trim();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ZONE_INDEX = ( function () {&lt;br /&gt;
		var idx = {};&lt;br /&gt;
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {&lt;br /&gt;
			idx[ zoneKey( name ) ] = name;&lt;br /&gt;
		} );&lt;br /&gt;
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {&lt;br /&gt;
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];&lt;br /&gt;
		} );&lt;br /&gt;
		return idx;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	// Canonical ZONE_AVG_LEVEL name for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function canonicalZone( name ) {&lt;br /&gt;
		if ( !name ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return ZONE_INDEX[ zoneKey( name ) ] || null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Average mob level for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function zoneAverageLevel( name ) {&lt;br /&gt;
		var canonical = canonicalZone( name );&lt;br /&gt;
		return canonical ? ZONE_AVG_LEVEL[ canonical ] : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function zoneDisplayNames() {&lt;br /&gt;
		return Object.keys( ZONE_AVG_LEVEL ).sort();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Score component checkboxes. `sv` marks the mutually-exclusive save group.&lt;br /&gt;
	var SCORE_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Weapon Ratio (dmg/delay)&#039; },&lt;br /&gt;
		{ key: &#039;dmg&#039;, label: &#039;Weapon Damage&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Weapon Delay&#039; },&lt;br /&gt;
		{ key: &#039;ac&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;str&#039;, label: &#039;Strength&#039; },&lt;br /&gt;
		{ key: &#039;dex&#039;, label: &#039;Dexterity&#039; },&lt;br /&gt;
		{ key: &#039;agi&#039;, label: &#039;Agility&#039; },&lt;br /&gt;
		{ key: &#039;sta&#039;, label: &#039;Stamina&#039; },&lt;br /&gt;
		{ key: &#039;int&#039;, label: &#039;Intelligence&#039; },&lt;br /&gt;
		{ key: &#039;wis&#039;, label: &#039;Wisdom&#039; },&lt;br /&gt;
		{ key: &#039;cha&#039;, label: &#039;Charisma&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var SV_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;svMagic&#039;, label: &#039;SV Magic&#039; },&lt;br /&gt;
		{ key: &#039;svFire&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;svCold&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;svPoison&#039;, label: &#039;SV Poison&#039; },&lt;br /&gt;
		{ key: &#039;svDisease&#039;, label: &#039;SV Disease&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var EFFECT_TYPES = [ &#039;focus&#039;, &#039;worn&#039;, &#039;click&#039;, &#039;combat&#039; ];&lt;br /&gt;
&lt;br /&gt;
	// Result columns. `enrich:true` = populated by the (future) enrichment phase.&lt;br /&gt;
	var COLUMNS = [&lt;br /&gt;
		{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
		{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
		{ key: &#039;slot&#039;, label: &#039;Slot&#039; },&lt;br /&gt;
		{ key: &#039;type&#039;, label: &#039;Type&#039; },&lt;br /&gt;
		{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;worn&#039;, label: &#039;Worn&#039; },&lt;br /&gt;
		{ key: &#039;click&#039;, label: &#039;Click&#039; },&lt;br /&gt;
		{ key: &#039;combat&#039;, label: &#039;Combat&#039; },&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Ratio&#039; },&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Delay&#039; },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039; },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039; },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039; },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039; },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039; },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039; },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039; },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039; },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039; },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; },&lt;br /&gt;
		{ key: &#039;source&#039;, label: &#039;Source&#039; },&lt;br /&gt;
		{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		var weights = {};&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
			weights[ c.key ] = 1;      // stat / weapon components default weight 1&lt;br /&gt;
		} );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
			weights[ c.key ] = 0.5;    // resist (SV) components default weight 0.5&lt;br /&gt;
		} );&lt;br /&gt;
		comps.totalSV = false;&lt;br /&gt;
		weights.totalSV = 0.5;         // Total SV behaves like a resist&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: 1,&lt;br /&gt;
			maxLevel: 100,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			componentWeights: weights,&lt;br /&gt;
			effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },&lt;br /&gt;
			columns: cols,&lt;br /&gt;
			columnOrder: COLUMNS.map( function ( c ) { return c.key; } )&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Parse one ClassSlotEquip HTML fragment into item records.&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column (3rd, index 2).&lt;br /&gt;
			var $src = $cells.eq( 2 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; : &#039;drop&#039;;&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				// enrichment placeholders. available: true|false|null (null = unknown)&lt;br /&gt;
				enrich: { focus: null, level: null, zone: null, available: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment: item -&amp;gt; zone + availability (Phase 1, via categories) ----&lt;br /&gt;
&lt;br /&gt;
	function titleFromHref( href, name ) {&lt;br /&gt;
		if ( href ) {&lt;br /&gt;
			var t = href.replace( /^https?:\/\/[^/]+/, &#039;&#039; ).replace( /^\/(wiki\/)?/, &#039;&#039; ).split( &#039;#&#039; )[ 0 ];&lt;br /&gt;
			try { t = decodeURIComponent( t ); } catch ( e ) {}&lt;br /&gt;
			if ( t ) { return t.replace( /_/g, &#039; &#039; ); }&lt;br /&gt;
		}&lt;br /&gt;
		return name || &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function resolveFinalTitle( title, alias ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		while ( alias[ title ] &amp;amp;&amp;amp; !seen[ title ] ) {&lt;br /&gt;
			seen[ title ] = 1;&lt;br /&gt;
			title = alias[ title ];&lt;br /&gt;
		}&lt;br /&gt;
		return title;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Batched prop=categories with redirect/normalize + continuation handling.&lt;br /&gt;
	// Resolves to { cats: {finalTitle:[categoryName,...]}, alias: {from:to} }.&lt;br /&gt;
	function fetchCategories( titles ) {&lt;br /&gt;
		var result = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
&lt;br /&gt;
			function runBatch( batch, cont ) {&lt;br /&gt;
				var params = {&lt;br /&gt;
					action: &#039;query&#039;,&lt;br /&gt;
					prop: &#039;categories&#039;,&lt;br /&gt;
					titles: batch.join( &#039;|&#039; ),&lt;br /&gt;
					cllimit: &#039;max&#039;,&lt;br /&gt;
					redirects: 1,&lt;br /&gt;
					format: &#039;json&#039;&lt;br /&gt;
				};&lt;br /&gt;
				if ( cont ) {&lt;br /&gt;
					params.clcontinue = cont;&lt;br /&gt;
				}&lt;br /&gt;
				return api.get( params ).then( function ( data ) {&lt;br /&gt;
					var q = data.query || {};&lt;br /&gt;
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );&lt;br /&gt;
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );&lt;br /&gt;
					var pages = q.pages || {};&lt;br /&gt;
					Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
						var pg = pages[ pid ];&lt;br /&gt;
						if ( !pg.title ) { return; }&lt;br /&gt;
						var cats = ( pg.categories || [] ).map( function ( c ) {&lt;br /&gt;
							return c.title.replace( /^Category:/, &#039;&#039; );&lt;br /&gt;
						} );&lt;br /&gt;
						result[ pg.title ] = ( result[ pg.title ] || [] ).concat( cats );&lt;br /&gt;
					} );&lt;br /&gt;
					if ( data.continue &amp;amp;&amp;amp; data.continue.clcontinue ) {&lt;br /&gt;
						return runBatch( batch, data.continue.clcontinue );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );&lt;br /&gt;
		} ).then( function () {&lt;br /&gt;
			return { cats: result, alias: alias };&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Set item.enrich.zone / .available from each item&#039;s categories.&lt;br /&gt;
	// Availability = &amp;quot;drops in a zone that is in ZONE_AVG_LEVEL&amp;quot; (Requirement #1).&lt;br /&gt;
	// Quest items carry no zone category (zone is on the quest page) — kept as&lt;br /&gt;
	// available:null pending Phase 2 quest resolution rather than excluded.&lt;br /&gt;
	function enrichZones( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			it.title = titleFromHref( it.href, it.name );&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; !seen[ it.title ] ) {&lt;br /&gt;
				seen[ it.title ] = 1;&lt;br /&gt;
				titles.push( it.title );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return fetchCategories( titles ).then( function ( res ) {&lt;br /&gt;
			items.forEach( function ( it ) {&lt;br /&gt;
				var cats = res.cats[ resolveFinalTitle( it.title, res.alias ) ] ||&lt;br /&gt;
					res.cats[ it.title ] || [];&lt;br /&gt;
				var zones = [];&lt;br /&gt;
				cats.forEach( function ( cat ) {&lt;br /&gt;
					var cz = canonicalZone( cat );&lt;br /&gt;
					if ( cz &amp;amp;&amp;amp; zones.indexOf( cz ) === -1 ) {&lt;br /&gt;
						zones.push( cz );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				var isQuest = it.source.type === &#039;quest&#039; ||&lt;br /&gt;
					cats.indexOf( &#039;Quest Items&#039; ) !== -1;&lt;br /&gt;
&lt;br /&gt;
				if ( zones.length === 1 ) {&lt;br /&gt;
					it.enrich.zone = zones[ 0 ];&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( zones.length &amp;gt; 1 ) {&lt;br /&gt;
					it.enrich.zone = &#039;multiple&#039;;&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( isQuest ) {&lt;br /&gt;
					it.enrich.zone = &#039;quest&#039;;   // Phase 2 resolves the actual start zone&lt;br /&gt;
					it.enrich.available = null; // unknown; keep for now&lt;br /&gt;
				} else {&lt;br /&gt;
					it.enrich.zone = null;&lt;br /&gt;
					it.enrich.available = false; // no in-scope zone -&amp;gt; out of scope&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			return items;&lt;br /&gt;
		}, function () {&lt;br /&gt;
			return items; // enrichment failed -&amp;gt; keep everything, unfiltered&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		/**&lt;br /&gt;
		 * fetchItems(query) -&amp;gt; Promise&amp;lt;Array&amp;lt;itemRecord&amp;gt;&amp;gt;&lt;br /&gt;
		 * query: { classes:[a,b,c], slots:[...] }&lt;br /&gt;
		 * Fans out one request per slot, merges, de-dupes by name+slot,&lt;br /&gt;
		 * then enriches each item with zone + availability.&lt;br /&gt;
		 */&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} ).then( enrichZones );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Availability (Requirement #1): drop into a currently-available zone.&lt;br /&gt;
		// available===false means &amp;quot;has drop zone(s), none currently available&amp;quot; -&amp;gt; hide.&lt;br /&gt;
		// null (unknown, e.g. unresolved quest items) is kept.&lt;br /&gt;
		if ( item.enrich.available === false ) {&lt;br /&gt;
			return false;&lt;br /&gt;
		}&lt;br /&gt;
		// Weapon type&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Level range (enrichment-dependent). In v1, item.enrich.level is null;&lt;br /&gt;
		// only filter when we actually have a level so we don&#039;t hide everything.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( lvl != null ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name&lt;br /&gt;
		// variants (&amp;quot;The …&amp;quot;, Guk aliases) line up with the ZONE_AVG_LEVEL keys.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			var itemZone = canonicalZone( item.enrich.zone );&lt;br /&gt;
			var matched = item.enrich.zone === &#039;multiple&#039; || state.zones.some( function ( sel ) {&lt;br /&gt;
				return canonicalZone( sel ) === itemZone;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !matched ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scoreOpts( state ) {&lt;br /&gt;
		return {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			weights: state.componentWeights,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;delay&#039; ) {&lt;br /&gt;
			return item.fields.delay || &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable stat key (DMG / AC / STR / SV_*)&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var names = item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.map( function ( e ) { return e.name; } );&lt;br /&gt;
		return names.join( &#039;, &#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function columnByKey( key ) {&lt;br /&gt;
		for ( var i = 0; i &amp;lt; COLUMNS.length; i++ ) {&lt;br /&gt;
			if ( COLUMNS[ i ].key === key ) {&lt;br /&gt;
				return COLUMNS[ i ];&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Reconcile a saved columnOrder with the current COLUMNS set: drop unknown/dupe&lt;br /&gt;
	// keys, append any columns missing from the saved order.&lt;br /&gt;
	function normalizeColumnOrder( state ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		var order = ( state.columnOrder || [] ).filter( function ( k ) {&lt;br /&gt;
			var ok = columnByKey( k ) &amp;amp;&amp;amp; !seen[ k ];&lt;br /&gt;
			seen[ k ] = true;&lt;br /&gt;
			return ok;&lt;br /&gt;
		} );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( !seen[ c.key ] ) {&lt;br /&gt;
				order.push( c.key );&lt;br /&gt;
				seen[ c.key ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		state.columnOrder = order;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = state.columnOrder&lt;br /&gt;
			.map( columnByKey )&lt;br /&gt;
			.filter( function ( c ) {&lt;br /&gt;
				return c &amp;amp;&amp;amp; ( c.always || state.columns[ c.key ] );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		// score + filter, then sort by score desc for a useful default&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;:&lt;br /&gt;
						$td.text( it.enrich.focus || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.text( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						var avg = zoneAverageLevel( it.enrich.zone );&lt;br /&gt;
						$td.text( avg == null ? &#039;—&#039; : avg );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Out-of-era drops are filtered out; quest items and the Level / Focus &#039; +&lt;br /&gt;
			&#039;columns are still being wired up.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
		var $col1 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col2 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col3 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		var $col4 = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-col&#039; } );&lt;br /&gt;
		$controls.append( $col1 ).append( $col2 ).append( $col3 ).append( $col4 );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 1: Classes / Slots / Weapon Type ----&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$col1.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$col1.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$col1.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 2: Upgrade Level / Level min-max / Zone ----&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$col2.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$col2.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		var $zoneWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$zoneWrap.append( el( &#039;label&#039;, null, &#039;Zone: &#039; ) );&lt;br /&gt;
		$zoneWrap.append( multiSelect( &#039;eqlis-zones&#039;, zoneDisplayNames(), state.zones ) );&lt;br /&gt;
		$col2.append( $zoneWrap );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 3: Item Score components (check to include; box = weight) ----&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score (check = include, box = weight)&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		function scoreRow( c, extraClass ) {&lt;br /&gt;
			var $row = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; + ( extraClass ? &#039; &#039; + extraClass : &#039;&#039; ) } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			var $wt = el( &#039;input&#039;, {&lt;br /&gt;
				type: &#039;number&#039;, step: 0.5, min: 0, &#039;data-weight&#039;: c.key,&lt;br /&gt;
				value: state.componentWeights[ c.key ], &#039;class&#039;: &#039;eqlis-wt&#039;&lt;br /&gt;
			} );&lt;br /&gt;
			$row.append( $cb )&lt;br /&gt;
				.append( document.createTextNode( &#039; &#039; + c.label + &#039; &#039; ) )&lt;br /&gt;
				.append( $wt );&lt;br /&gt;
			return $row;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) { $score.append( scoreRow( c ) ); } );&lt;br /&gt;
&lt;br /&gt;
		var $totalRow = scoreRow( { key: &#039;totalSV&#039;, label: &#039;Total SV (sum of saves)&#039; } );&lt;br /&gt;
		$score.append( $totalRow );&lt;br /&gt;
		var $totalCb = $totalRow.find( &#039;input[data-comp]&#039; );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) { $score.append( scoreRow( c, &#039;eqlis-sv&#039; ) ); } );&lt;br /&gt;
&lt;br /&gt;
		// disable individual saves when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$col3.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// ---- Column 4: Columns (show/hide + reorder) ----&lt;br /&gt;
		// Changes apply on the next List Results (no live re-render).&lt;br /&gt;
		normalizeColumnOrder( state );&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns (▲▼ to reorder)&#039; ) );&lt;br /&gt;
		var $colList = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-collist&#039; } );&lt;br /&gt;
		$cols.append( $colList );&lt;br /&gt;
		$col4.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		function moveColumn( idx, dir ) {&lt;br /&gt;
			var j = idx + dir;&lt;br /&gt;
			if ( j &amp;lt; 0 || j &amp;gt;= state.columnOrder.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var tmp = state.columnOrder[ idx ];&lt;br /&gt;
			state.columnOrder[ idx ] = state.columnOrder[ j ];&lt;br /&gt;
			state.columnOrder[ j ] = tmp;&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnList();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		function renderColumnList() {&lt;br /&gt;
			$colList.empty();&lt;br /&gt;
			state.columnOrder.forEach( function ( key, idx ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( !c ) { return; }&lt;br /&gt;
				var $row = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-colrow&#039; } );&lt;br /&gt;
				var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-col&#039;: c.key } );&lt;br /&gt;
				if ( c.always ) {&lt;br /&gt;
					$cb.prop( &#039;checked&#039;, true ).prop( &#039;disabled&#039;, true );&lt;br /&gt;
				} else if ( state.columns[ c.key ] ) {&lt;br /&gt;
					$cb.prop( &#039;checked&#039;, true );&lt;br /&gt;
				}&lt;br /&gt;
				var $up = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-colmove&#039;, title: &#039;Move up&#039; }, &#039;▲&#039; );&lt;br /&gt;
				var $down = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-colmove&#039;, title: &#039;Move down&#039; }, &#039;▼&#039; );&lt;br /&gt;
				$up.on( &#039;click&#039;, function () { moveColumn( idx, -1 ); } );&lt;br /&gt;
				$down.on( &#039;click&#039;, function () { moveColumn( idx, 1 ); } );&lt;br /&gt;
				$row&lt;br /&gt;
					.append( $cb )&lt;br /&gt;
					.append( document.createTextNode( &#039; &#039; + c.label + &#039; &#039; ) )&lt;br /&gt;
					.append( $up ).append( $down );&lt;br /&gt;
				$colList.append( $row );&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		renderColumnList();&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		// List Results button + status + results container&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		// ---- read UI back into state ----&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.zones = readMulti( $( &#039;#eqlis-zones&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$score.find( &#039;input[data-weight]&#039; ).each( function () {&lt;br /&gt;
				var v = parseFloat( $( this ).val() );&lt;br /&gt;
				state.componentWeights[ $( this ).attr( &#039;data-weight&#039; ) ] = isNaN( v ) ? 1 : v;&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			$cols.find( &#039;input[data-col]&#039; ).each( function () {&lt;br /&gt;
				state.columns[ $( this ).attr( &#039;data-col&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// Seed the wiki&#039;s ItemLevelSlider default so popover sliders initialize at&lt;br /&gt;
			// the upgrade level chosen here (its storage key is &#039;ils-default-level-v2&#039;).&lt;br /&gt;
			try {&lt;br /&gt;
				window.localStorage.setItem( &#039;ils-default-level-v2&#039;,&lt;br /&gt;
					JSON.stringify( { full: state.level, fraction: 0 } ) );&lt;br /&gt;
			} catch ( e ) {}&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// NOTE: intentionally no live re-render on control changes. Per spec, the&lt;br /&gt;
		// table only (re)builds when List Results is pressed — moving the upgrade&lt;br /&gt;
		// slider or toggling score/columns changes persisted state but does not&lt;br /&gt;
		// touch the results until the next List Results click.&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160883</id>
		<title>User:Chonx/common.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160883"/>
		<updated>2026-07-24T00:03:20Z</updated>

		<summary type="html">&lt;p&gt;Chonx: zones&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/**&lt;br /&gt;
 * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.&lt;br /&gt;
 *&lt;br /&gt;
 * Single self-contained module (same shape as ext.itemLevelSlider /&lt;br /&gt;
 * ext.classSlotEquip). Paste into User:&amp;lt;you&amp;gt;/common.js, then create a page&lt;br /&gt;
 * containing:  &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
 *&lt;br /&gt;
 * Data access goes through ACTIVE_DATASOURCE so a future server-side&lt;br /&gt;
 * Special:ItemScoreTable can replace ClientDataSource with a one-line change.&lt;br /&gt;
 *&lt;br /&gt;
 * RELOADING AFTER AN EDIT: MediaWiki serves the user JS bundle from a server-side&lt;br /&gt;
 * ResourceLoader cache keyed by a version hash. After you save common.js the hash&lt;br /&gt;
 * can take a few minutes to recompute, so a browser hard-refresh (Ctrl+Shift+R)&lt;br /&gt;
 * alone may still run the OLD code. Reliable ways to force the new version:&lt;br /&gt;
 *   1. Append &amp;amp;debug=true to the page URL (bypasses the RL cache/minifier), or&lt;br /&gt;
 *   2. Visit  index.php?title=User:&amp;lt;you&amp;gt;/common.js&amp;amp;action=raw  once, then reload, or&lt;br /&gt;
 *   3. Run this in the console to eval the freshest copy immediately:&lt;br /&gt;
 *      fetch(&#039;/index.php?title=User:&amp;lt;you&amp;gt;/common.js&amp;amp;action=raw&amp;amp;_=&#039;+Date.now())&lt;br /&gt;
 *        .then(r=&amp;gt;r.text()).then(t=&amp;gt;eval(t));&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ---- styles (injected; common.js has no separate CSS channel) ----&lt;br /&gt;
	( function () {&lt;br /&gt;
		var s = document.createElement( &#039;style&#039; );&lt;br /&gt;
		s.setAttribute( &#039;data-eqlis&#039;, &#039;1&#039; );&lt;br /&gt;
		s.textContent = &amp;quot;/* EQL Item Score Table \u2014 styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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; gap: 0.9rem 1.4rem; margin-bottom: 0.75rem; }\n.eqlis-field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }\n.eqlis-field &amp;gt; 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: inline-flex; align-items: center; gap: 0.25rem; margin: 0.1rem 0.6rem 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-collist { display: flex; flex-direction: column; gap: 0.15rem; }\n.eqlis-colrow { display: flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; }\n.eqlis-colmove { background: #202044; color: #ffd700; border: 1px solid #6a5acd; border-radius: 3px; cursor: pointer; line-height: 1; padding: 1px 5px; margin-left: auto; }\n.eqlis-colmove:hover { background: #2c2c5c; }\n&amp;quot;;&lt;br /&gt;
		( document.head || document.documentElement ).appendChild( s );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = 10;&lt;br /&gt;
&lt;br /&gt;
	// Stats that scale via the &amp;quot;primary spreadsheet&amp;quot; rule (incl. saves).&lt;br /&gt;
	var PRIMARY_STATS = [&lt;br /&gt;
		&#039;AC&#039;, &#039;HP&#039;, &#039;MP&#039;, &#039;END&#039;,&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;WIS&#039;, &#039;INT&#039;, &#039;CHA&#039;,&lt;br /&gt;
		&#039;SV_MAGIC&#039;, &#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Stats that, when &amp;gt;=2 are present, grant SV VOID: +level.&lt;br /&gt;
	var SV_VOID_QUALIFIERS = [&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;INT&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;CHA&#039;, &#039;WIS&#039;,&lt;br /&gt;
		&#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_MAGIC&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// All stat labels we recognize in a stat block, longest-first for regex.&lt;br /&gt;
	var STAT_ALIASES = {&lt;br /&gt;
		&#039;DAMAGE&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;DMG&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;MANA&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;MP&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;ENDUR&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;END&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;SV MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;SV FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;SV COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;SV POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;SV DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;HP REGEN&#039;: &#039;HP_REGEN&#039;,&lt;br /&gt;
		&#039;WEIGHT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;WT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;AC&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;ARMOR CLASS&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;HP&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;HIT POINTS&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;STR&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STRENGTH&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;STAMINA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;AGI&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;AGILITY&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;DEX&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;DEXTERITY&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;WIS&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;WISDOM&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;INT&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;INTELLIGENCE&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;CHA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;CHARISMA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;HASTE&#039;: &#039;HASTE&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas (ported from ext.itemLevelSlider.js) --------------&lt;br /&gt;
	// state here is just an integer effective level (0..10).&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Parse a stat block (plain text; callers should strip HTML first).&lt;br /&gt;
	 * Returns a normalized item fields object. focus_effect is NOT present in&lt;br /&gt;
	 * stat blocks — it is set later by the data-source enrichment step.&lt;br /&gt;
	 */&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			delay: 0,&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
			item.delay = parseFloat( skillMatch[ 2 ] );&lt;br /&gt;
		} else {&lt;br /&gt;
			var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
			if ( delayOnly ) {&lt;br /&gt;
				item.delay = parseFloat( delayOnly[ 1 ] );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Stats: for each known label, capture &amp;quot;LABEL: [+-]?num&amp;quot;&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			// DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Default score component weights (all 1). A component contributes its&lt;br /&gt;
	// scaled stat value when enabled.&lt;br /&gt;
	function defaultComponents() {&lt;br /&gt;
		return {&lt;br /&gt;
			ratio: false, dmg: false, delay: false,&lt;br /&gt;
			ac: false, str: false, sta: false, agi: false, dex: false,&lt;br /&gt;
			int: false, wis: false, cha: false,&lt;br /&gt;
			totalSV: false,&lt;br /&gt;
			svMagic: false, svFire: false, svCold: false,&lt;br /&gt;
			svPoison: false, svDisease: false, svVoid: false&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Compute the scaled value of one stat key at a given level.&lt;br /&gt;
	 */&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;DMG&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * scoreItem(item, opts)&lt;br /&gt;
	 *   opts.level        integer 0..10&lt;br /&gt;
	 *   opts.components    map of enabled score components (see defaultComponents)&lt;br /&gt;
	 *   opts.effectBonus  { focus, worn, click, combat } flat points per present effect&lt;br /&gt;
	 */&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || defaultComponents();&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		function add( name, value ) {&lt;br /&gt;
			if ( value ) {&lt;br /&gt;
				breakdown[ name ] = value;&lt;br /&gt;
				score += value;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var svKeys = {&lt;br /&gt;
			svMagic: &#039;SV_MAGIC&#039;, svFire: &#039;SV_FIRE&#039;, svCold: &#039;SV_COLD&#039;,&lt;br /&gt;
			svPoison: &#039;SV_POISON&#039;, svDisease: &#039;SV_DISEASE&#039;&lt;br /&gt;
		};&lt;br /&gt;
		var simpleKeys = {&lt;br /&gt;
			ac: &#039;AC&#039;, str: &#039;STR&#039;, sta: &#039;STA&#039;, agi: &#039;AGI&#039;, dex: &#039;DEX&#039;,&lt;br /&gt;
			int: &#039;INT&#039;, wis: &#039;WIS&#039;, cha: &#039;CHA&#039;&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var scaledDmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.ratio &amp;amp;&amp;amp; item.delay &amp;gt; 0 &amp;amp;&amp;amp; scaledDmg &amp;gt; 0 ) {&lt;br /&gt;
			// Ratio score = (damage / delay) * 10  (damage/delay per user; *10 to scale up)&lt;br /&gt;
			add( &#039;ratio&#039;, ( scaledDmg / item.delay ) * 10 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.dmg ) {&lt;br /&gt;
			add( &#039;dmg&#039;, scaledDmg );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.delay &amp;amp;&amp;amp; item.delay &amp;gt; 0 ) {&lt;br /&gt;
			// Lower delay is better; contribute inverse so faster weapons score higher.&lt;br /&gt;
			add( &#039;delay&#039;, 100 / item.delay );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		Object.keys( simpleKeys ).forEach( function ( ck ) {&lt;br /&gt;
			if ( comp[ ck ] ) {&lt;br /&gt;
				add( ck, scaledStat( item, simpleKeys[ ck ], level ) );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.totalSV ) {&lt;br /&gt;
			var svTotal = 0;&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				svTotal += scaledStat( item, svKeys[ sk ], level );&lt;br /&gt;
			} );&lt;br /&gt;
			add( &#039;totalSV&#039;, svTotal );&lt;br /&gt;
		} else {&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				if ( comp[ sk ] ) {&lt;br /&gt;
					add( sk, scaledStat( item, svKeys[ sk ], level ) );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		if ( comp.svVoid ) {&lt;br /&gt;
			add( &#039;svVoid&#039;, svVoidBonus( item, level ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effect flat bonuses (per present effect type).&lt;br /&gt;
		[ &#039;worn&#039;, &#039;combat&#039;, &#039;click&#039; ].forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				add( &#039;effect_&#039; + t, effectBonus[ t ] );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( item.effectTypes.focus &amp;amp;&amp;amp; effectBonus.focus ) {&lt;br /&gt;
			add( &#039;effect_focus&#039;, effectBonus.focus );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * 1000 ) / 1000,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		if ( !item.delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / item.delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		defaultComponents: defaultComponents,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// ===================== app layer (UI / data / mount) =====================&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md&lt;br /&gt;
	// (name -&amp;gt; average mob level). STATIC data (eqltools-derived). This doubles as the&lt;br /&gt;
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone&lt;br /&gt;
	// filter options, and the &amp;quot;Zone Avg Lvl&amp;quot; column source.&lt;br /&gt;
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.&lt;br /&gt;
	var ZONE_AVG_LEVEL = {&lt;br /&gt;
		&#039;The Feerrott&#039;: 4,&lt;br /&gt;
		&#039;Toxxulia Forest&#039;: 4,&lt;br /&gt;
		&#039;Misty Thicket&#039;: 5,&lt;br /&gt;
		&#039;Qeynos Hills&#039;: 6,&lt;br /&gt;
		&#039;The Qeynos Aqueduct System&#039;: 6,&lt;br /&gt;
		&#039;Innothule Swamp&#039;: 6,&lt;br /&gt;
		&#039;The Steamfont Mountains&#039;: 6,&lt;br /&gt;
		&#039;The Greater Faydark&#039;: 7,&lt;br /&gt;
		&#039;Butcherblock Mountains&#039;: 7,&lt;br /&gt;
		&#039;Nektulos Forest&#039;: 7,&lt;br /&gt;
		&#039;East Commonlands&#039;: 8,&lt;br /&gt;
		&#039;Everfrost Peaks&#039;: 8,&lt;br /&gt;
		&#039;The Northern Desert of Ro&#039;: 9,&lt;br /&gt;
		&#039;Blackburrow&#039;: 10,&lt;br /&gt;
		&#039;The Southern Desert of Ro&#039;: 10,&lt;br /&gt;
		&#039;West Commonlands&#039;: 10,&lt;br /&gt;
		&#039;The Western Plains of Karana&#039;: 10,&lt;br /&gt;
		&#039;Erud\&#039;s Crossing&#039;: 11,&lt;br /&gt;
		&#039;The Warrens&#039;: 11,&lt;br /&gt;
		&#039;Clan Crushbone&#039;: 12,&lt;br /&gt;
		&#039;Befallen&#039;: 13,&lt;br /&gt;
		&#039;The Lavastorm Mountains&#039;: 13,&lt;br /&gt;
		&#039;Dagnor\&#039;s Cauldron&#039;: 13,&lt;br /&gt;
		&#039;The Gorge of King Xorbb&#039;: 14,&lt;br /&gt;
		&#039;The Oasis of Marr&#039;: 14,&lt;br /&gt;
		&#039;The Eastern Plains of Karana&#039;: 14,&lt;br /&gt;
		&#039;Highpass Hold&#039;: 15,&lt;br /&gt;
		&#039;The Lesser Faydark&#039;: 15,&lt;br /&gt;
		&#039;The Northern Plains of Karana&#039;: 15,&lt;br /&gt;
		&#039;Lake Rathetear&#039;: 17,&lt;br /&gt;
		&#039;The Rathe Mountains&#039;: 17,&lt;br /&gt;
		&#039;Najena&#039;: 19,&lt;br /&gt;
		&#039;The Liberated Citadel of Runnyeye&#039;: 19,&lt;br /&gt;
		&#039;The City of Guk&#039;: 20,&lt;br /&gt;
		&#039;The Southern Plains of Karana&#039;: 21,&lt;br /&gt;
		&#039;The Stonebrunt Mountains&#039;: 21,&lt;br /&gt;
		&#039;The Ocean of Tears&#039;: 22,&lt;br /&gt;
		&#039;The Estate of Unrest&#039;: 23,&lt;br /&gt;
		&#039;High Keep&#039;: 25,&lt;br /&gt;
		&#039;Solusek\&#039;s Eye&#039;: 25,&lt;br /&gt;
		&#039;Temple of Cazic-Thule&#039;: 26,&lt;br /&gt;
		&#039;Permafrost Keep&#039;: 26,&lt;br /&gt;
		&#039;Kithicor Forest&#039;: 30,&lt;br /&gt;
		&#039;The Castle of Mistmoore&#039;: 31,&lt;br /&gt;
		&#039;The Lair of the Splitpaw&#039;: 31,&lt;br /&gt;
		&#039;The Temple of Solusek Ro&#039;: 35,&lt;br /&gt;
		&#039;The Ruins of Old Guk&#039;: 36,&lt;br /&gt;
		&#039;Kedge Keep&#039;: 40,&lt;br /&gt;
		&#039;Nagafen\&#039;s Lair&#039;: 40,&lt;br /&gt;
		&#039;The Ruins of Old Paineel&#039;: 45,&lt;br /&gt;
		&#039;The Plane of Fear&#039;: 50,&lt;br /&gt;
		&#039;The Plane of Hate&#039;: 52,&lt;br /&gt;
		&#039;The Plane of Sky&#039;: 54&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Wiki zone names that don&#039;t normalize to a ZONE_AVG_LEVEL key by prefix-stripping&lt;br /&gt;
	// alone. Extend as item-&amp;gt;zone enrichment surfaces more mismatches.&lt;br /&gt;
	var ZONE_ALIASES = {&lt;br /&gt;
		&#039;Lower Guk&#039;: &#039;The Ruins of Old Guk&#039;,&lt;br /&gt;
		&#039;Upper Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Paineel&#039;: &#039;The Ruins of Old Paineel&#039;,&lt;br /&gt;
		&#039;Runnyeye&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Runnyeye Citadel&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Castle Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Splitpaw&#039;: &#039;The Lair of the Splitpaw&#039;,&lt;br /&gt;
		&#039;Unrest&#039;: &#039;The Estate of Unrest&#039;,&lt;br /&gt;
		&#039;Cazic Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Cazic-Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Oasis of Marr&#039;: &#039;The Oasis of Marr&#039;,&lt;br /&gt;
		&#039;Solusek A&#039;: &#039;Solusek\&#039;s Eye&#039;,&lt;br /&gt;
		&#039;Solusek B&#039;: &#039;Nagafen\&#039;s Lair&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	function zoneKey( name ) {&lt;br /&gt;
		return String( name || &#039;&#039; )&lt;br /&gt;
			.toLowerCase()&lt;br /&gt;
			.replace( /^the\s+/, &#039;&#039; )&lt;br /&gt;
			.replace( /[^a-z0-9]+/g, &#039; &#039; )&lt;br /&gt;
			.trim();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ZONE_INDEX = ( function () {&lt;br /&gt;
		var idx = {};&lt;br /&gt;
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {&lt;br /&gt;
			idx[ zoneKey( name ) ] = name;&lt;br /&gt;
		} );&lt;br /&gt;
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {&lt;br /&gt;
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];&lt;br /&gt;
		} );&lt;br /&gt;
		return idx;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	// Canonical ZONE_AVG_LEVEL name for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function canonicalZone( name ) {&lt;br /&gt;
		if ( !name ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return ZONE_INDEX[ zoneKey( name ) ] || null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Average mob level for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function zoneAverageLevel( name ) {&lt;br /&gt;
		var canonical = canonicalZone( name );&lt;br /&gt;
		return canonical ? ZONE_AVG_LEVEL[ canonical ] : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function zoneDisplayNames() {&lt;br /&gt;
		return Object.keys( ZONE_AVG_LEVEL ).sort();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Score component checkboxes. `sv` marks the mutually-exclusive save group.&lt;br /&gt;
	var SCORE_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Weapon Ratio (dmg/delay)&#039; },&lt;br /&gt;
		{ key: &#039;dmg&#039;, label: &#039;Weapon Damage&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Weapon Delay&#039; },&lt;br /&gt;
		{ key: &#039;ac&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;str&#039;, label: &#039;Strength&#039; },&lt;br /&gt;
		{ key: &#039;dex&#039;, label: &#039;Dexterity&#039; },&lt;br /&gt;
		{ key: &#039;agi&#039;, label: &#039;Agility&#039; },&lt;br /&gt;
		{ key: &#039;sta&#039;, label: &#039;Stamina&#039; },&lt;br /&gt;
		{ key: &#039;int&#039;, label: &#039;Intelligence&#039; },&lt;br /&gt;
		{ key: &#039;wis&#039;, label: &#039;Wisdom&#039; },&lt;br /&gt;
		{ key: &#039;cha&#039;, label: &#039;Charisma&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var SV_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;svMagic&#039;, label: &#039;SV Magic&#039; },&lt;br /&gt;
		{ key: &#039;svFire&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;svCold&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;svPoison&#039;, label: &#039;SV Poison&#039; },&lt;br /&gt;
		{ key: &#039;svDisease&#039;, label: &#039;SV Disease&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var EFFECT_TYPES = [ &#039;focus&#039;, &#039;worn&#039;, &#039;click&#039;, &#039;combat&#039; ];&lt;br /&gt;
&lt;br /&gt;
	// Result columns. `enrich:true` = populated by the (future) enrichment phase.&lt;br /&gt;
	var COLUMNS = [&lt;br /&gt;
		{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
		{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
		{ key: &#039;slot&#039;, label: &#039;Slot&#039; },&lt;br /&gt;
		{ key: &#039;type&#039;, label: &#039;Type&#039; },&lt;br /&gt;
		{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;worn&#039;, label: &#039;Worn&#039; },&lt;br /&gt;
		{ key: &#039;click&#039;, label: &#039;Click&#039; },&lt;br /&gt;
		{ key: &#039;combat&#039;, label: &#039;Combat&#039; },&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Ratio&#039; },&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Delay&#039; },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039; },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039; },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039; },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039; },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039; },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039; },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039; },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039; },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039; },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; },&lt;br /&gt;
		{ key: &#039;source&#039;, label: &#039;Source&#039; },&lt;br /&gt;
		{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		SCORE_COMPONENTS.concat( SV_COMPONENTS ).forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
		} );&lt;br /&gt;
		comps.totalSV = false;&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: 1,&lt;br /&gt;
			maxLevel: 100,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },&lt;br /&gt;
			columns: cols,&lt;br /&gt;
			columnOrder: COLUMNS.map( function ( c ) { return c.key; } )&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Parse one ClassSlotEquip HTML fragment into item records.&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column (3rd, index 2).&lt;br /&gt;
			var $src = $cells.eq( 2 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; : &#039;drop&#039;;&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				// enrichment placeholders. available: true|false|null (null = unknown)&lt;br /&gt;
				enrich: { focus: null, level: null, zone: null, available: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- enrichment: item -&amp;gt; zone + availability (Phase 1, via categories) ----&lt;br /&gt;
&lt;br /&gt;
	function titleFromHref( href, name ) {&lt;br /&gt;
		if ( href ) {&lt;br /&gt;
			var t = href.replace( /^https?:\/\/[^/]+/, &#039;&#039; ).replace( /^\/(wiki\/)?/, &#039;&#039; ).split( &#039;#&#039; )[ 0 ];&lt;br /&gt;
			try { t = decodeURIComponent( t ); } catch ( e ) {}&lt;br /&gt;
			if ( t ) { return t.replace( /_/g, &#039; &#039; ); }&lt;br /&gt;
		}&lt;br /&gt;
		return name || &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function resolveFinalTitle( title, alias ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		while ( alias[ title ] &amp;amp;&amp;amp; !seen[ title ] ) {&lt;br /&gt;
			seen[ title ] = 1;&lt;br /&gt;
			title = alias[ title ];&lt;br /&gt;
		}&lt;br /&gt;
		return title;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Batched prop=categories with redirect/normalize + continuation handling.&lt;br /&gt;
	// Resolves to { cats: {finalTitle:[categoryName,...]}, alias: {from:to} }.&lt;br /&gt;
	function fetchCategories( titles ) {&lt;br /&gt;
		var result = {};&lt;br /&gt;
		var alias = {};&lt;br /&gt;
		var batches = [];&lt;br /&gt;
		var i;&lt;br /&gt;
		for ( i = 0; i &amp;lt; titles.length; i += 50 ) {&lt;br /&gt;
			batches.push( titles.slice( i, i + 50 ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return mw.loader.using( [ &#039;mediawiki.api&#039; ] ).then( function () {&lt;br /&gt;
			var api = new mw.Api();&lt;br /&gt;
&lt;br /&gt;
			function runBatch( batch, cont ) {&lt;br /&gt;
				var params = {&lt;br /&gt;
					action: &#039;query&#039;,&lt;br /&gt;
					prop: &#039;categories&#039;,&lt;br /&gt;
					titles: batch.join( &#039;|&#039; ),&lt;br /&gt;
					cllimit: &#039;max&#039;,&lt;br /&gt;
					redirects: 1,&lt;br /&gt;
					format: &#039;json&#039;&lt;br /&gt;
				};&lt;br /&gt;
				if ( cont ) {&lt;br /&gt;
					params.clcontinue = cont;&lt;br /&gt;
				}&lt;br /&gt;
				return api.get( params ).then( function ( data ) {&lt;br /&gt;
					var q = data.query || {};&lt;br /&gt;
					( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );&lt;br /&gt;
					( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );&lt;br /&gt;
					var pages = q.pages || {};&lt;br /&gt;
					Object.keys( pages ).forEach( function ( pid ) {&lt;br /&gt;
						var pg = pages[ pid ];&lt;br /&gt;
						if ( !pg.title ) { return; }&lt;br /&gt;
						var cats = ( pg.categories || [] ).map( function ( c ) {&lt;br /&gt;
							return c.title.replace( /^Category:/, &#039;&#039; );&lt;br /&gt;
						} );&lt;br /&gt;
						result[ pg.title ] = ( result[ pg.title ] || [] ).concat( cats );&lt;br /&gt;
					} );&lt;br /&gt;
					if ( data.continue &amp;amp;&amp;amp; data.continue.clcontinue ) {&lt;br /&gt;
						return runBatch( batch, data.continue.clcontinue );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );&lt;br /&gt;
		} ).then( function () {&lt;br /&gt;
			return { cats: result, alias: alias };&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Set item.enrich.zone / .available from each item&#039;s categories.&lt;br /&gt;
	// Availability = &amp;quot;drops in a zone that is in ZONE_AVG_LEVEL&amp;quot; (Requirement #1).&lt;br /&gt;
	// Quest items carry no zone category (zone is on the quest page) — kept as&lt;br /&gt;
	// available:null pending Phase 2 quest resolution rather than excluded.&lt;br /&gt;
	function enrichZones( items ) {&lt;br /&gt;
		var titles = [];&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		items.forEach( function ( it ) {&lt;br /&gt;
			it.title = titleFromHref( it.href, it.name );&lt;br /&gt;
			if ( it.title &amp;amp;&amp;amp; !seen[ it.title ] ) {&lt;br /&gt;
				seen[ it.title ] = 1;&lt;br /&gt;
				titles.push( it.title );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( !titles.length ) {&lt;br /&gt;
			return $.Deferred().resolve( items ).promise();&lt;br /&gt;
		}&lt;br /&gt;
		return fetchCategories( titles ).then( function ( res ) {&lt;br /&gt;
			items.forEach( function ( it ) {&lt;br /&gt;
				var cats = res.cats[ resolveFinalTitle( it.title, res.alias ) ] ||&lt;br /&gt;
					res.cats[ it.title ] || [];&lt;br /&gt;
				var zones = [];&lt;br /&gt;
				cats.forEach( function ( cat ) {&lt;br /&gt;
					var cz = canonicalZone( cat );&lt;br /&gt;
					if ( cz &amp;amp;&amp;amp; zones.indexOf( cz ) === -1 ) {&lt;br /&gt;
						zones.push( cz );&lt;br /&gt;
					}&lt;br /&gt;
				} );&lt;br /&gt;
				var isQuest = it.source.type === &#039;quest&#039; ||&lt;br /&gt;
					cats.indexOf( &#039;Quest Items&#039; ) !== -1;&lt;br /&gt;
&lt;br /&gt;
				if ( zones.length === 1 ) {&lt;br /&gt;
					it.enrich.zone = zones[ 0 ];&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( zones.length &amp;gt; 1 ) {&lt;br /&gt;
					it.enrich.zone = &#039;multiple&#039;;&lt;br /&gt;
					it.enrich.available = true;&lt;br /&gt;
				} else if ( isQuest ) {&lt;br /&gt;
					it.enrich.zone = &#039;quest&#039;;   // Phase 2 resolves the actual start zone&lt;br /&gt;
					it.enrich.available = null; // unknown; keep for now&lt;br /&gt;
				} else {&lt;br /&gt;
					it.enrich.zone = null;&lt;br /&gt;
					it.enrich.available = false; // no in-scope zone -&amp;gt; out of scope&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
			return items;&lt;br /&gt;
		}, function () {&lt;br /&gt;
			return items; // enrichment failed -&amp;gt; keep everything, unfiltered&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		/**&lt;br /&gt;
		 * fetchItems(query) -&amp;gt; Promise&amp;lt;Array&amp;lt;itemRecord&amp;gt;&amp;gt;&lt;br /&gt;
		 * query: { classes:[a,b,c], slots:[...] }&lt;br /&gt;
		 * Fans out one request per slot, merges, de-dupes by name+slot,&lt;br /&gt;
		 * then enriches each item with zone + availability.&lt;br /&gt;
		 */&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} ).then( enrichZones );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Availability (Requirement #1): drop into a currently-available zone.&lt;br /&gt;
		// available===false means &amp;quot;has drop zone(s), none currently available&amp;quot; -&amp;gt; hide.&lt;br /&gt;
		// null (unknown, e.g. unresolved quest items) is kept.&lt;br /&gt;
		if ( item.enrich.available === false ) {&lt;br /&gt;
			return false;&lt;br /&gt;
		}&lt;br /&gt;
		// Weapon type&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Level range (enrichment-dependent). In v1, item.enrich.level is null;&lt;br /&gt;
		// only filter when we actually have a level so we don&#039;t hide everything.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( lvl != null ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name&lt;br /&gt;
		// variants (&amp;quot;The …&amp;quot;, Guk aliases) line up with the ZONE_AVG_LEVEL keys.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			var itemZone = canonicalZone( item.enrich.zone );&lt;br /&gt;
			var matched = item.enrich.zone === &#039;multiple&#039; || state.zones.some( function ( sel ) {&lt;br /&gt;
				return canonicalZone( sel ) === itemZone;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !matched ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scoreOpts( state ) {&lt;br /&gt;
		return {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;delay&#039; ) {&lt;br /&gt;
			return item.fields.delay || &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable stat key (DMG / AC / STR / SV_*)&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var names = item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.map( function ( e ) { return e.name; } );&lt;br /&gt;
		return names.join( &#039;, &#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function columnByKey( key ) {&lt;br /&gt;
		for ( var i = 0; i &amp;lt; COLUMNS.length; i++ ) {&lt;br /&gt;
			if ( COLUMNS[ i ].key === key ) {&lt;br /&gt;
				return COLUMNS[ i ];&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Reconcile a saved columnOrder with the current COLUMNS set: drop unknown/dupe&lt;br /&gt;
	// keys, append any columns missing from the saved order.&lt;br /&gt;
	function normalizeColumnOrder( state ) {&lt;br /&gt;
		var seen = {};&lt;br /&gt;
		var order = ( state.columnOrder || [] ).filter( function ( k ) {&lt;br /&gt;
			var ok = columnByKey( k ) &amp;amp;&amp;amp; !seen[ k ];&lt;br /&gt;
			seen[ k ] = true;&lt;br /&gt;
			return ok;&lt;br /&gt;
		} );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( !seen[ c.key ] ) {&lt;br /&gt;
				order.push( c.key );&lt;br /&gt;
				seen[ c.key ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		state.columnOrder = order;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = state.columnOrder&lt;br /&gt;
			.map( columnByKey )&lt;br /&gt;
			.filter( function ( c ) {&lt;br /&gt;
				return c &amp;amp;&amp;amp; ( c.always || state.columns[ c.key ] );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		// score + filter, then sort by score desc for a useful default&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;:&lt;br /&gt;
						$td.text( it.enrich.focus || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.text( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						var avg = zoneAverageLevel( it.enrich.zone );&lt;br /&gt;
						$td.text( avg == null ? &#039;—&#039; : avg );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Out-of-era drops are filtered out; quest items and the Level / Focus &#039; +&lt;br /&gt;
			&#039;columns are still being wired up.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
&lt;br /&gt;
		// Upgrade level slider&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$controls.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		// Classes&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$controls.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		// Slots&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$controls.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		// Weapon types&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$controls.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// Zones (currently-available list from ZONE_AVG_LEVEL)&lt;br /&gt;
		var $zoneWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$zoneWrap.append( el( &#039;label&#039;, null, &#039;Zone: &#039; ) );&lt;br /&gt;
		$zoneWrap.append( multiSelect( &#039;eqlis-zones&#039;, zoneDisplayNames(), state.zones ) );&lt;br /&gt;
		$controls.append( $zoneWrap );&lt;br /&gt;
&lt;br /&gt;
		// Level range&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$controls.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		// Score components&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score components&#039; ) );&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label ) );&lt;br /&gt;
			$score.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		// Total SV toggle&lt;br /&gt;
		var $totalLab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
		var $totalCb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: &#039;totalSV&#039; } );&lt;br /&gt;
		if ( state.components.totalSV ) { $totalCb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
		$totalLab.append( $totalCb ).append( document.createTextNode( &#039; Total SV (sum of saves)&#039; ) );&lt;br /&gt;
		$score.append( $totalLab );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check eqlis-sv&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label ) );&lt;br /&gt;
			$score.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		// disable individual saves when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		// Effects bonus&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$controls.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// Column show/hide + reorder. Changes apply on the next List Results (no live&lt;br /&gt;
		// re-render), consistent with the rest of the panel.&lt;br /&gt;
		normalizeColumnOrder( state );&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns (▲▼ to reorder)&#039; ) );&lt;br /&gt;
		var $colList = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-collist&#039; } );&lt;br /&gt;
		$cols.append( $colList );&lt;br /&gt;
		$controls.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		function moveColumn( idx, dir ) {&lt;br /&gt;
			var j = idx + dir;&lt;br /&gt;
			if ( j &amp;lt; 0 || j &amp;gt;= state.columnOrder.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var tmp = state.columnOrder[ idx ];&lt;br /&gt;
			state.columnOrder[ idx ] = state.columnOrder[ j ];&lt;br /&gt;
			state.columnOrder[ j ] = tmp;&lt;br /&gt;
			saveState( state );&lt;br /&gt;
			renderColumnList();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		function renderColumnList() {&lt;br /&gt;
			$colList.empty();&lt;br /&gt;
			state.columnOrder.forEach( function ( key, idx ) {&lt;br /&gt;
				var c = columnByKey( key );&lt;br /&gt;
				if ( !c ) { return; }&lt;br /&gt;
				var $row = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-colrow&#039; } );&lt;br /&gt;
				var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-col&#039;: c.key } );&lt;br /&gt;
				if ( c.always ) {&lt;br /&gt;
					$cb.prop( &#039;checked&#039;, true ).prop( &#039;disabled&#039;, true );&lt;br /&gt;
				} else if ( state.columns[ c.key ] ) {&lt;br /&gt;
					$cb.prop( &#039;checked&#039;, true );&lt;br /&gt;
				}&lt;br /&gt;
				var $up = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-colmove&#039;, title: &#039;Move up&#039; }, &#039;▲&#039; );&lt;br /&gt;
				var $down = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-colmove&#039;, title: &#039;Move down&#039; }, &#039;▼&#039; );&lt;br /&gt;
				$up.on( &#039;click&#039;, function () { moveColumn( idx, -1 ); } );&lt;br /&gt;
				$down.on( &#039;click&#039;, function () { moveColumn( idx, 1 ); } );&lt;br /&gt;
				$row&lt;br /&gt;
					.append( $cb )&lt;br /&gt;
					.append( document.createTextNode( &#039; &#039; + c.label + ( c.enrich ? &#039; *&#039; : &#039;&#039; ) + &#039; &#039; ) )&lt;br /&gt;
					.append( $up ).append( $down );&lt;br /&gt;
				$colList.append( $row );&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		renderColumnList();&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		// List Results button + status + results container&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		// ---- read UI back into state ----&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.zones = readMulti( $( &#039;#eqlis-zones&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			$cols.find( &#039;input[data-col]&#039; ).each( function () {&lt;br /&gt;
				state.columns[ $( this ).attr( &#039;data-col&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// NOTE: intentionally no live re-render on control changes. Per spec, the&lt;br /&gt;
		// table only (re)builds when List Results is pressed — moving the upgrade&lt;br /&gt;
		// slider or toggling score/columns changes persisted state but does not&lt;br /&gt;
		// touch the results until the next List Results click.&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/testItemTable&amp;diff=160865</id>
		<title>User:Chonx/testItemTable</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/testItemTable&amp;diff=160865"/>
		<updated>2026-07-23T23:23:07Z</updated>

		<summary type="html">&lt;p&gt;Chonx: Created page with &amp;quot;&amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160864</id>
		<title>User:Chonx/common.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160864"/>
		<updated>2026-07-23T23:22:35Z</updated>

		<summary type="html">&lt;p&gt;Chonx: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/**&lt;br /&gt;
 * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.&lt;br /&gt;
 *&lt;br /&gt;
 * Single self-contained module (same shape as ext.itemLevelSlider /&lt;br /&gt;
 * ext.classSlotEquip). Paste into User:&amp;lt;you&amp;gt;/common.js, then create a page&lt;br /&gt;
 * containing:  &amp;lt;div id=&amp;quot;eql-item-score-table&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
 *&lt;br /&gt;
 * Data access goes through ACTIVE_DATASOURCE so a future server-side&lt;br /&gt;
 * Special:ItemScoreTable can replace ClientDataSource with a one-line change.&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ---- styles (injected; common.js has no separate CSS channel) ----&lt;br /&gt;
	( function () {&lt;br /&gt;
		var s = document.createElement( &#039;style&#039; );&lt;br /&gt;
		s.setAttribute( &#039;data-eqlis&#039;, &#039;1&#039; );&lt;br /&gt;
		s.textContent = &amp;quot;/* EQL Item Score Table \u2014 styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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; gap: 0.9rem 1.4rem; margin-bottom: 0.75rem; }\n.eqlis-field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }\n.eqlis-field &amp;gt; 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: inline-flex; align-items: center; gap: 0.25rem; margin: 0.1rem 0.6rem 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&amp;quot;;&lt;br /&gt;
		( document.head || document.documentElement ).appendChild( s );&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = 10;&lt;br /&gt;
&lt;br /&gt;
	// Stats that scale via the &amp;quot;primary spreadsheet&amp;quot; rule (incl. saves).&lt;br /&gt;
	var PRIMARY_STATS = [&lt;br /&gt;
		&#039;AC&#039;, &#039;HP&#039;, &#039;MP&#039;, &#039;END&#039;,&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;WIS&#039;, &#039;INT&#039;, &#039;CHA&#039;,&lt;br /&gt;
		&#039;SV_MAGIC&#039;, &#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Stats that, when &amp;gt;=2 are present, grant SV VOID: +level.&lt;br /&gt;
	var SV_VOID_QUALIFIERS = [&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;INT&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;CHA&#039;, &#039;WIS&#039;,&lt;br /&gt;
		&#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_MAGIC&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// All stat labels we recognize in a stat block, longest-first for regex.&lt;br /&gt;
	var STAT_ALIASES = {&lt;br /&gt;
		&#039;DAMAGE&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;DMG&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;MANA&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;MP&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;ENDUR&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;END&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;SV MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;SV FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;SV COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;SV POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;SV DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;HP REGEN&#039;: &#039;HP_REGEN&#039;,&lt;br /&gt;
		&#039;WEIGHT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;WT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;AC&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;ARMOR CLASS&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;HP&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;HIT POINTS&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;STR&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STRENGTH&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;STAMINA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;AGI&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;AGILITY&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;DEX&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;DEXTERITY&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;WIS&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;WISDOM&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;INT&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;INTELLIGENCE&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;CHA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;CHARISMA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;HASTE&#039;: &#039;HASTE&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas (ported from ext.itemLevelSlider.js) --------------&lt;br /&gt;
	// state here is just an integer effective level (0..10).&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Parse a stat block (plain text; callers should strip HTML first).&lt;br /&gt;
	 * Returns a normalized item fields object. focus_effect is NOT present in&lt;br /&gt;
	 * stat blocks — it is set later by the data-source enrichment step.&lt;br /&gt;
	 */&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			delay: 0,&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
			item.delay = parseFloat( skillMatch[ 2 ] );&lt;br /&gt;
		} else {&lt;br /&gt;
			var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
			if ( delayOnly ) {&lt;br /&gt;
				item.delay = parseFloat( delayOnly[ 1 ] );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Stats: for each known label, capture &amp;quot;LABEL: [+-]?num&amp;quot;&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			// DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Default score component weights (all 1). A component contributes its&lt;br /&gt;
	// scaled stat value when enabled.&lt;br /&gt;
	function defaultComponents() {&lt;br /&gt;
		return {&lt;br /&gt;
			ratio: false, dmg: false, delay: false,&lt;br /&gt;
			ac: false, str: false, sta: false, agi: false, dex: false,&lt;br /&gt;
			int: false, wis: false, cha: false,&lt;br /&gt;
			totalSV: false,&lt;br /&gt;
			svMagic: false, svFire: false, svCold: false,&lt;br /&gt;
			svPoison: false, svDisease: false, svVoid: false&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Compute the scaled value of one stat key at a given level.&lt;br /&gt;
	 */&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;DMG&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * scoreItem(item, opts)&lt;br /&gt;
	 *   opts.level        integer 0..10&lt;br /&gt;
	 *   opts.components    map of enabled score components (see defaultComponents)&lt;br /&gt;
	 *   opts.effectBonus  { focus, worn, click, combat } flat points per present effect&lt;br /&gt;
	 */&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || defaultComponents();&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		function add( name, value ) {&lt;br /&gt;
			if ( value ) {&lt;br /&gt;
				breakdown[ name ] = value;&lt;br /&gt;
				score += value;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var svKeys = {&lt;br /&gt;
			svMagic: &#039;SV_MAGIC&#039;, svFire: &#039;SV_FIRE&#039;, svCold: &#039;SV_COLD&#039;,&lt;br /&gt;
			svPoison: &#039;SV_POISON&#039;, svDisease: &#039;SV_DISEASE&#039;&lt;br /&gt;
		};&lt;br /&gt;
		var simpleKeys = {&lt;br /&gt;
			ac: &#039;AC&#039;, str: &#039;STR&#039;, sta: &#039;STA&#039;, agi: &#039;AGI&#039;, dex: &#039;DEX&#039;,&lt;br /&gt;
			int: &#039;INT&#039;, wis: &#039;WIS&#039;, cha: &#039;CHA&#039;&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var scaledDmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.ratio &amp;amp;&amp;amp; item.delay &amp;gt; 0 &amp;amp;&amp;amp; scaledDmg &amp;gt; 0 ) {&lt;br /&gt;
			// Ratio score = (damage / delay) * 10  (damage/delay per user; *10 to scale up)&lt;br /&gt;
			add( &#039;ratio&#039;, ( scaledDmg / item.delay ) * 10 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.dmg ) {&lt;br /&gt;
			add( &#039;dmg&#039;, scaledDmg );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.delay &amp;amp;&amp;amp; item.delay &amp;gt; 0 ) {&lt;br /&gt;
			// Lower delay is better; contribute inverse so faster weapons score higher.&lt;br /&gt;
			add( &#039;delay&#039;, 100 / item.delay );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		Object.keys( simpleKeys ).forEach( function ( ck ) {&lt;br /&gt;
			if ( comp[ ck ] ) {&lt;br /&gt;
				add( ck, scaledStat( item, simpleKeys[ ck ], level ) );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.totalSV ) {&lt;br /&gt;
			var svTotal = 0;&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				svTotal += scaledStat( item, svKeys[ sk ], level );&lt;br /&gt;
			} );&lt;br /&gt;
			add( &#039;totalSV&#039;, svTotal );&lt;br /&gt;
		} else {&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				if ( comp[ sk ] ) {&lt;br /&gt;
					add( sk, scaledStat( item, svKeys[ sk ], level ) );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		if ( comp.svVoid ) {&lt;br /&gt;
			add( &#039;svVoid&#039;, svVoidBonus( item, level ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effect flat bonuses (per present effect type).&lt;br /&gt;
		[ &#039;worn&#039;, &#039;combat&#039;, &#039;click&#039; ].forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				add( &#039;effect_&#039; + t, effectBonus[ t ] );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( item.effectTypes.focus &amp;amp;&amp;amp; effectBonus.focus ) {&lt;br /&gt;
			add( &#039;effect_focus&#039;, effectBonus.focus );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * 1000 ) / 1000,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		if ( !item.delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / item.delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		defaultComponents: defaultComponents,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// ===================== app layer (UI / data / mount) =====================&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Currently-available zones + average mob level, exactly as given in ZoneLevels.md&lt;br /&gt;
	// (name -&amp;gt; average mob level). STATIC data (eqltools-derived). This doubles as the&lt;br /&gt;
	// availability whitelist (an item is in-scope if its drop zone is here), the Zone&lt;br /&gt;
	// filter options, and the &amp;quot;Zone Avg Lvl&amp;quot; column source.&lt;br /&gt;
	// REVISIT: swap to a live wiki-sourced pull once that data path is settled.&lt;br /&gt;
	var ZONE_AVG_LEVEL = {&lt;br /&gt;
		&#039;The Feerrott&#039;: 4,&lt;br /&gt;
		&#039;Toxxulia Forest&#039;: 4,&lt;br /&gt;
		&#039;Misty Thicket&#039;: 5,&lt;br /&gt;
		&#039;Qeynos Hills&#039;: 6,&lt;br /&gt;
		&#039;The Qeynos Aqueduct System&#039;: 6,&lt;br /&gt;
		&#039;Innothule Swamp&#039;: 6,&lt;br /&gt;
		&#039;The Steamfont Mountains&#039;: 6,&lt;br /&gt;
		&#039;The Greater Faydark&#039;: 7,&lt;br /&gt;
		&#039;Butcherblock Mountains&#039;: 7,&lt;br /&gt;
		&#039;Nektulos Forest&#039;: 7,&lt;br /&gt;
		&#039;East Commonlands&#039;: 8,&lt;br /&gt;
		&#039;Everfrost Peaks&#039;: 8,&lt;br /&gt;
		&#039;The Northern Desert of Ro&#039;: 9,&lt;br /&gt;
		&#039;Blackburrow&#039;: 10,&lt;br /&gt;
		&#039;The Southern Desert of Ro&#039;: 10,&lt;br /&gt;
		&#039;West Commonlands&#039;: 10,&lt;br /&gt;
		&#039;The Western Plains of Karana&#039;: 10,&lt;br /&gt;
		&#039;Erud\&#039;s Crossing&#039;: 11,&lt;br /&gt;
		&#039;The Warrens&#039;: 11,&lt;br /&gt;
		&#039;Clan Crushbone&#039;: 12,&lt;br /&gt;
		&#039;Befallen&#039;: 13,&lt;br /&gt;
		&#039;The Lavastorm Mountains&#039;: 13,&lt;br /&gt;
		&#039;Dagnor\&#039;s Cauldron&#039;: 13,&lt;br /&gt;
		&#039;The Gorge of King Xorbb&#039;: 14,&lt;br /&gt;
		&#039;The Oasis of Marr&#039;: 14,&lt;br /&gt;
		&#039;The Eastern Plains of Karana&#039;: 14,&lt;br /&gt;
		&#039;Highpass Hold&#039;: 15,&lt;br /&gt;
		&#039;The Lesser Faydark&#039;: 15,&lt;br /&gt;
		&#039;The Northern Plains of Karana&#039;: 15,&lt;br /&gt;
		&#039;Lake Rathetear&#039;: 17,&lt;br /&gt;
		&#039;The Rathe Mountains&#039;: 17,&lt;br /&gt;
		&#039;Najena&#039;: 19,&lt;br /&gt;
		&#039;The Liberated Citadel of Runnyeye&#039;: 19,&lt;br /&gt;
		&#039;The City of Guk&#039;: 20,&lt;br /&gt;
		&#039;The Southern Plains of Karana&#039;: 21,&lt;br /&gt;
		&#039;The Stonebrunt Mountains&#039;: 21,&lt;br /&gt;
		&#039;The Ocean of Tears&#039;: 22,&lt;br /&gt;
		&#039;The Estate of Unrest&#039;: 23,&lt;br /&gt;
		&#039;High Keep&#039;: 25,&lt;br /&gt;
		&#039;Solusek\&#039;s Eye&#039;: 25,&lt;br /&gt;
		&#039;Temple of Cazic-Thule&#039;: 26,&lt;br /&gt;
		&#039;Permafrost Keep&#039;: 26,&lt;br /&gt;
		&#039;Kithicor Forest&#039;: 30,&lt;br /&gt;
		&#039;The Castle of Mistmoore&#039;: 31,&lt;br /&gt;
		&#039;The Lair of the Splitpaw&#039;: 31,&lt;br /&gt;
		&#039;The Temple of Solusek Ro&#039;: 35,&lt;br /&gt;
		&#039;The Ruins of Old Guk&#039;: 36,&lt;br /&gt;
		&#039;Kedge Keep&#039;: 40,&lt;br /&gt;
		&#039;Nagafen\&#039;s Lair&#039;: 40,&lt;br /&gt;
		&#039;The Ruins of Old Paineel&#039;: 45,&lt;br /&gt;
		&#039;The Plane of Fear&#039;: 50,&lt;br /&gt;
		&#039;The Plane of Hate&#039;: 52,&lt;br /&gt;
		&#039;The Plane of Sky&#039;: 54&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Wiki zone names that don&#039;t normalize to a ZONE_AVG_LEVEL key by prefix-stripping&lt;br /&gt;
	// alone. Extend as item-&amp;gt;zone enrichment surfaces more mismatches.&lt;br /&gt;
	var ZONE_ALIASES = {&lt;br /&gt;
		&#039;Lower Guk&#039;: &#039;The Ruins of Old Guk&#039;,&lt;br /&gt;
		&#039;Upper Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Guk&#039;: &#039;The City of Guk&#039;,&lt;br /&gt;
		&#039;Paineel&#039;: &#039;The Ruins of Old Paineel&#039;,&lt;br /&gt;
		&#039;Runnyeye&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Runnyeye Citadel&#039;: &#039;The Liberated Citadel of Runnyeye&#039;,&lt;br /&gt;
		&#039;Castle Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Mistmoore&#039;: &#039;The Castle of Mistmoore&#039;,&lt;br /&gt;
		&#039;Splitpaw&#039;: &#039;The Lair of the Splitpaw&#039;,&lt;br /&gt;
		&#039;Unrest&#039;: &#039;The Estate of Unrest&#039;,&lt;br /&gt;
		&#039;Cazic Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Cazic-Thule&#039;: &#039;Temple of Cazic-Thule&#039;,&lt;br /&gt;
		&#039;Oasis of Marr&#039;: &#039;The Oasis of Marr&#039;,&lt;br /&gt;
		&#039;Solusek A&#039;: &#039;Solusek\&#039;s Eye&#039;,&lt;br /&gt;
		&#039;Solusek B&#039;: &#039;Nagafen\&#039;s Lair&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	function zoneKey( name ) {&lt;br /&gt;
		return String( name || &#039;&#039; )&lt;br /&gt;
			.toLowerCase()&lt;br /&gt;
			.replace( /^the\s+/, &#039;&#039; )&lt;br /&gt;
			.replace( /[^a-z0-9]+/g, &#039; &#039; )&lt;br /&gt;
			.trim();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ZONE_INDEX = ( function () {&lt;br /&gt;
		var idx = {};&lt;br /&gt;
		Object.keys( ZONE_AVG_LEVEL ).forEach( function ( name ) {&lt;br /&gt;
			idx[ zoneKey( name ) ] = name;&lt;br /&gt;
		} );&lt;br /&gt;
		Object.keys( ZONE_ALIASES ).forEach( function ( alias ) {&lt;br /&gt;
			idx[ zoneKey( alias ) ] = ZONE_ALIASES[ alias ];&lt;br /&gt;
		} );&lt;br /&gt;
		return idx;&lt;br /&gt;
	}() );&lt;br /&gt;
&lt;br /&gt;
	// Canonical ZONE_AVG_LEVEL name for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function canonicalZone( name ) {&lt;br /&gt;
		if ( !name ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return ZONE_INDEX[ zoneKey( name ) ] || null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Average mob level for any wiki zone string, or null if out of scope.&lt;br /&gt;
	function zoneAverageLevel( name ) {&lt;br /&gt;
		var canonical = canonicalZone( name );&lt;br /&gt;
		return canonical ? ZONE_AVG_LEVEL[ canonical ] : null;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function zoneDisplayNames() {&lt;br /&gt;
		return Object.keys( ZONE_AVG_LEVEL ).sort();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Score component checkboxes. `sv` marks the mutually-exclusive save group.&lt;br /&gt;
	var SCORE_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Weapon Ratio (dmg/delay)&#039; },&lt;br /&gt;
		{ key: &#039;dmg&#039;, label: &#039;Weapon Damage&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Weapon Delay (faster=better)&#039; },&lt;br /&gt;
		{ key: &#039;ac&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;str&#039;, label: &#039;Strength&#039; },&lt;br /&gt;
		{ key: &#039;dex&#039;, label: &#039;Dexterity&#039; },&lt;br /&gt;
		{ key: &#039;agi&#039;, label: &#039;Agility&#039; },&lt;br /&gt;
		{ key: &#039;sta&#039;, label: &#039;Stamina&#039; },&lt;br /&gt;
		{ key: &#039;int&#039;, label: &#039;Intelligence&#039; },&lt;br /&gt;
		{ key: &#039;wis&#039;, label: &#039;Wisdom&#039; },&lt;br /&gt;
		{ key: &#039;cha&#039;, label: &#039;Charisma&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var SV_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;svMagic&#039;, label: &#039;SV Magic&#039; },&lt;br /&gt;
		{ key: &#039;svFire&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;svCold&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;svPoison&#039;, label: &#039;SV Poison&#039; },&lt;br /&gt;
		{ key: &#039;svDisease&#039;, label: &#039;SV Disease&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var EFFECT_TYPES = [ &#039;focus&#039;, &#039;worn&#039;, &#039;click&#039;, &#039;combat&#039; ];&lt;br /&gt;
&lt;br /&gt;
	// Result columns. `enrich:true` = populated by the (future) enrichment phase.&lt;br /&gt;
	var COLUMNS = [&lt;br /&gt;
		{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
		{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
		{ key: &#039;slot&#039;, label: &#039;Slot&#039; },&lt;br /&gt;
		{ key: &#039;type&#039;, label: &#039;Type&#039; },&lt;br /&gt;
		{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;worn&#039;, label: &#039;Worn&#039; },&lt;br /&gt;
		{ key: &#039;click&#039;, label: &#039;Click&#039; },&lt;br /&gt;
		{ key: &#039;combat&#039;, label: &#039;Combat&#039; },&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Ratio&#039; },&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Delay&#039; },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039; },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039; },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039; },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039; },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039; },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039; },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039; },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039; },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039; },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; },&lt;br /&gt;
		{ key: &#039;source&#039;, label: &#039;Source&#039; },&lt;br /&gt;
		{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		SCORE_COMPONENTS.concat( SV_COMPONENTS ).forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
		} );&lt;br /&gt;
		comps.totalSV = false;&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: 1,&lt;br /&gt;
			maxLevel: 100,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },&lt;br /&gt;
			columns: cols&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Parse one ClassSlotEquip HTML fragment into item records.&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column (3rd, index 2).&lt;br /&gt;
			var $src = $cells.eq( 2 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; : &#039;drop&#039;;&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				// enrichment placeholders&lt;br /&gt;
				enrich: { focus: null, level: null, zone: null, zoneAvg: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		/**&lt;br /&gt;
		 * fetchItems(query) -&amp;gt; Promise&amp;lt;Array&amp;lt;itemRecord&amp;gt;&amp;gt;&lt;br /&gt;
		 * query: { classes:[a,b,c], slots:[...] }&lt;br /&gt;
		 * Fans out one request per slot, merges, de-dupes by name+slot.&lt;br /&gt;
		 */&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Weapon type&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Level range (enrichment-dependent). In v1, item.enrich.level is null;&lt;br /&gt;
		// only filter when we actually have a level so we don&#039;t hide everything.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( lvl != null ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — match via canonical zone name so wiki name&lt;br /&gt;
		// variants (&amp;quot;The …&amp;quot;, Guk aliases) line up with the ZONE_AVG_LEVEL keys.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			var itemZone = canonicalZone( item.enrich.zone );&lt;br /&gt;
			var matched = item.enrich.zone === &#039;multiple&#039; || state.zones.some( function ( sel ) {&lt;br /&gt;
				return canonicalZone( sel ) === itemZone;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !matched ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scoreOpts( state ) {&lt;br /&gt;
		return {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;delay&#039; ) {&lt;br /&gt;
			return item.fields.delay || &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable stat key (DMG / AC / STR / SV_*)&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var names = item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.map( function ( e ) { return e.name; } );&lt;br /&gt;
		return names.join( &#039;, &#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = COLUMNS.filter( function ( c ) {&lt;br /&gt;
			return c.always || state.columns[ c.key ];&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		// score + filter, then sort by score desc for a useful default&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;:&lt;br /&gt;
						$td.text( it.enrich.focus || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.text( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						var avg = zoneAverageLevel( it.enrich.zone );&lt;br /&gt;
						$td.text( avg == null ? &#039;—&#039; : avg );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Zone / Level / Focus columns arrive in the enrichment phase.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
&lt;br /&gt;
		// Upgrade level slider&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$controls.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		// Classes&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$controls.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		// Slots&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$controls.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		// Weapon types&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$controls.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// Zones (currently-available list from ZONE_AVG_LEVEL)&lt;br /&gt;
		var $zoneWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$zoneWrap.append( el( &#039;label&#039;, null, &#039;Zone: &#039; ) );&lt;br /&gt;
		$zoneWrap.append( multiSelect( &#039;eqlis-zones&#039;, zoneDisplayNames(), state.zones ) );&lt;br /&gt;
		$controls.append( $zoneWrap );&lt;br /&gt;
&lt;br /&gt;
		// Level range&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$controls.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		// Score components&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score components&#039; ) );&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label ) );&lt;br /&gt;
			$score.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		// Total SV toggle&lt;br /&gt;
		var $totalLab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
		var $totalCb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: &#039;totalSV&#039; } );&lt;br /&gt;
		if ( state.components.totalSV ) { $totalCb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
		$totalLab.append( $totalCb ).append( document.createTextNode( &#039; Total SV (sum of saves)&#039; ) );&lt;br /&gt;
		$score.append( $totalLab );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check eqlis-sv&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label ) );&lt;br /&gt;
			$score.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		// disable individual saves when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		// Effects bonus&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$controls.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// Column toggles&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns&#039; ) );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( c.always ) { return; }&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-col&#039;: c.key } );&lt;br /&gt;
			if ( state.columns[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label + ( c.enrich ? &#039; *&#039; : &#039;&#039; ) ) );&lt;br /&gt;
			$cols.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		$controls.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		// List Results button + status + results container&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		// ---- read UI back into state ----&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.zones = readMulti( $( &#039;#eqlis-zones&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			$cols.find( &#039;input[data-col]&#039; ).each( function () {&lt;br /&gt;
				state.columns[ $( this ).attr( &#039;data-col&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// re-render (no refetch) when scoring/columns change after a run&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () {&lt;br /&gt;
			if ( lastItems ) { render(); }&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
	<entry>
		<id>https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160863</id>
		<title>User:Chonx/common.js</title>
		<link rel="alternate" type="text/html" href="https://eqlwiki.com/index.php?title=User:Chonx/common.js&amp;diff=160863"/>
		<updated>2026-07-23T23:01:16Z</updated>

		<summary type="html">&lt;p&gt;Chonx: test spaghetti&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function(){var s=document.createElement(&#039;style&#039;);s.setAttribute(&#039;data-eqlis&#039;,&#039;1&#039;);s.textContent=&lt;br /&gt;
&amp;quot;/* EQL Item Score Table \u00e2\u20ac\u201d styles (paste into User:&amp;lt;you&amp;gt;/common.css, optional).\n   Mirrors the wiki&#039;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; gap: 0.9rem 1.4rem; margin-bottom: 0.75rem; }\n.eqlis-field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }\n.eqlis-field &amp;gt; 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: inline-flex; align-items: center; gap: 0.25rem; margin: 0.1rem 0.6rem 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&amp;quot;&lt;br /&gt;
;(document.head||document.documentElement).appendChild(s);})();&lt;br /&gt;
&lt;br /&gt;
/* ---- eqlis.core.js ---- */&lt;br /&gt;
/**&lt;br /&gt;
 * EQL Item Score — pure logic core (no jQuery, no mw, no DOM, no network).&lt;br /&gt;
 *&lt;br /&gt;
 * This file is deliberately dependency-free so it can run in three places&lt;br /&gt;
 * with identical behavior:&lt;br /&gt;
 *   1. test-logic.html  (double-click, offline, assertions)  &amp;lt;-- Tier 1 test&lt;br /&gt;
 *   2. the Tampermonkey / common.js live bundle               &amp;lt;-- Tier 2 runtime&lt;br /&gt;
 *   3. a future Special:ItemScoreTable server port (same formulas, PHP)&lt;br /&gt;
 *&lt;br /&gt;
 * It exposes a single global: window.EQLIS&lt;br /&gt;
 *&lt;br /&gt;
 * Two responsibilities:&lt;br /&gt;
 *   A. parseItemData(text)  -&amp;gt; normalized item object from a stat block&lt;br /&gt;
 *   B. scoreItem(item, opts) -&amp;gt; { score, breakdown, scaled } using the&lt;br /&gt;
 *      EXACT scaling formulas ported from ext.itemLevelSlider.js&lt;br /&gt;
 *&lt;br /&gt;
 * Upgrade level: the guide filter is an integer 0..10 (EQTable.md). We map it&lt;br /&gt;
 * to ItemLevelSlider&#039;s &amp;quot;full level&amp;quot; with fraction 0, so effectiveLevel === level.&lt;br /&gt;
 */&lt;br /&gt;
( function ( root ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	var MAX_LEVEL = 10;&lt;br /&gt;
&lt;br /&gt;
	// Stats that scale via the &amp;quot;primary spreadsheet&amp;quot; rule (incl. saves).&lt;br /&gt;
	var PRIMARY_STATS = [&lt;br /&gt;
		&#039;AC&#039;, &#039;HP&#039;, &#039;MP&#039;, &#039;END&#039;,&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;WIS&#039;, &#039;INT&#039;, &#039;CHA&#039;,&lt;br /&gt;
		&#039;SV_MAGIC&#039;, &#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Stats that, when &amp;gt;=2 are present, grant SV VOID: +level.&lt;br /&gt;
	var SV_VOID_QUALIFIERS = [&lt;br /&gt;
		&#039;STR&#039;, &#039;STA&#039;, &#039;INT&#039;, &#039;AGI&#039;, &#039;DEX&#039;, &#039;CHA&#039;, &#039;WIS&#039;,&lt;br /&gt;
		&#039;SV_FIRE&#039;, &#039;SV_COLD&#039;, &#039;SV_POISON&#039;, &#039;SV_MAGIC&#039;, &#039;SV_DISEASE&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// All stat labels we recognize in a stat block, longest-first for regex.&lt;br /&gt;
	var STAT_ALIASES = {&lt;br /&gt;
		&#039;DAMAGE&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;DMG&#039;: &#039;DMG&#039;,&lt;br /&gt;
		&#039;MANA&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;MP&#039;: &#039;MP&#039;,&lt;br /&gt;
		&#039;ENDUR&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;END&#039;: &#039;END&#039;,&lt;br /&gt;
		&#039;SV MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;SV FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;SV COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;SV POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;SV DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;MAGIC&#039;: &#039;SV_MAGIC&#039;,&lt;br /&gt;
		&#039;FIRE&#039;: &#039;SV_FIRE&#039;,&lt;br /&gt;
		&#039;COLD&#039;: &#039;SV_COLD&#039;,&lt;br /&gt;
		&#039;POISON&#039;: &#039;SV_POISON&#039;,&lt;br /&gt;
		&#039;DISEASE&#039;: &#039;SV_DISEASE&#039;,&lt;br /&gt;
		&#039;HP REGEN&#039;: &#039;HP_REGEN&#039;,&lt;br /&gt;
		&#039;WEIGHT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;WT&#039;: &#039;WT&#039;,&lt;br /&gt;
		&#039;AC&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;ARMOR CLASS&#039;: &#039;AC&#039;,&lt;br /&gt;
		&#039;HP&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;HIT POINTS&#039;: &#039;HP&#039;,&lt;br /&gt;
		&#039;STR&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STRENGTH&#039;: &#039;STR&#039;,&lt;br /&gt;
		&#039;STA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;STAMINA&#039;: &#039;STA&#039;,&lt;br /&gt;
		&#039;AGI&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;AGILITY&#039;: &#039;AGI&#039;,&lt;br /&gt;
		&#039;DEX&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;DEXTERITY&#039;: &#039;DEX&#039;,&lt;br /&gt;
		&#039;WIS&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;WISDOM&#039;: &#039;WIS&#039;,&lt;br /&gt;
		&#039;INT&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;INTELLIGENCE&#039;: &#039;INT&#039;,&lt;br /&gt;
		&#039;CHA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;CHARISMA&#039;: &#039;CHA&#039;,&lt;br /&gt;
		&#039;HASTE&#039;: &#039;HASTE&#039;&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Labels to match in the stat text (longest first so &amp;quot;SV FIRE&amp;quot; beats &amp;quot;FIRE&amp;quot;).&lt;br /&gt;
	var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {&lt;br /&gt;
		return b.length - a.length;&lt;br /&gt;
	} );&lt;br /&gt;
&lt;br /&gt;
	// ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----&lt;br /&gt;
&lt;br /&gt;
	function excelRound( value, digits ) {&lt;br /&gt;
		var factor = Math.pow( 10, digits || 0 );&lt;br /&gt;
		var scaled = value * factor;&lt;br /&gt;
		if ( value &amp;gt;= 0 ) {&lt;br /&gt;
			return Math.floor( scaled + 0.5 ) / factor;&lt;br /&gt;
		}&lt;br /&gt;
		return Math.ceil( scaled - 0.5 ) / factor;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function intFloor( value ) {&lt;br /&gt;
		return Math.floor( value );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scaling formulas (ported from ext.itemLevelSlider.js) --------------&lt;br /&gt;
	// state here is just an integer effective level (0..10).&lt;br /&gt;
&lt;br /&gt;
	function scalePrimaryStat( signedBase, level ) {&lt;br /&gt;
		if ( signedBase === 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 0 &amp;amp;&amp;amp; signedBase &amp;lt;= 10 ) {&lt;br /&gt;
			return signedBase + level;&lt;br /&gt;
		}&lt;br /&gt;
		if ( signedBase &amp;gt; 10 ) {&lt;br /&gt;
			return intFloor( signedBase + excelRound( signedBase * level / 10, 0 ) );&lt;br /&gt;
		}&lt;br /&gt;
		return Math.min( 0, signedBase + level );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scaleDamage( signedBase, level ) {&lt;br /&gt;
		if ( signedBase &amp;lt;= 0 ) {&lt;br /&gt;
			return signedBase;&lt;br /&gt;
		}&lt;br /&gt;
		return signedBase + intFloor( signedBase * level / 10 );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Parsing ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function canonicalStatKey( label ) {&lt;br /&gt;
		var name = String( label || &#039;&#039; ).toUpperCase().replace( /\s+/g, &#039; &#039; ).trim();&lt;br /&gt;
		return STAT_ALIASES[ name ] || name.replace( /\s+/g, &#039;_&#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function detectEffectType( descriptor ) {&lt;br /&gt;
		var d = String( descriptor || &#039;&#039; ).toLowerCase();&lt;br /&gt;
		if ( /\(worn/.test( d ) ) {&lt;br /&gt;
			return &#039;worn&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( /\(combat/.test( d ) ) {&lt;br /&gt;
			return &#039;combat&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// Click effects render a casting time but aren&#039;t Worn/Combat.&lt;br /&gt;
		if ( /casting time/.test( d ) ) {&lt;br /&gt;
			return &#039;click&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;click&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Parse a stat block (plain text; callers should strip HTML first).&lt;br /&gt;
	 * Returns a normalized item fields object. focus_effect is NOT present in&lt;br /&gt;
	 * stat blocks — it is set later by the data-source enrichment step.&lt;br /&gt;
	 */&lt;br /&gt;
	function parseItemData( text ) {&lt;br /&gt;
		var raw = String( text || &#039;&#039; ).replace( / /g, &#039; &#039; );&lt;br /&gt;
		var item = {&lt;br /&gt;
			slot: null,&lt;br /&gt;
			skill: null,        // e.g. &amp;quot;1H Slashing&amp;quot; (weapon skill line)&lt;br /&gt;
			type: &#039;armor&#039;,      // weapon skill, or &amp;quot;armor&amp;quot; when no Skill: line&lt;br /&gt;
			delay: 0,&lt;br /&gt;
			stats: {},          // canonical key -&amp;gt; signed base number&lt;br /&gt;
			effects: [],        // [{ type, name }]&lt;br /&gt;
			effectTypes: {},    // { worn:true, combat:true, click:true, focus:true }&lt;br /&gt;
			flags: {&lt;br /&gt;
				magic: /\bMAGIC ITEM\b/i.test( raw ),&lt;br /&gt;
				lore: /\bLORE ITEM\b/i.test( raw ),&lt;br /&gt;
				noDrop: /\bNO DROP\b/i.test( raw )&lt;br /&gt;
			}&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var slotMatch = raw.match( /Slot:\s*([A-Z0-9 ]+?)(?:\s{2,}|&amp;lt;|\bSkill\b|\bAC\b|\bCharges\b|\bAtk\b|$)/i );&lt;br /&gt;
		if ( slotMatch ) {&lt;br /&gt;
			item.slot = slotMatch[ 1 ].trim();&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
		if ( skillMatch ) {&lt;br /&gt;
			item.skill = skillMatch[ 1 ].trim();&lt;br /&gt;
			item.type = item.skill;&lt;br /&gt;
			item.delay = parseFloat( skillMatch[ 2 ] );&lt;br /&gt;
		} else {&lt;br /&gt;
			var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );&lt;br /&gt;
			if ( delayOnly ) {&lt;br /&gt;
				item.delay = parseFloat( delayOnly[ 1 ] );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effects come in two formats on the wiki:&lt;br /&gt;
		//   1. &amp;quot;Effect: &amp;lt;name&amp;gt; (&amp;lt;Worn|Combat|Casting Time...&amp;gt;)&amp;quot;  -&amp;gt; type from descriptor&lt;br /&gt;
		//   2. &amp;quot;&amp;lt;Focus|Worn|Click|Combat&amp;gt; Effect: &amp;lt;name&amp;gt;&amp;quot;        -&amp;gt; type from the word&lt;br /&gt;
		function pushEffect( type, name ) {&lt;br /&gt;
			name = String( name || &#039;&#039; ).replace( /\s*\(.*$/, &#039;&#039; ).trim();&lt;br /&gt;
			if ( !name ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var dup = item.effects.some( function ( e ) {&lt;br /&gt;
				return e.type === type &amp;amp;&amp;amp; e.name === name;&lt;br /&gt;
			} );&lt;br /&gt;
			if ( !dup ) {&lt;br /&gt;
				item.effects.push( { type: type, name: name } );&lt;br /&gt;
				item.effectTypes[ type ] = true;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var typedRe = /\b(Focus|Worn|Click|Combat)\s+Effect:\s*([^\n&amp;lt;(]+)/gi;&lt;br /&gt;
		var tm;&lt;br /&gt;
		while ( ( tm = typedRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( tm[ 1 ].toLowerCase(), tm[ 2 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var effectRe = /(?:^|[^A-Za-z])Effect:\s*([^()\n&amp;lt;]+?)\s*\(([^)]*)\)/gi;&lt;br /&gt;
		var em;&lt;br /&gt;
		while ( ( em = effectRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			pushEffect( detectEffectType( &#039;(&#039; + em[ 2 ] + &#039;)&#039; ), em[ 1 ] );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Stats: for each known label, capture &amp;quot;LABEL: [+-]?num&amp;quot;&lt;br /&gt;
		var labelAlt = STAT_LABELS.map( function ( s ) {&lt;br /&gt;
			return s.replace( /[.*+?^${}()|[\]\\]/g, &#039;\\$&amp;amp;&#039; );&lt;br /&gt;
		} ).join( &#039;|&#039; );&lt;br /&gt;
		var statRe = new RegExp( &#039;\\b(&#039; + labelAlt + &#039;)\\s*:\\s*([+\\-]?)(\\d+(?:\\.\\d+)?)&#039;, &#039;gi&#039; );&lt;br /&gt;
		var sm;&lt;br /&gt;
		while ( ( sm = statRe.exec( raw ) ) !== null ) {&lt;br /&gt;
			var key = canonicalStatKey( sm[ 1 ] );&lt;br /&gt;
			var value = parseFloat( sm[ 3 ] );&lt;br /&gt;
			if ( sm[ 2 ] === &#039;-&#039; ) {&lt;br /&gt;
				value = -value;&lt;br /&gt;
			}&lt;br /&gt;
			// DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.&lt;br /&gt;
			item.stats[ key ] = value;&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return item;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Scoring ------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	// Default score component weights (all 1). A component contributes its&lt;br /&gt;
	// scaled stat value when enabled.&lt;br /&gt;
	function defaultComponents() {&lt;br /&gt;
		return {&lt;br /&gt;
			ratio: false, dmg: false, delay: false,&lt;br /&gt;
			ac: false, str: false, sta: false, agi: false, dex: false,&lt;br /&gt;
			int: false, wis: false, cha: false,&lt;br /&gt;
			totalSV: false,&lt;br /&gt;
			svMagic: false, svFire: false, svCold: false,&lt;br /&gt;
			svPoison: false, svDisease: false, svVoid: false&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function svVoidBonus( item, level ) {&lt;br /&gt;
		if ( level &amp;lt;= 0 ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var count = 0;&lt;br /&gt;
		for ( var i = 0; i &amp;lt; SV_VOID_QUALIFIERS.length; i++ ) {&lt;br /&gt;
			if ( Object.prototype.hasOwnProperty.call( item.stats, SV_VOID_QUALIFIERS[ i ] ) ) {&lt;br /&gt;
				count++;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return count &amp;gt;= 2 ? level : 0;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * Compute the scaled value of one stat key at a given level.&lt;br /&gt;
	 */&lt;br /&gt;
	function scaledStat( item, key, level ) {&lt;br /&gt;
		if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {&lt;br /&gt;
			return 0;&lt;br /&gt;
		}&lt;br /&gt;
		var base = item.stats[ key ];&lt;br /&gt;
		if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {&lt;br /&gt;
			return scalePrimaryStat( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;DMG&#039; ) {&lt;br /&gt;
			return scaleDamage( base, level );&lt;br /&gt;
		}&lt;br /&gt;
		return base;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	/**&lt;br /&gt;
	 * scoreItem(item, opts)&lt;br /&gt;
	 *   opts.level        integer 0..10&lt;br /&gt;
	 *   opts.components    map of enabled score components (see defaultComponents)&lt;br /&gt;
	 *   opts.effectBonus  { focus, worn, click, combat } flat points per present effect&lt;br /&gt;
	 */&lt;br /&gt;
	function scoreItem( item, opts ) {&lt;br /&gt;
		opts = opts || {};&lt;br /&gt;
		var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );&lt;br /&gt;
		var comp = opts.components || defaultComponents();&lt;br /&gt;
		var effectBonus = opts.effectBonus || {};&lt;br /&gt;
		var breakdown = {};&lt;br /&gt;
		var score = 0;&lt;br /&gt;
&lt;br /&gt;
		function add( name, value ) {&lt;br /&gt;
			if ( value ) {&lt;br /&gt;
				breakdown[ name ] = value;&lt;br /&gt;
				score += value;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		var svKeys = {&lt;br /&gt;
			svMagic: &#039;SV_MAGIC&#039;, svFire: &#039;SV_FIRE&#039;, svCold: &#039;SV_COLD&#039;,&lt;br /&gt;
			svPoison: &#039;SV_POISON&#039;, svDisease: &#039;SV_DISEASE&#039;&lt;br /&gt;
		};&lt;br /&gt;
		var simpleKeys = {&lt;br /&gt;
			ac: &#039;AC&#039;, str: &#039;STR&#039;, sta: &#039;STA&#039;, agi: &#039;AGI&#039;, dex: &#039;DEX&#039;,&lt;br /&gt;
			int: &#039;INT&#039;, wis: &#039;WIS&#039;, cha: &#039;CHA&#039;&lt;br /&gt;
		};&lt;br /&gt;
&lt;br /&gt;
		var scaledDmg = scaledStat( item, &#039;DMG&#039;, level );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.ratio &amp;amp;&amp;amp; item.delay &amp;gt; 0 &amp;amp;&amp;amp; scaledDmg &amp;gt; 0 ) {&lt;br /&gt;
			// Ratio score = (damage / delay) * 10  (damage/delay per user; *10 to scale up)&lt;br /&gt;
			add( &#039;ratio&#039;, ( scaledDmg / item.delay ) * 10 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.dmg ) {&lt;br /&gt;
			add( &#039;dmg&#039;, scaledDmg );&lt;br /&gt;
		}&lt;br /&gt;
		if ( comp.delay &amp;amp;&amp;amp; item.delay &amp;gt; 0 ) {&lt;br /&gt;
			// Lower delay is better; contribute inverse so faster weapons score higher.&lt;br /&gt;
			add( &#039;delay&#039;, 100 / item.delay );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		Object.keys( simpleKeys ).forEach( function ( ck ) {&lt;br /&gt;
			if ( comp[ ck ] ) {&lt;br /&gt;
				add( ck, scaledStat( item, simpleKeys[ ck ], level ) );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		if ( comp.totalSV ) {&lt;br /&gt;
			var svTotal = 0;&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				svTotal += scaledStat( item, svKeys[ sk ], level );&lt;br /&gt;
			} );&lt;br /&gt;
			add( &#039;totalSV&#039;, svTotal );&lt;br /&gt;
		} else {&lt;br /&gt;
			Object.keys( svKeys ).forEach( function ( sk ) {&lt;br /&gt;
				if ( comp[ sk ] ) {&lt;br /&gt;
					add( sk, scaledStat( item, svKeys[ sk ], level ) );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		if ( comp.svVoid ) {&lt;br /&gt;
			add( &#039;svVoid&#039;, svVoidBonus( item, level ) );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// Effect flat bonuses (per present effect type).&lt;br /&gt;
		[ &#039;worn&#039;, &#039;combat&#039;, &#039;click&#039; ].forEach( function ( t ) {&lt;br /&gt;
			if ( item.effectTypes[ t ] &amp;amp;&amp;amp; effectBonus[ t ] ) {&lt;br /&gt;
				add( &#039;effect_&#039; + t, effectBonus[ t ] );&lt;br /&gt;
			}&lt;br /&gt;
		} );&lt;br /&gt;
		if ( item.effectTypes.focus &amp;amp;&amp;amp; effectBonus.focus ) {&lt;br /&gt;
			add( &#039;effect_focus&#039;, effectBonus.focus );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		return {&lt;br /&gt;
			score: Math.round( score * 1000 ) / 1000,&lt;br /&gt;
			breakdown: breakdown&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- Weapon ratio helper (for the display column) -----------------------&lt;br /&gt;
&lt;br /&gt;
	function weaponRatio( item, level ) {&lt;br /&gt;
		var dmg = scaledStat( item, &#039;DMG&#039;, level || 0 );&lt;br /&gt;
		if ( !item.delay || dmg &amp;lt;= 0 ) {&lt;br /&gt;
			return null;&lt;br /&gt;
		}&lt;br /&gt;
		return dmg / item.delay;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	root.EQLIS = {&lt;br /&gt;
		MAX_LEVEL: MAX_LEVEL,&lt;br /&gt;
		PRIMARY_STATS: PRIMARY_STATS,&lt;br /&gt;
		SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,&lt;br /&gt;
		excelRound: excelRound,&lt;br /&gt;
		scalePrimaryStat: scalePrimaryStat,&lt;br /&gt;
		scaleDamage: scaleDamage,&lt;br /&gt;
		canonicalStatKey: canonicalStatKey,&lt;br /&gt;
		parseItemData: parseItemData,&lt;br /&gt;
		defaultComponents: defaultComponents,&lt;br /&gt;
		scaledStat: scaledStat,&lt;br /&gt;
		svVoidBonus: svVoidBonus,&lt;br /&gt;
		scoreItem: scoreItem,&lt;br /&gt;
		weaponRatio: weaponRatio&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
}( typeof window !== &#039;undefined&#039; ? window : this ) );&lt;br /&gt;
&lt;br /&gt;
/* ---- eqlis.app.js ---- */&lt;br /&gt;
/**&lt;br /&gt;
 * EQL Item Score Table — app layer (browser, runs on eqlwiki.com).&lt;br /&gt;
 *&lt;br /&gt;
 * Depends on: window.EQLIS (eqlis.core.js), jQuery, mediaWiki.&lt;br /&gt;
 * Mounts into #eql-item-score-table when present.&lt;br /&gt;
 *&lt;br /&gt;
 * DELIVERY: hosted as User:&amp;lt;you&amp;gt;/eqlItemScore.js, loaded by common.js.&lt;br /&gt;
 * FLIP-READY: all data access goes through the DataSource abstraction below.&lt;br /&gt;
 *   - ClientDataSource  = today (fans out to Special:ClassSlotEquip + API).&lt;br /&gt;
 *   - SpecialPageDataSource = future (single fetch to Special:ItemScoreTable).&lt;br /&gt;
 *   Swap ACTIVE_DATASOURCE to flip; nothing else changes.&lt;br /&gt;
 */&lt;br /&gt;
( function ( $, mw, EQLIS ) {&lt;br /&gt;
	&#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
	if ( !EQLIS ) {&lt;br /&gt;
		if ( window.console ) {&lt;br /&gt;
			console.error( &#039;[eqlis] eqlis.core.js not loaded before eqlis.app.js&#039; );&lt;br /&gt;
		}&lt;br /&gt;
		return;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var STORAGE_KEY = &#039;eqlis-state-v1&#039;;&lt;br /&gt;
	var MOUNT_ID = &#039;eql-item-score-table&#039;;&lt;br /&gt;
&lt;br /&gt;
	// ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).&lt;br /&gt;
	var SLOTS = [&lt;br /&gt;
		&#039;Primary&#039;, &#039;One-Handed&#039;, &#039;Two-Handed&#039;, &#039;Secondary&#039;, &#039;Range&#039;, &#039;Ammo&#039;,&lt;br /&gt;
		&#039;Bard Instrument&#039;, &#039;Head&#039;, &#039;Face&#039;, &#039;Ear&#039;, &#039;Neck&#039;, &#039;Shoulders&#039;, &#039;Arms&#039;,&lt;br /&gt;
		&#039;Back&#039;, &#039;Wrist&#039;, &#039;Hands&#039;, &#039;Fingers&#039;, &#039;Chest&#039;, &#039;Waist&#039;, &#039;Legs&#039;, &#039;Feet&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	var CLASSES = [&lt;br /&gt;
		&#039;&#039;, &#039;Bard&#039;, &#039;Beastlord&#039;, &#039;Berserker&#039;, &#039;Cleric&#039;, &#039;Druid&#039;, &#039;Enchanter&#039;,&lt;br /&gt;
		&#039;Magician&#039;, &#039;Monk&#039;, &#039;Necromancer&#039;, &#039;Paladin&#039;, &#039;Ranger&#039;, &#039;Rogue&#039;,&lt;br /&gt;
		&#039;Shadow Knight&#039;, &#039;Shaman&#039;, &#039;Warrior&#039;, &#039;Wizard&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Weapon types as they appear in the &amp;quot;Skill:&amp;quot; line / categories.&lt;br /&gt;
	var WEAPON_TYPES = [&lt;br /&gt;
		&#039;1H Blunt&#039;, &#039;2H Blunt&#039;, &#039;1H Slashing&#039;, &#039;2H Slashing&#039;,&lt;br /&gt;
		&#039;Piercing&#039;, &#039;2H Piercing&#039;, &#039;Archery&#039;, &#039;Throwing&#039;,&lt;br /&gt;
		&#039;Instrument&#039;, &#039;Hand to Hand&#039;&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// Score component checkboxes. `sv` marks the mutually-exclusive save group.&lt;br /&gt;
	var SCORE_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Weapon Ratio (dmg/delay)&#039; },&lt;br /&gt;
		{ key: &#039;dmg&#039;, label: &#039;Weapon Damage&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Weapon Delay (faster=better)&#039; },&lt;br /&gt;
		{ key: &#039;ac&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;str&#039;, label: &#039;Strength&#039; },&lt;br /&gt;
		{ key: &#039;dex&#039;, label: &#039;Dexterity&#039; },&lt;br /&gt;
		{ key: &#039;agi&#039;, label: &#039;Agility&#039; },&lt;br /&gt;
		{ key: &#039;sta&#039;, label: &#039;Stamina&#039; },&lt;br /&gt;
		{ key: &#039;int&#039;, label: &#039;Intelligence&#039; },&lt;br /&gt;
		{ key: &#039;wis&#039;, label: &#039;Wisdom&#039; },&lt;br /&gt;
		{ key: &#039;cha&#039;, label: &#039;Charisma&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var SV_COMPONENTS = [&lt;br /&gt;
		{ key: &#039;svMagic&#039;, label: &#039;SV Magic&#039; },&lt;br /&gt;
		{ key: &#039;svFire&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;svCold&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;svPoison&#039;, label: &#039;SV Poison&#039; },&lt;br /&gt;
		{ key: &#039;svDisease&#039;, label: &#039;SV Disease&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; }&lt;br /&gt;
	];&lt;br /&gt;
	var EFFECT_TYPES = [ &#039;focus&#039;, &#039;worn&#039;, &#039;click&#039;, &#039;combat&#039; ];&lt;br /&gt;
&lt;br /&gt;
	// Result columns. `enrich:true` = populated by the (future) enrichment phase.&lt;br /&gt;
	var COLUMNS = [&lt;br /&gt;
		{ key: &#039;name&#039;, label: &#039;Name&#039;, always: true },&lt;br /&gt;
		{ key: &#039;score&#039;, label: &#039;Item Score&#039;, always: true },&lt;br /&gt;
		{ key: &#039;slot&#039;, label: &#039;Slot&#039; },&lt;br /&gt;
		{ key: &#039;type&#039;, label: &#039;Type&#039; },&lt;br /&gt;
		{ key: &#039;focus&#039;, label: &#039;Focus&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;worn&#039;, label: &#039;Worn&#039; },&lt;br /&gt;
		{ key: &#039;click&#039;, label: &#039;Click&#039; },&lt;br /&gt;
		{ key: &#039;combat&#039;, label: &#039;Combat&#039; },&lt;br /&gt;
		{ key: &#039;ratio&#039;, label: &#039;Ratio&#039; },&lt;br /&gt;
		{ key: &#039;DMG&#039;, label: &#039;DMG&#039; },&lt;br /&gt;
		{ key: &#039;delay&#039;, label: &#039;Delay&#039; },&lt;br /&gt;
		{ key: &#039;AC&#039;, label: &#039;AC&#039; },&lt;br /&gt;
		{ key: &#039;STR&#039;, label: &#039;STR&#039; },&lt;br /&gt;
		{ key: &#039;DEX&#039;, label: &#039;DEX&#039; },&lt;br /&gt;
		{ key: &#039;AGI&#039;, label: &#039;AGI&#039; },&lt;br /&gt;
		{ key: &#039;STA&#039;, label: &#039;STA&#039; },&lt;br /&gt;
		{ key: &#039;INT&#039;, label: &#039;INT&#039; },&lt;br /&gt;
		{ key: &#039;WIS&#039;, label: &#039;WIS&#039; },&lt;br /&gt;
		{ key: &#039;CHA&#039;, label: &#039;CHA&#039; },&lt;br /&gt;
		{ key: &#039;SV_MAGIC&#039;, label: &#039;SV Mag&#039; },&lt;br /&gt;
		{ key: &#039;SV_FIRE&#039;, label: &#039;SV Fire&#039; },&lt;br /&gt;
		{ key: &#039;SV_COLD&#039;, label: &#039;SV Cold&#039; },&lt;br /&gt;
		{ key: &#039;SV_POISON&#039;, label: &#039;SV Poi&#039; },&lt;br /&gt;
		{ key: &#039;SV_DISEASE&#039;, label: &#039;SV Dis&#039; },&lt;br /&gt;
		{ key: &#039;svVoid&#039;, label: &#039;SV Void&#039; },&lt;br /&gt;
		{ key: &#039;source&#039;, label: &#039;Source&#039; },&lt;br /&gt;
		{ key: &#039;level&#039;, label: &#039;Level&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zone&#039;, label: &#039;Zone&#039;, enrich: true },&lt;br /&gt;
		{ key: &#039;zoneAvg&#039;, label: &#039;Zone Avg Lvl&#039;, enrich: true }&lt;br /&gt;
	];&lt;br /&gt;
&lt;br /&gt;
	// ---- state / persistence ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function defaultState() {&lt;br /&gt;
		var comps = {};&lt;br /&gt;
		SCORE_COMPONENTS.concat( SV_COMPONENTS ).forEach( function ( c ) {&lt;br /&gt;
			comps[ c.key ] = false;&lt;br /&gt;
		} );&lt;br /&gt;
		comps.totalSV = false;&lt;br /&gt;
		var cols = {};&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1&lt;br /&gt;
		} );&lt;br /&gt;
		return {&lt;br /&gt;
			level: 0,&lt;br /&gt;
			classes: [ &#039;&#039;, &#039;&#039;, &#039;&#039; ],&lt;br /&gt;
			slots: [ &#039;Primary&#039; ],&lt;br /&gt;
			weaponTypes: [],&lt;br /&gt;
			zones: [],&lt;br /&gt;
			minLevel: 1,&lt;br /&gt;
			maxLevel: 100,&lt;br /&gt;
			components: comps,&lt;br /&gt;
			effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },&lt;br /&gt;
			columns: cols&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function loadState() {&lt;br /&gt;
		try {&lt;br /&gt;
			var raw = window.localStorage.getItem( STORAGE_KEY );&lt;br /&gt;
			if ( raw ) {&lt;br /&gt;
				return $.extend( true, defaultState(), JSON.parse( raw ) );&lt;br /&gt;
			}&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
		return defaultState();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function saveState( state ) {&lt;br /&gt;
		try {&lt;br /&gt;
			window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) );&lt;br /&gt;
		} catch ( e ) {}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- DataSource abstraction --------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function classSlotEquipUrl( classes, slot ) {&lt;br /&gt;
		var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
		return base + &#039;?title=Special:ClassSlotEquip&amp;amp;eqlBuilder=1&#039; +&lt;br /&gt;
			&#039;&amp;amp;classA=&#039; + encodeURIComponent( classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classB=&#039; + encodeURIComponent( classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;classC=&#039; + encodeURIComponent( classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
			&#039;&amp;amp;slot=&#039; + encodeURIComponent( slot );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// Parse one ClassSlotEquip HTML fragment into item records.&lt;br /&gt;
	function parseFragment( html, slot ) {&lt;br /&gt;
		var $frag = $( &#039;&amp;lt;div&amp;gt;&#039; ).html( html );&lt;br /&gt;
		var items = [];&lt;br /&gt;
		$frag.find( &#039;table.eoTable tr, table.sortable tr&#039; ).each( function () {&lt;br /&gt;
			var $tr = $( this );&lt;br /&gt;
			if ( $tr.find( &#039;th&#039; ).length ) {&lt;br /&gt;
				return; // header&lt;br /&gt;
			}&lt;br /&gt;
			var $cells = $tr.children( &#039;td&#039; );&lt;br /&gt;
			if ( !$cells.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var $nameCell = $cells.eq( 0 );&lt;br /&gt;
			var $link = $nameCell.find( &#039;a&#039; ).first();&lt;br /&gt;
			var $hb = $nameCell.find( &#039;.hb&#039; ).first();&lt;br /&gt;
			var $itemdata = $nameCell.find( &#039;.itemdata&#039; ).first();&lt;br /&gt;
			if ( !$itemdata.length ) {&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			var fields = EQLIS.parseItemData( $itemdata.text() );&lt;br /&gt;
			// Source: the &amp;quot;Drops From&amp;quot; column (3rd, index 2).&lt;br /&gt;
			var $src = $cells.eq( 2 );&lt;br /&gt;
			var srcText = $.trim( $src.text() );&lt;br /&gt;
			var sourceType = /quest/i.test( srcText ) ? &#039;quest&#039; : &#039;drop&#039;;&lt;br /&gt;
&lt;br /&gt;
			items.push( {&lt;br /&gt;
				name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),&lt;br /&gt;
				href: $link.attr( &#039;href&#039; ) || null,&lt;br /&gt;
				popoverHtml: $hb.length ? $hb.prop( &#039;outerHTML&#039; ) : &#039;&#039;,&lt;br /&gt;
				fields: fields,&lt;br /&gt;
				slot: slot,&lt;br /&gt;
				source: { type: sourceType, text: srcText, html: $src.html() },&lt;br /&gt;
				// enrichment placeholders&lt;br /&gt;
				enrich: { focus: null, level: null, zone: null, zoneAvg: null }&lt;br /&gt;
			} );&lt;br /&gt;
		} );&lt;br /&gt;
		return items;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	var ClientDataSource = {&lt;br /&gt;
		/**&lt;br /&gt;
		 * fetchItems(query) -&amp;gt; Promise&amp;lt;Array&amp;lt;itemRecord&amp;gt;&amp;gt;&lt;br /&gt;
		 * query: { classes:[a,b,c], slots:[...] }&lt;br /&gt;
		 * Fans out one request per slot, merges, de-dupes by name+slot.&lt;br /&gt;
		 */&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var slots = query.slots &amp;amp;&amp;amp; query.slots.length ? query.slots : [ &#039;Primary&#039; ];&lt;br /&gt;
			var requests = slots.map( function ( slot ) {&lt;br /&gt;
				return $.ajax( {&lt;br /&gt;
					url: classSlotEquipUrl( query.classes, slot ),&lt;br /&gt;
					dataType: &#039;html&#039;&lt;br /&gt;
				} ).then( function ( html ) {&lt;br /&gt;
					return parseFragment( html, slot );&lt;br /&gt;
				}, function () {&lt;br /&gt;
					return []; // one slot failing shouldn&#039;t kill the whole run&lt;br /&gt;
				} );&lt;br /&gt;
			} );&lt;br /&gt;
&lt;br /&gt;
			return $.when.apply( $, requests ).then( function () {&lt;br /&gt;
				var merged = [];&lt;br /&gt;
				var seen = {};&lt;br /&gt;
				var lists = Array.prototype.slice.call( arguments );&lt;br /&gt;
				// $.when with 1 request passes the value directly, not in an array-of-args.&lt;br /&gt;
				if ( requests.length === 1 ) {&lt;br /&gt;
					lists = [ arguments[ 0 ] ];&lt;br /&gt;
				}&lt;br /&gt;
				lists.forEach( function ( list ) {&lt;br /&gt;
					( list || [] ).forEach( function ( it ) {&lt;br /&gt;
						var k = it.name + &#039;|&#039; + it.slot;&lt;br /&gt;
						if ( !seen[ k ] ) {&lt;br /&gt;
							seen[ k ] = true;&lt;br /&gt;
							merged.push( it );&lt;br /&gt;
						}&lt;br /&gt;
					} );&lt;br /&gt;
				} );&lt;br /&gt;
				return merged;&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	// Stub for the eventual server-backed path (kept so the flip is one line).&lt;br /&gt;
	var SpecialPageDataSource = {&lt;br /&gt;
		fetchItems: function ( query ) {&lt;br /&gt;
			var base = ( mw.config.get( &#039;wgScript&#039; ) || &#039;/index.php&#039; );&lt;br /&gt;
			var url = base + &#039;?title=Special:ItemScoreTable&amp;amp;format=json&#039; +&lt;br /&gt;
				&#039;&amp;amp;classA=&#039; + encodeURIComponent( query.classes[ 0 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classB=&#039; + encodeURIComponent( query.classes[ 1 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;classC=&#039; + encodeURIComponent( query.classes[ 2 ] || &#039;&#039; ) +&lt;br /&gt;
				&#039;&amp;amp;slots=&#039; + encodeURIComponent( ( query.slots || [] ).join( &#039;|&#039; ) );&lt;br /&gt;
			return $.getJSON( url ).then( function ( data ) {&lt;br /&gt;
				return data.items || [];&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
	};&lt;br /&gt;
&lt;br /&gt;
	var ACTIVE_DATASOURCE = ClientDataSource; // &amp;lt;-- flip to SpecialPageDataSource later&lt;br /&gt;
&lt;br /&gt;
	// ---- filtering + scoring ------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function normalizeType( skill ) {&lt;br /&gt;
		if ( !skill ) {&lt;br /&gt;
			return &#039;armor&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		return skill;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function passesFilters( item, state ) {&lt;br /&gt;
		// Weapon type&lt;br /&gt;
		if ( state.weaponTypes.length ) {&lt;br /&gt;
			var t = normalizeType( item.fields.type );&lt;br /&gt;
			if ( state.weaponTypes.indexOf( t ) === -1 ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Level range (enrichment-dependent). In v1, item.enrich.level is null;&lt;br /&gt;
		// only filter when we actually have a level so we don&#039;t hide everything.&lt;br /&gt;
		var lvl = item.enrich.level;&lt;br /&gt;
		if ( lvl != null ) {&lt;br /&gt;
			if ( lvl &amp;lt; state.minLevel || lvl &amp;gt; state.maxLevel ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		// Zone (enrichment-dependent) — same guard.&lt;br /&gt;
		if ( state.zones.length &amp;amp;&amp;amp; item.enrich.zone != null ) {&lt;br /&gt;
			if ( state.zones.indexOf( item.enrich.zone ) === -1 &amp;amp;&amp;amp;&lt;br /&gt;
				item.enrich.zone !== &#039;multiple&#039; ) {&lt;br /&gt;
				return false;&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
		return true;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function scoreOpts( state ) {&lt;br /&gt;
		return {&lt;br /&gt;
			level: state.level,&lt;br /&gt;
			components: state.components,&lt;br /&gt;
			effectBonus: state.effectBonus&lt;br /&gt;
		};&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- rendering ----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function el( tag, attrs, children ) {&lt;br /&gt;
		var $e = $( &#039;&amp;lt;&#039; + tag + &#039;&amp;gt;&#039; );&lt;br /&gt;
		if ( attrs ) {&lt;br /&gt;
			$e.attr( attrs );&lt;br /&gt;
		}&lt;br /&gt;
		if ( children != null ) {&lt;br /&gt;
			$e.append( children );&lt;br /&gt;
		}&lt;br /&gt;
		return $e;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function statCell( item, key, level ) {&lt;br /&gt;
		if ( key === &#039;ratio&#039; ) {&lt;br /&gt;
			var r = EQLIS.weaponRatio( item.fields, level );&lt;br /&gt;
			return r == null ? &#039;&#039; : r.toFixed( 3 );&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;delay&#039; ) {&lt;br /&gt;
			return item.fields.delay || &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		if ( key === &#039;svVoid&#039; ) {&lt;br /&gt;
			var v = EQLIS.svVoidBonus( item.fields, level );&lt;br /&gt;
			return v ? &#039;+&#039; + v : &#039;&#039;;&lt;br /&gt;
		}&lt;br /&gt;
		// scalable stat key (DMG / AC / STR / SV_*)&lt;br /&gt;
		if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {&lt;br /&gt;
			return EQLIS.scaledStat( item.fields, key, level );&lt;br /&gt;
		}&lt;br /&gt;
		return &#039;&#039;;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function effectCell( item, type ) {&lt;br /&gt;
		var names = item.fields.effects&lt;br /&gt;
			.filter( function ( e ) { return e.type === type; } )&lt;br /&gt;
			.map( function ( e ) { return e.name; } );&lt;br /&gt;
		return names.join( &#039;, &#039; );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildResultsTable( items, state ) {&lt;br /&gt;
		var level = state.level;&lt;br /&gt;
		var visibleCols = COLUMNS.filter( function ( c ) {&lt;br /&gt;
			return c.always || state.columns[ c.key ];&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		var $table = el( &#039;table&#039;, { &#039;class&#039;: &#039;eoTable sortable eqlis-results&#039; } );&lt;br /&gt;
		var $thead = el( &#039;thead&#039; );&lt;br /&gt;
		var $htr = el( &#039;tr&#039; );&lt;br /&gt;
		visibleCols.forEach( function ( c ) {&lt;br /&gt;
			$htr.append( el( &#039;th&#039;, null, document.createTextNode( c.label ) ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$thead.append( $htr );&lt;br /&gt;
		$table.append( $thead );&lt;br /&gt;
&lt;br /&gt;
		var $tbody = el( &#039;tbody&#039; );&lt;br /&gt;
&lt;br /&gt;
		// score + filter, then sort by score desc for a useful default&lt;br /&gt;
		var rows = items&lt;br /&gt;
			.filter( function ( it ) { return passesFilters( it, state ); } )&lt;br /&gt;
			.map( function ( it ) {&lt;br /&gt;
				it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).score;&lt;br /&gt;
				return it;&lt;br /&gt;
			} )&lt;br /&gt;
			.sort( function ( a, b ) { return b._score - a._score; } );&lt;br /&gt;
&lt;br /&gt;
		rows.forEach( function ( it ) {&lt;br /&gt;
			var $tr = el( &#039;tr&#039; );&lt;br /&gt;
			visibleCols.forEach( function ( c ) {&lt;br /&gt;
				var $td = el( &#039;td&#039; );&lt;br /&gt;
				switch ( c.key ) {&lt;br /&gt;
					case &#039;name&#039;:&lt;br /&gt;
						// embed the popover markup so the wiki&#039;s hover CSS works&lt;br /&gt;
						$td.html(&lt;br /&gt;
							&#039;&amp;lt;div class=&amp;quot;hbdiv&amp;quot;&amp;gt;&#039; +&lt;br /&gt;
							( it.href ? &#039;&amp;lt;a href=&amp;quot;&#039; + it.href + &#039;&amp;quot;&amp;gt;&#039; + it.name + &#039;&amp;lt;/a&amp;gt;&#039; : it.name ) +&lt;br /&gt;
							( it.popoverHtml || &#039;&#039; ) +&lt;br /&gt;
							&#039;&amp;lt;/div&amp;gt;&#039;&lt;br /&gt;
						);&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;score&#039;:&lt;br /&gt;
						$td.addClass( &#039;eqlis-score&#039; ).text( it._score );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;slot&#039;:&lt;br /&gt;
						$td.text( it.slot );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;type&#039;:&lt;br /&gt;
						$td.text( normalizeType( it.fields.type ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;focus&#039;:&lt;br /&gt;
						$td.text( it.enrich.focus || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;worn&#039;: case &#039;click&#039;: case &#039;combat&#039;:&lt;br /&gt;
						$td.text( effectCell( it, c.key ) );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;source&#039;:&lt;br /&gt;
						$td.html( it.source.html || it.source.text || &#039;&#039; );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;level&#039;:&lt;br /&gt;
						$td.text( it.enrich.level == null ? &#039;—&#039; : it.enrich.level );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zone&#039;:&lt;br /&gt;
						$td.text( it.enrich.zone == null ? &#039;—&#039; : it.enrich.zone );&lt;br /&gt;
						break;&lt;br /&gt;
					case &#039;zoneAvg&#039;:&lt;br /&gt;
						$td.text( it.enrich.zoneAvg == null ? &#039;—&#039; : it.enrich.zoneAvg );&lt;br /&gt;
						break;&lt;br /&gt;
					default:&lt;br /&gt;
						$td.text( statCell( it, c.key, level ) );&lt;br /&gt;
				}&lt;br /&gt;
				$tr.append( $td );&lt;br /&gt;
			} );&lt;br /&gt;
			$tbody.append( $tr );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		$table.append( $tbody );&lt;br /&gt;
		return { $table: $table, count: rows.length };&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- UI shell -----------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function multiSelect( id, values, selected, labelFor ) {&lt;br /&gt;
		var $sel = el( &#039;select&#039;, { id: id, multiple: &#039;multiple&#039;, size: Math.min( 8, values.length ), &#039;class&#039;: &#039;eqlis-multi&#039; } );&lt;br /&gt;
		values.forEach( function ( v ) {&lt;br /&gt;
			var $o = el( &#039;option&#039;, { value: v }, document.createTextNode( labelFor ? labelFor( v ) : ( v || &#039;— None —&#039; ) ) );&lt;br /&gt;
			if ( selected.indexOf( v ) !== -1 ) {&lt;br /&gt;
				$o.prop( &#039;selected&#039;, true );&lt;br /&gt;
			}&lt;br /&gt;
			$sel.append( $o );&lt;br /&gt;
		} );&lt;br /&gt;
		return $sel;&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function readMulti( $sel ) {&lt;br /&gt;
		return ( $sel.val() || [] ).slice();&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	function buildUI( $root, state ) {&lt;br /&gt;
		$root.empty().addClass( &#039;eqlis-app&#039; );&lt;br /&gt;
&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-title&#039; }, &#039;Equipment Analyzer&#039; ) );&lt;br /&gt;
		$root.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-subtitle&#039; },&lt;br /&gt;
			&#039;Pick classes/slots and scoring options, then press List Results. &#039; +&lt;br /&gt;
			&#039;Zone / Level / Focus columns arrive in the enrichment phase.&#039; ) );&lt;br /&gt;
&lt;br /&gt;
		var $controls = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-controls&#039; } );&lt;br /&gt;
&lt;br /&gt;
		// Upgrade level slider&lt;br /&gt;
		var $lvlWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lvlWrap.append( el( &#039;label&#039;, null, &#039;Upgrade Level: &#039; ) );&lt;br /&gt;
		var $lvl = el( &#039;input&#039;, { type: &#039;range&#039;, min: 0, max: EQLIS.MAX_LEVEL, step: 1, value: state.level, id: &#039;eqlis-level&#039; } );&lt;br /&gt;
		var $lvlOut = el( &#039;span&#039;, { &#039;class&#039;: &#039;eqlis-level-out&#039; }, String( state.level ) );&lt;br /&gt;
		$lvl.on( &#039;input&#039;, function () { $lvlOut.text( this.value ); } );&lt;br /&gt;
		$lvlWrap.append( $lvl ).append( $lvlOut );&lt;br /&gt;
		$controls.append( $lvlWrap );&lt;br /&gt;
&lt;br /&gt;
		// Classes&lt;br /&gt;
		var $clsRow = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$clsRow.append( el( &#039;label&#039;, null, &#039;Classes (up to 3): &#039; ) );&lt;br /&gt;
		[ 0, 1, 2 ].forEach( function ( i ) {&lt;br /&gt;
			var $s = el( &#039;select&#039;, { id: &#039;eqlis-class-&#039; + i, &#039;class&#039;: &#039;eqlis-single&#039; } );&lt;br /&gt;
			CLASSES.forEach( function ( c ) {&lt;br /&gt;
				var $o = el( &#039;option&#039;, { value: c }, document.createTextNode( c || &#039;— None —&#039; ) );&lt;br /&gt;
				if ( state.classes[ i ] === c ) { $o.prop( &#039;selected&#039;, true ); }&lt;br /&gt;
				$s.append( $o );&lt;br /&gt;
			} );&lt;br /&gt;
			$clsRow.append( $s );&lt;br /&gt;
		} );&lt;br /&gt;
		$controls.append( $clsRow );&lt;br /&gt;
&lt;br /&gt;
		// Slots&lt;br /&gt;
		var $slotWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$slotWrap.append( el( &#039;label&#039;, null, &#039;Slots: &#039; ) );&lt;br /&gt;
		$slotWrap.append( multiSelect( &#039;eqlis-slots&#039;, SLOTS, state.slots ) );&lt;br /&gt;
		$controls.append( $slotWrap );&lt;br /&gt;
&lt;br /&gt;
		// Weapon types&lt;br /&gt;
		var $wtWrap = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$wtWrap.append( el( &#039;label&#039;, null, &#039;Weapon Type: &#039; ) );&lt;br /&gt;
		$wtWrap.append( multiSelect( &#039;eqlis-wtypes&#039;, WEAPON_TYPES, state.weaponTypes ) );&lt;br /&gt;
		$controls.append( $wtWrap );&lt;br /&gt;
&lt;br /&gt;
		// Level range&lt;br /&gt;
		var $lr = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$lr.append( el( &#039;label&#039;, null, &#039;Level min/max: &#039; ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-minlvl&#039;, min: 1, max: 100, value: state.minLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$lr.append( el( &#039;input&#039;, { type: &#039;number&#039;, id: &#039;eqlis-maxlvl&#039;, min: 1, max: 100, value: state.maxLevel, &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		$controls.append( $lr );&lt;br /&gt;
&lt;br /&gt;
		// Score components&lt;br /&gt;
		var $score = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-scorebox&#039; } );&lt;br /&gt;
		$score.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Item Score components&#039; ) );&lt;br /&gt;
		SCORE_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label ) );&lt;br /&gt;
			$score.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		// Total SV toggle&lt;br /&gt;
		var $totalLab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
		var $totalCb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: &#039;totalSV&#039; } );&lt;br /&gt;
		if ( state.components.totalSV ) { $totalCb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
		$totalLab.append( $totalCb ).append( document.createTextNode( &#039; Total SV (sum of saves)&#039; ) );&lt;br /&gt;
		$score.append( $totalLab );&lt;br /&gt;
		SV_COMPONENTS.forEach( function ( c ) {&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check eqlis-sv&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-comp&#039;: c.key } );&lt;br /&gt;
			if ( state.components[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label ) );&lt;br /&gt;
			$score.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		// disable individual saves when Total SV is on&lt;br /&gt;
		function syncSvDisabled() {&lt;br /&gt;
			var on = $totalCb.prop( &#039;checked&#039; );&lt;br /&gt;
			$score.find( &#039;.eqlis-sv input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				if ( $( this ).attr( &#039;data-comp&#039; ) !== &#039;svVoid&#039; ) {&lt;br /&gt;
					$( this ).prop( &#039;disabled&#039;, on );&lt;br /&gt;
				}&lt;br /&gt;
			} );&lt;br /&gt;
		}&lt;br /&gt;
		$totalCb.on( &#039;change&#039;, syncSvDisabled );&lt;br /&gt;
		syncSvDisabled();&lt;br /&gt;
&lt;br /&gt;
		// Effects bonus&lt;br /&gt;
		var $eff = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field&#039; } );&lt;br /&gt;
		$eff.append( el( &#039;label&#039;, null, &#039;Effect score bonus (flat): &#039; ) );&lt;br /&gt;
		EFFECT_TYPES.forEach( function ( t ) {&lt;br /&gt;
			$eff.append( document.createTextNode( &#039; &#039; + t + &#039; &#039; ) );&lt;br /&gt;
			$eff.append( el( &#039;input&#039;, { type: &#039;number&#039;, &#039;data-effbonus&#039;: t, value: state.effectBonus[ t ], &#039;class&#039;: &#039;eqlis-num&#039; } ) );&lt;br /&gt;
		} );&lt;br /&gt;
		$score.append( $eff );&lt;br /&gt;
		$controls.append( $score );&lt;br /&gt;
&lt;br /&gt;
		// Column toggles&lt;br /&gt;
		var $cols = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-field eqlis-colbox&#039; } );&lt;br /&gt;
		$cols.append( el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-legend&#039; }, &#039;Columns&#039; ) );&lt;br /&gt;
		COLUMNS.forEach( function ( c ) {&lt;br /&gt;
			if ( c.always ) { return; }&lt;br /&gt;
			var $lab = el( &#039;label&#039;, { &#039;class&#039;: &#039;eqlis-check&#039; } );&lt;br /&gt;
			var $cb = el( &#039;input&#039;, { type: &#039;checkbox&#039;, &#039;data-col&#039;: c.key } );&lt;br /&gt;
			if ( state.columns[ c.key ] ) { $cb.prop( &#039;checked&#039;, true ); }&lt;br /&gt;
			$lab.append( $cb ).append( document.createTextNode( &#039; &#039; + c.label + ( c.enrich ? &#039; *&#039; : &#039;&#039; ) ) );&lt;br /&gt;
			$cols.append( $lab );&lt;br /&gt;
		} );&lt;br /&gt;
		$controls.append( $cols );&lt;br /&gt;
&lt;br /&gt;
		$root.append( $controls );&lt;br /&gt;
&lt;br /&gt;
		// List Results button + status + results container&lt;br /&gt;
		var $btn = el( &#039;button&#039;, { type: &#039;button&#039;, &#039;class&#039;: &#039;eqlis-run&#039; }, &#039;List Results&#039; );&lt;br /&gt;
		var $status = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-status&#039; } );&lt;br /&gt;
		var $results = el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-results&#039; } );&lt;br /&gt;
		$root.append( $btn ).append( $status ).append( $results );&lt;br /&gt;
&lt;br /&gt;
		// ---- read UI back into state ----&lt;br /&gt;
		function collect() {&lt;br /&gt;
			state.level = parseInt( $lvl.val(), 10 ) || 0;&lt;br /&gt;
			state.classes = [ 0, 1, 2 ].map( function ( i ) {&lt;br /&gt;
				return $( &#039;#eqlis-class-&#039; + i ).val() || &#039;&#039;;&lt;br /&gt;
			} );&lt;br /&gt;
			state.slots = readMulti( $( &#039;#eqlis-slots&#039; ) );&lt;br /&gt;
			state.weaponTypes = readMulti( $( &#039;#eqlis-wtypes&#039; ) );&lt;br /&gt;
			state.minLevel = parseInt( $( &#039;#eqlis-minlvl&#039; ).val(), 10 ) || 1;&lt;br /&gt;
			state.maxLevel = parseInt( $( &#039;#eqlis-maxlvl&#039; ).val(), 10 ) || 100;&lt;br /&gt;
			$score.find( &#039;input[data-comp]&#039; ).each( function () {&lt;br /&gt;
				state.components[ $( this ).attr( &#039;data-comp&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			$eff.find( &#039;input[data-effbonus]&#039; ).each( function () {&lt;br /&gt;
				state.effectBonus[ $( this ).attr( &#039;data-effbonus&#039; ) ] = parseFloat( $( this ).val() ) || 0;&lt;br /&gt;
			} );&lt;br /&gt;
			$cols.find( &#039;input[data-col]&#039; ).each( function () {&lt;br /&gt;
				state.columns[ $( this ).attr( &#039;data-col&#039; ) ] = $( this ).prop( &#039;checked&#039; );&lt;br /&gt;
			} );&lt;br /&gt;
			saveState( state );&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		// persist on any change WITHOUT fetching (List Results gates fetch)&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () { collect(); } );&lt;br /&gt;
&lt;br /&gt;
		var lastItems = null;&lt;br /&gt;
&lt;br /&gt;
		function render() {&lt;br /&gt;
			if ( !lastItems ) { return; }&lt;br /&gt;
			var built = buildResultsTable( lastItems, state );&lt;br /&gt;
			$results.empty().append(&lt;br /&gt;
				el( &#039;div&#039;, { &#039;class&#039;: &#039;eqlis-count&#039; }, built.count + &#039; items (of &#039; + lastItems.length + &#039; fetched) — sorted by score&#039; )&lt;br /&gt;
			).append( built.$table );&lt;br /&gt;
			// let the wiki instrument popovers + item-level sliders inside results&lt;br /&gt;
			if ( mw.hook ) { mw.hook( &#039;wikipage.content&#039; ).fire( $results ); }&lt;br /&gt;
			if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }&lt;br /&gt;
			if ( mw.loader ) {&lt;br /&gt;
				mw.loader.using( [ &#039;jquery.tablesorter&#039; ] ).then( function () {&lt;br /&gt;
					try { $results.find( &#039;table.sortable&#039; ).tablesorter(); } catch ( e ) {}&lt;br /&gt;
				} );&lt;br /&gt;
			}&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		$btn.on( &#039;click&#039;, function () {&lt;br /&gt;
			collect();&lt;br /&gt;
			var sel = state.classes.filter( Boolean );&lt;br /&gt;
			if ( !sel.length ) {&lt;br /&gt;
				$status.text( &#039;Select at least one class.&#039; ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				return;&lt;br /&gt;
			}&lt;br /&gt;
			$status.removeClass( &#039;eqlis-err&#039; ).text( &#039;Loading…&#039; );&lt;br /&gt;
			$btn.prop( &#039;disabled&#039;, true );&lt;br /&gt;
			ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )&lt;br /&gt;
				.then( function ( items ) {&lt;br /&gt;
					lastItems = items;&lt;br /&gt;
					$status.text( &#039;&#039; );&lt;br /&gt;
					render();&lt;br /&gt;
				}, function ( e ) {&lt;br /&gt;
					$status.text( &#039;Load failed: &#039; + ( e &amp;amp;&amp;amp; e.message ? e.message : &#039;unknown error&#039; ) ).addClass( &#039;eqlis-err&#039; );&lt;br /&gt;
				} )&lt;br /&gt;
				.always( function () { $btn.prop( &#039;disabled&#039;, false ); } );&lt;br /&gt;
		} );&lt;br /&gt;
&lt;br /&gt;
		// re-render (no refetch) when scoring/columns change after a run&lt;br /&gt;
		$controls.on( &#039;change input&#039;, function () {&lt;br /&gt;
			if ( lastItems ) { render(); }&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	// ---- mount --------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
	function init( root ) {&lt;br /&gt;
		var host = ( root || document ).querySelector ?&lt;br /&gt;
			( root || document ).querySelector( &#039;#&#039; + MOUNT_ID ) :&lt;br /&gt;
			document.getElementById( MOUNT_ID );&lt;br /&gt;
		if ( !host || host.getAttribute( &#039;data-eqlis-ready&#039; ) === &#039;1&#039; ) {&lt;br /&gt;
			return;&lt;br /&gt;
		}&lt;br /&gt;
		host.setAttribute( &#039;data-eqlis-ready&#039;, &#039;1&#039; );&lt;br /&gt;
		buildUI( $( host ), loadState() );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	$( function () { init( document ); } );&lt;br /&gt;
	if ( mw.hook ) {&lt;br /&gt;
		mw.hook( &#039;wikipage.content&#039; ).add( function ( $c ) {&lt;br /&gt;
			// don&#039;t recurse into our own results&lt;br /&gt;
			if ( $c &amp;amp;&amp;amp; $c.closest &amp;amp;&amp;amp; $c.closest( &#039;.eqlis-results&#039; ).length ) { return; }&lt;br /&gt;
			init( document );&lt;br /&gt;
		} );&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	window.eqlItemScoreInit = init;&lt;br /&gt;
&lt;br /&gt;
}( jQuery, mediaWiki, window.EQLIS ) );&lt;/div&gt;</summary>
		<author><name>Chonx</name></author>
	</entry>
</feed>