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