|
|
| Line 1: |
Line 1: |
| /** | | /* ============================================================ |
| * ext.itemScoreTable - EQL Equipment Analyzer / Item Score guide.
| | This is ALL that goes in User:Chonx/common.js |
| * | | ------------------------------------------------------------ |
| * Single self-contained module (same shape as ext.itemLevelSlider /
| | It loads the real module RAW (action=raw = NOT minified), which |
| * ext.classSlotEquip). Paste into User:<you>/common.js, then create a page
| | avoids the ResourceLoader minifier that breaks the pasted-inline |
| * containing: <div id="eql-item-score-table"></div>
| | version. Bonus: to iterate you now only edit the module page and |
| *
| | hard-refresh — no re-pasting this loader. |
| * Data access goes through ACTIVE_DATASOURCE so a future server-side
| |
| * Special:ItemScoreTable can replace ClientDataSource with a one-line change.
| |
| *
| |
| * RELOADING AFTER AN EDIT: MediaWiki serves the user JS bundle from a server-side
| |
| * ResourceLoader cache keyed by a version hash. After you save common.js the hash
| |
| * can take a few minutes to recompute, so a browser hard-refresh (Ctrl+Shift+R)
| |
| * alone may still run the OLD code. Reliable ways to force the new version:
| |
| * 1. Append &debug=true to the page URL (bypasses the RL cache/minifier), or
| |
| * 2. Visit index.php?title=User:<you>/common.js&action=raw once, then reload, or
| |
| * 3. Run this in the console to eval the freshest copy immediately:
| |
| * fetch('/index.php?title=User:<you>/common.js&action=raw&_='+Date.now())
| |
| * .then(r=>r.text()).then(t=>eval(t));
| |
| */
| |
| ( function ( $, mw ) {
| |
| 'use strict';
| |
|
| |
|
| // ---- styles (injected; common.js has no separate CSS channel) ----
| | Put the full ext.itemScoreTable.js contents in: |
| ( function () {
| | User:Chonx/eqlItemScore.js |
| var s = document.createElement( 'style' );
| | ============================================================ */ |
| s.setAttribute( 'data-eqlis', '1' );
| | mw.loader.load( |
| 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; 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 > 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";
| | mw.config.get( 'wgScript' ) + |
| ( document.head || document.documentElement ).appendChild( s );
| | '?title=User:Chonx/eqlItemScore.js&action=raw&ctype=text/javascript' |
| }() );
| | ); |
| | |
| | |
| var MAX_LEVEL = 10;
| |
| | |
| // Stats that scale via the "primary spreadsheet" rule (incl. saves).
| |
| var PRIMARY_STATS = [
| |
| 'AC', 'HP', 'MP', 'END',
| |
| 'STR', 'STA', 'AGI', 'DEX', 'WIS', 'INT', 'CHA',
| |
| 'SV_MAGIC', 'SV_FIRE', 'SV_COLD', 'SV_POISON', 'SV_DISEASE'
| |
| ];
| |
| | |
| // Stats that, when >=2 are present, grant SV VOID: +level.
| |
| var SV_VOID_QUALIFIERS = [
| |
| 'STR', 'STA', 'INT', 'AGI', 'DEX', 'CHA', 'WIS',
| |
| 'SV_FIRE', 'SV_COLD', 'SV_POISON', 'SV_MAGIC', 'SV_DISEASE'
| |
| ];
| |
| | |
| // All stat labels we recognize in a stat block, longest-first for regex.
| |
| var STAT_ALIASES = {
| |
| 'DAMAGE': 'DMG',
| |
| 'DMG': 'DMG',
| |
| 'MANA': 'MP',
| |
| 'MP': 'MP',
| |
| 'ENDUR': 'END',
| |
| 'END': 'END',
| |
| 'SV MAGIC': 'SV_MAGIC',
| |
| 'SV FIRE': 'SV_FIRE',
| |
| 'SV COLD': 'SV_COLD',
| |
| 'SV POISON': 'SV_POISON',
| |
| 'SV DISEASE': 'SV_DISEASE',
| |
| 'MAGIC': 'SV_MAGIC',
| |
| 'FIRE': 'SV_FIRE',
| |
| 'COLD': 'SV_COLD',
| |
| 'POISON': 'SV_POISON',
| |
| 'DISEASE': 'SV_DISEASE',
| |
| 'HP REGEN': 'HP_REGEN',
| |
| 'WEIGHT': 'WT',
| |
| 'WT': 'WT',
| |
| 'AC': 'AC',
| |
| 'ARMOR CLASS': 'AC',
| |
| 'HP': 'HP',
| |
| 'HIT POINTS': 'HP',
| |
| 'STR': 'STR',
| |
| 'STRENGTH': 'STR',
| |
| 'STA': 'STA',
| |
| 'STAMINA': 'STA',
| |
| 'AGI': 'AGI',
| |
| 'AGILITY': 'AGI',
| |
| 'DEX': 'DEX',
| |
| 'DEXTERITY': 'DEX',
| |
| 'WIS': 'WIS',
| |
| 'WISDOM': 'WIS',
| |
| 'INT': 'INT',
| |
| 'INTELLIGENCE': 'INT',
| |
| 'CHA': 'CHA',
| |
| 'CHARISMA': 'CHA',
| |
| 'HASTE': 'HASTE'
| |
| };
| |
| | |
| // Labels to match in the stat text (longest first so "SV FIRE" beats "FIRE").
| |
| var STAT_LABELS = Object.keys( STAT_ALIASES ).sort( function ( a, b ) {
| |
| return b.length - a.length;
| |
| } );
| |
| | |
| // ---- Excel-parity math (ported verbatim from ext.itemLevelSlider.js) ----
| |
| | |
| 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 (ported from ext.itemLevelSlider.js) --------------
| |
| // state here is just an integer effective level (0..10).
| |
| | |
| 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';
| |
| }
| |
| | |
| /**
| |
| * Parse a stat block (plain text; callers should strip HTML first).
| |
| * Returns a normalized item fields object. focus_effect is NOT present in
| |
| * stat blocks — it is set later by the data-source enrichment step.
| |
| */
| |
| function parseItemData( text ) {
| |
| 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
| |
| delay: 0,
| |
| stats: {}, // canonical key -> signed base number
| |
| 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();
| |
| }
| |
| | |
| var skillMatch = raw.match( /Skill:\s*([A-Za-z0-9 ]+?)\s+Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );
| |
| if ( skillMatch ) {
| |
| item.skill = skillMatch[ 1 ].trim();
| |
| item.type = item.skill;
| |
| item.delay = parseFloat( skillMatch[ 2 ] );
| |
| } else {
| |
| var delayOnly = raw.match( /Atk\s+Delay:\s*(\d+(?:\.\d+)?)/i );
| |
| if ( delayOnly ) {
| |
| item.delay = parseFloat( delayOnly[ 1 ] );
| |
| }
| |
| }
| |
| | |
| // 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 ] );
| |
| }
| |
| | |
| // Stats: for each known label, capture "LABEL: [+-]?num"
| |
| 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;
| |
| }
| |
| // DMG/WT/HASTE/HP_REGEN handled specially or ignored for scoring stats.
| |
| item.stats[ key ] = value;
| |
| }
| |
| | |
| return item;
| |
| }
| |
| | |
| // ---- Scoring ------------------------------------------------------------
| |
| | |
| // Default score component weights (all 1). A component contributes its
| |
| // scaled stat value when enabled.
| |
| function defaultComponents() {
| |
| return {
| |
| ratio: false, dmg: false, delay: false,
| |
| ac: false, str: false, sta: false, agi: false, dex: false,
| |
| int: false, wis: false, cha: false,
| |
| totalSV: false,
| |
| svMagic: false, svFire: false, svCold: false,
| |
| svPoison: false, svDisease: false, svVoid: false
| |
| };
| |
| }
| |
| | |
| function svVoidBonus( item, level ) {
| |
| 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;
| |
| }
| |
| | |
| /**
| |
| * Compute the scaled value of one stat key at a given level.
| |
| */
| |
| function scaledStat( item, key, level ) {
| |
| if ( !Object.prototype.hasOwnProperty.call( item.stats, key ) ) {
| |
| return 0;
| |
| }
| |
| var base = item.stats[ key ];
| |
| if ( PRIMARY_STATS.indexOf( key ) !== -1 ) {
| |
| return scalePrimaryStat( base, level );
| |
| }
| |
| if ( key === 'DMG' ) {
| |
| return scaleDamage( base, level );
| |
| }
| |
| return base;
| |
| }
| |
| | |
| /**
| |
| * scoreItem(item, opts)
| |
| * opts.level integer 0..10
| |
| * opts.components map of enabled score components (see defaultComponents)
| |
| * opts.effectBonus { focus, worn, click, combat } flat points per present effect
| |
| */
| |
| function scoreItem( item, opts ) {
| |
| opts = opts || {};
| |
| var level = Math.max( 0, Math.min( MAX_LEVEL, parseInt( opts.level, 10 ) || 0 ) );
| |
| var comp = opts.components || defaultComponents();
| |
| var effectBonus = opts.effectBonus || {};
| |
| var breakdown = {};
| |
| var score = 0;
| |
| | |
| function add( name, value ) {
| |
| if ( value ) {
| |
| breakdown[ name ] = value;
| |
| score += value;
| |
| }
| |
| }
| |
| | |
| var svKeys = {
| |
| svMagic: 'SV_MAGIC', svFire: 'SV_FIRE', svCold: 'SV_COLD',
| |
| svPoison: 'SV_POISON', svDisease: 'SV_DISEASE'
| |
| };
| |
| var simpleKeys = {
| |
| ac: 'AC', str: 'STR', sta: 'STA', agi: 'AGI', dex: 'DEX',
| |
| int: 'INT', wis: 'WIS', cha: 'CHA'
| |
| };
| |
| | |
| var scaledDmg = scaledStat( item, 'DMG', level );
| |
| | |
| if ( comp.ratio && item.delay > 0 && scaledDmg > 0 ) {
| |
| // Ratio score = (damage / delay) * 10 (damage/delay per user; *10 to scale up)
| |
| add( 'ratio', ( scaledDmg / item.delay ) * 10 );
| |
| }
| |
| if ( comp.dmg ) {
| |
| add( 'dmg', scaledDmg );
| |
| }
| |
| if ( comp.delay && item.delay > 0 ) {
| |
| // Lower delay is better; contribute inverse so faster weapons score higher.
| |
| add( 'delay', 100 / item.delay );
| |
| }
| |
| | |
| Object.keys( simpleKeys ).forEach( function ( ck ) {
| |
| if ( comp[ ck ] ) {
| |
| add( ck, scaledStat( item, simpleKeys[ ck ], level ) );
| |
| }
| |
| } );
| |
| | |
| if ( comp.totalSV ) {
| |
| var svTotal = 0;
| |
| Object.keys( svKeys ).forEach( function ( sk ) {
| |
| svTotal += scaledStat( item, svKeys[ sk ], level );
| |
| } );
| |
| add( 'totalSV', svTotal );
| |
| } else {
| |
| Object.keys( svKeys ).forEach( function ( sk ) {
| |
| if ( comp[ sk ] ) {
| |
| add( sk, scaledStat( item, svKeys[ sk ], level ) );
| |
| }
| |
| } );
| |
| }
| |
| | |
| if ( comp.svVoid ) {
| |
| add( 'svVoid', svVoidBonus( item, level ) );
| |
| }
| |
| | |
| // Effect flat bonuses (per present effect type).
| |
| [ 'worn', 'combat', 'click' ].forEach( function ( t ) {
| |
| if ( item.effectTypes[ t ] && effectBonus[ t ] ) {
| |
| add( 'effect_' + t, effectBonus[ t ] );
| |
| }
| |
| } );
| |
| if ( item.effectTypes.focus && effectBonus.focus ) {
| |
| add( 'effect_focus', effectBonus.focus );
| |
| }
| |
| | |
| return {
| |
| score: Math.round( score * 1000 ) / 1000,
| |
| breakdown: breakdown
| |
| };
| |
| }
| |
| | |
| // ---- Weapon ratio helper (for the display column) -----------------------
| |
| | |
| function weaponRatio( item, level ) {
| |
| var dmg = scaledStat( item, 'DMG', level || 0 );
| |
| if ( !item.delay || dmg <= 0 ) {
| |
| return null;
| |
| }
| |
| return dmg / item.delay;
| |
| }
| |
| | |
| var EQLIS = {
| |
| MAX_LEVEL: MAX_LEVEL,
| |
| PRIMARY_STATS: PRIMARY_STATS,
| |
| SV_VOID_QUALIFIERS: SV_VOID_QUALIFIERS,
| |
| excelRound: excelRound,
| |
| scalePrimaryStat: scalePrimaryStat,
| |
| scaleDamage: scaleDamage,
| |
| canonicalStatKey: canonicalStatKey,
| |
| parseItemData: parseItemData,
| |
| defaultComponents: defaultComponents,
| |
| 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';
| |
| | |
| // ClassSlotEquip understands these slot values (from ext.classSlotEquip.js).
| |
| 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;
| |
| }() );
| |
| | |
| // Canonical ZONE_AVG_LEVEL name for any wiki zone string, or null if out of scope.
| |
| function canonicalZone( name ) {
| |
| if ( !name ) {
| |
| return null;
| |
| }
| |
| return ZONE_INDEX[ zoneKey( name ) ] || null;
| |
| }
| |
| | |
| // Average mob level for any wiki zone string, or null if out of scope.
| |
| function zoneAverageLevel( name ) {
| |
| var canonical = canonicalZone( name );
| |
| return canonical ? ZONE_AVG_LEVEL[ canonical ] : null;
| |
| }
| |
| | |
| function zoneDisplayNames() {
| |
| return Object.keys( ZONE_AVG_LEVEL ).sort();
| |
| }
| |
| | |
| // Score component checkboxes. `sv` marks the mutually-exclusive save group.
| |
| var SCORE_COMPONENTS = [
| |
| { key: 'ratio', label: 'Weapon Ratio (dmg/delay)' },
| |
| { key: 'dmg', label: 'Weapon Damage' },
| |
| { key: 'delay', label: 'Weapon Delay' },
| |
| { key: 'ac', label: 'AC' },
| |
| { key: 'str', label: 'Strength' },
| |
| { key: 'dex', label: 'Dexterity' },
| |
| { key: 'agi', label: 'Agility' },
| |
| { key: 'sta', label: 'Stamina' },
| |
| { key: 'int', label: 'Intelligence' },
| |
| { key: 'wis', label: 'Wisdom' },
| |
| { key: 'cha', label: 'Charisma' }
| |
| ];
| |
| var SV_COMPONENTS = [
| |
| { key: 'svMagic', label: 'SV Magic' },
| |
| { key: 'svFire', label: 'SV Fire' },
| |
| { key: 'svCold', label: 'SV Cold' },
| |
| { key: 'svPoison', label: 'SV Poison' },
| |
| { key: 'svDisease', label: 'SV Disease' },
| |
| { key: 'svVoid', label: 'SV Void' }
| |
| ];
| |
| var EFFECT_TYPES = [ 'focus', 'worn', 'click', 'combat' ];
| |
| | |
| // Result columns. `enrich:true` = populated by the (future) enrichment phase.
| |
| var COLUMNS = [
| |
| { key: 'name', label: 'Name', always: true },
| |
| { key: 'score', label: 'Item Score', always: true },
| |
| { key: 'slot', label: 'Slot' },
| |
| { key: 'type', label: 'Type' },
| |
| { key: 'focus', label: 'Focus', enrich: true },
| |
| { key: 'worn', label: 'Worn' },
| |
| { key: 'click', label: 'Click' },
| |
| { key: 'combat', label: 'Combat' },
| |
| { key: 'ratio', label: 'Ratio' },
| |
| { key: 'DMG', label: 'DMG' },
| |
| { key: 'delay', label: 'Delay' },
| |
| { key: 'AC', label: 'AC' },
| |
| { key: 'STR', label: 'STR' },
| |
| { key: 'DEX', label: 'DEX' },
| |
| { key: 'AGI', label: 'AGI' },
| |
| { key: 'STA', label: 'STA' },
| |
| { key: 'INT', label: 'INT' },
| |
| { key: 'WIS', label: 'WIS' },
| |
| { key: 'CHA', label: 'CHA' },
| |
| { key: 'SV_MAGIC', label: 'SV Mag' },
| |
| { key: 'SV_FIRE', label: 'SV Fire' },
| |
| { key: 'SV_COLD', label: 'SV Cold' },
| |
| { key: 'SV_POISON', label: 'SV Poi' },
| |
| { key: 'SV_DISEASE', label: 'SV Dis' },
| |
| { key: 'svVoid', label: 'SV Void' },
| |
| { key: 'source', label: 'Source' },
| |
| { key: 'level', label: 'Level', enrich: true },
| |
| { key: 'zone', label: 'Zone', enrich: true },
| |
| { key: 'zoneAvg', label: 'Zone Avg Lvl', enrich: true }
| |
| ];
| |
| | |
| // ---- state / persistence ------------------------------------------------
| |
| | |
| function defaultState() {
| |
| var comps = {};
| |
| SCORE_COMPONENTS.concat( SV_COMPONENTS ).forEach( function ( c ) {
| |
| comps[ c.key ] = false;
| |
| } );
| |
| comps.totalSV = false;
| |
| var cols = {};
| |
| COLUMNS.forEach( function ( c ) {
| |
| cols[ c.key ] = !c.enrich; // hide enrichment columns by default in v1
| |
| } );
| |
| return {
| |
| level: 0,
| |
| classes: [ '', '', '' ],
| |
| slots: [ 'Primary' ],
| |
| weaponTypes: [],
| |
| zones: [],
| |
| minLevel: 1,
| |
| maxLevel: 100,
| |
| components: comps,
| |
| effectBonus: { focus: 25, worn: 25, click: 25, combat: 25 },
| |
| 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 );
| |
| }
| |
| | |
| // Parse one ClassSlotEquip HTML fragment into item records.
| |
| function parseFragment( html, slot ) {
| |
| var $frag = $( '<div>' ).html( html );
| |
| var items = [];
| |
| $frag.find( 'table.eoTable tr, table.sortable tr' ).each( function () {
| |
| var $tr = $( this );
| |
| if ( $tr.find( 'th' ).length ) {
| |
| return; // header
| |
| }
| |
| var $cells = $tr.children( 'td' );
| |
| if ( !$cells.length ) {
| |
| return;
| |
| }
| |
| var $nameCell = $cells.eq( 0 );
| |
| var $link = $nameCell.find( 'a' ).first();
| |
| var $hb = $nameCell.find( '.hb' ).first();
| |
| var $itemdata = $nameCell.find( '.itemdata' ).first();
| |
| if ( !$itemdata.length ) {
| |
| return;
| |
| }
| |
| var fields = EQLIS.parseItemData( $itemdata.text() );
| |
| // Source: the "Drops From" column (3rd, index 2).
| |
| var $src = $cells.eq( 2 );
| |
| var srcText = $.trim( $src.text() );
| |
| var sourceType = /quest/i.test( srcText ) ? 'quest' : 'drop';
| |
| | |
| items.push( {
| |
| name: $.trim( $link.text() ) || $.trim( $nameCell.text() ),
| |
| href: $link.attr( 'href' ) || null,
| |
| popoverHtml: $hb.length ? $hb.prop( 'outerHTML' ) : '',
| |
| fields: fields,
| |
| slot: slot,
| |
| source: { type: sourceType, text: srcText, html: $src.html() },
| |
| // enrichment placeholders. available: true|false|null (null = unknown)
| |
| enrich: { focus: null, level: null, zone: null, available: null }
| |
| } );
| |
| } );
| |
| return items;
| |
| }
| |
| | |
| // ---- enrichment: item -> zone + availability (Phase 1, via categories) ----
| |
| | |
| function titleFromHref( href, name ) {
| |
| if ( href ) {
| |
| var t = href.replace( /^https?:\/\/[^/]+/, '' ).replace( /^\/(wiki\/)?/, '' ).split( '#' )[ 0 ];
| |
| try { t = decodeURIComponent( t ); } catch ( e ) {}
| |
| if ( t ) { return t.replace( /_/g, ' ' ); }
| |
| }
| |
| return name || '';
| |
| }
| |
| | |
| function resolveFinalTitle( title, alias ) {
| |
| var seen = {};
| |
| while ( alias[ title ] && !seen[ title ] ) {
| |
| seen[ title ] = 1;
| |
| title = alias[ title ];
| |
| }
| |
| return title;
| |
| }
| |
| | |
| // Batched prop=categories with redirect/normalize + continuation handling.
| |
| // Resolves to { cats: {finalTitle:[categoryName,...]}, alias: {from:to} }.
| |
| function fetchCategories( titles ) {
| |
| var result = {};
| |
| var alias = {};
| |
| var batches = [];
| |
| var i;
| |
| for ( i = 0; i < titles.length; i += 50 ) {
| |
| batches.push( titles.slice( i, i + 50 ) );
| |
| }
| |
| | |
| return mw.loader.using( [ 'mediawiki.api' ] ).then( function () {
| |
| var api = new mw.Api();
| |
| | |
| function runBatch( batch, cont ) {
| |
| var params = {
| |
| action: 'query',
| |
| prop: 'categories',
| |
| titles: batch.join( '|' ),
| |
| cllimit: 'max',
| |
| redirects: 1,
| |
| format: 'json'
| |
| };
| |
| if ( cont ) {
| |
| params.clcontinue = cont;
| |
| }
| |
| return api.get( params ).then( function ( data ) {
| |
| var q = data.query || {};
| |
| ( q.normalized || [] ).forEach( function ( n ) { alias[ n.from ] = n.to; } );
| |
| ( q.redirects || [] ).forEach( function ( r ) { alias[ r.from ] = r.to; } );
| |
| var pages = q.pages || {};
| |
| Object.keys( pages ).forEach( function ( pid ) {
| |
| var pg = pages[ pid ];
| |
| if ( !pg.title ) { return; }
| |
| var cats = ( pg.categories || [] ).map( function ( c ) {
| |
| return c.title.replace( /^Category:/, '' );
| |
| } );
| |
| result[ pg.title ] = ( result[ pg.title ] || [] ).concat( cats );
| |
| } );
| |
| if ( data.continue && data.continue.clcontinue ) {
| |
| return runBatch( batch, data.continue.clcontinue );
| |
| }
| |
| } );
| |
| }
| |
| | |
| return $.when.apply( $, batches.map( function ( b ) { return runBatch( b, null ); } ) );
| |
| } ).then( function () {
| |
| return { cats: result, alias: alias };
| |
| } );
| |
| }
| |
| | |
| // Set item.enrich.zone / .available from each item's categories. | |
| // Availability = "drops in a zone that is in ZONE_AVG_LEVEL" (Requirement #1).
| |
| // Quest items carry no zone category (zone is on the quest page) — kept as
| |
| // available:null pending Phase 2 quest resolution rather than excluded.
| |
| function enrichZones( items ) {
| |
| var titles = [];
| |
| var seen = {};
| |
| items.forEach( function ( it ) {
| |
| it.title = titleFromHref( it.href, it.name );
| |
| if ( it.title && !seen[ it.title ] ) {
| |
| seen[ it.title ] = 1;
| |
| titles.push( it.title );
| |
| }
| |
| } );
| |
| if ( !titles.length ) {
| |
| return $.Deferred().resolve( items ).promise();
| |
| }
| |
| return fetchCategories( titles ).then( function ( res ) {
| |
| items.forEach( function ( it ) {
| |
| var cats = res.cats[ resolveFinalTitle( it.title, res.alias ) ] ||
| |
| res.cats[ it.title ] || [];
| |
| var zones = [];
| |
| cats.forEach( function ( cat ) {
| |
| var cz = canonicalZone( cat );
| |
| if ( cz && zones.indexOf( cz ) === -1 ) {
| |
| zones.push( cz );
| |
| }
| |
| } );
| |
| var isQuest = it.source.type === 'quest' ||
| |
| cats.indexOf( 'Quest Items' ) !== -1;
| |
| | |
| if ( zones.length === 1 ) {
| |
| it.enrich.zone = zones[ 0 ];
| |
| it.enrich.available = true;
| |
| } else if ( zones.length > 1 ) {
| |
| it.enrich.zone = 'multiple';
| |
| it.enrich.available = true;
| |
| } else if ( isQuest ) {
| |
| it.enrich.zone = 'quest'; // Phase 2 resolves the actual start zone
| |
| it.enrich.available = null; // unknown; keep for now
| |
| } else {
| |
| it.enrich.zone = null;
| |
| it.enrich.available = false; // no in-scope zone -> out of scope
| |
| }
| |
| } );
| |
| return items;
| |
| }, function () {
| |
| return items; // enrichment failed -> keep everything, unfiltered
| |
| } );
| |
| }
| |
| | |
| var ClientDataSource = {
| |
| /**
| |
| * fetchItems(query) -> Promise<Array<itemRecord>>
| |
| * query: { classes:[a,b,c], slots:[...] }
| |
| * Fans out one request per slot, merges, de-dupes by name+slot,
| |
| * then enriches each item with zone + availability.
| |
| */
| |
| fetchItems: function ( query ) {
| |
| var slots = query.slots && query.slots.length ? query.slots : [ 'Primary' ];
| |
| var requests = slots.map( function ( slot ) {
| |
| return $.ajax( {
| |
| url: classSlotEquipUrl( query.classes, slot ),
| |
| dataType: 'html'
| |
| } ).then( function ( html ) {
| |
| return parseFragment( html, slot );
| |
| }, function () {
| |
| return []; // one slot failing shouldn't kill the whole run
| |
| } );
| |
| } );
| |
| | |
| return $.when.apply( $, requests ).then( function () {
| |
| var merged = [];
| |
| var seen = {};
| |
| var lists = Array.prototype.slice.call( arguments );
| |
| // $.when with 1 request passes the value directly, not in an array-of-args.
| |
| if ( requests.length === 1 ) {
| |
| lists = [ arguments[ 0 ] ];
| |
| }
| |
| lists.forEach( function ( list ) {
| |
| ( list || [] ).forEach( function ( it ) {
| |
| var k = it.name + '|' + it.slot;
| |
| if ( !seen[ k ] ) {
| |
| seen[ k ] = true;
| |
| merged.push( it );
| |
| }
| |
| } );
| |
| } );
| |
| return merged;
| |
| } ).then( enrichZones );
| |
| }
| |
| };
| |
| | |
| // 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;
| |
| }
| |
| // Weapon type
| |
| if ( state.weaponTypes.length ) {
| |
| var t = normalizeType( item.fields.type );
| |
| if ( state.weaponTypes.indexOf( t ) === -1 ) {
| |
| return false;
| |
| }
| |
| }
| |
| // Level range (enrichment-dependent). In v1, item.enrich.level is null;
| |
| // only filter when we actually have a level so we don't hide everything.
| |
| var lvl = item.enrich.level;
| |
| if ( lvl != null ) {
| |
| if ( lvl < state.minLevel || lvl > state.maxLevel ) {
| |
| return false;
| |
| }
| |
| }
| |
| // Zone (enrichment-dependent) — match via canonical zone name so wiki name
| |
| // variants ("The …", Guk aliases) line up with the ZONE_AVG_LEVEL keys.
| |
| if ( state.zones.length && item.enrich.zone != null ) {
| |
| var itemZone = canonicalZone( item.enrich.zone );
| |
| var matched = item.enrich.zone === 'multiple' || state.zones.some( function ( sel ) {
| |
| return canonicalZone( sel ) === itemZone;
| |
| } );
| |
| if ( !matched ) {
| |
| return false;
| |
| }
| |
| }
| |
| return true;
| |
| }
| |
| | |
| function scoreOpts( state ) {
| |
| return {
| |
| level: state.level,
| |
| components: state.components,
| |
| effectBonus: state.effectBonus
| |
| };
| |
| }
| |
| | |
| // ---- 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 === 'delay' ) {
| |
| return item.fields.delay || '';
| |
| }
| |
| if ( key === 'svVoid' ) {
| |
| var v = EQLIS.svVoidBonus( item.fields, level );
| |
| return v ? '+' + v : '';
| |
| }
| |
| // scalable stat key (DMG / AC / STR / SV_*)
| |
| if ( Object.prototype.hasOwnProperty.call( item.fields.stats, key ) ) {
| |
| return EQLIS.scaledStat( item.fields, key, level );
| |
| }
| |
| return '';
| |
| }
| |
| | |
| function effectCell( item, type ) {
| |
| var names = item.fields.effects
| |
| .filter( function ( e ) { return e.type === type; } )
| |
| .map( function ( e ) { return e.name; } );
| |
| return names.join( ', ' );
| |
| }
| |
| | |
| function columnByKey( key ) {
| |
| for ( var i = 0; i < COLUMNS.length; i++ ) {
| |
| if ( COLUMNS[ i ].key === key ) {
| |
| return COLUMNS[ i ];
| |
| }
| |
| }
| |
| return null;
| |
| }
| |
| | |
| // Reconcile a saved columnOrder with the current COLUMNS set: drop unknown/dupe
| |
| // keys, append any columns missing from the saved order.
| |
| 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' );
| |
| | |
| // score + filter, then sort by score desc for a useful default
| |
| var rows = items
| |
| .filter( function ( it ) { return passesFilters( it, state ); } )
| |
| .map( function ( it ) {
| |
| it._score = EQLIS.scoreItem( it.fields, scoreOpts( state ) ).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':
| |
| $td.text( it.enrich.focus || '' );
| |
| break;
| |
| case 'worn': case 'click': case 'combat':
| |
| $td.text( effectCell( it, c.key ) );
| |
| break;
| |
| case 'source':
| |
| $td.html( it.source.html || it.source.text || '' );
| |
| break;
| |
| case 'level':
| |
| $td.text( it.enrich.level == null ? '—' : it.enrich.level );
| |
| break;
| |
| case 'zone':
| |
| $td.text( it.enrich.zone == null ? '—' : it.enrich.zone );
| |
| break;
| |
| case 'zoneAvg':
| |
| var avg = zoneAverageLevel( it.enrich.zone );
| |
| $td.text( avg == null ? '—' : avg );
| |
| 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-era drops are filtered out; quest items and the Level / Focus ' +
| |
| 'columns are still being wired up.' ) );
| |
| | |
| var $controls = el( 'div', { 'class': 'eqlis-controls' } );
| |
| | |
| // Upgrade level slider
| |
| 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 );
| |
| $controls.append( $lvlWrap );
| |
| | |
| // Classes
| |
| 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 );
| |
| } );
| |
| $controls.append( $clsRow );
| |
| | |
| // Slots
| |
| var $slotWrap = el( 'div', { 'class': 'eqlis-field' } );
| |
| $slotWrap.append( el( 'label', null, 'Slots: ' ) );
| |
| $slotWrap.append( multiSelect( 'eqlis-slots', SLOTS, state.slots ) );
| |
| $controls.append( $slotWrap );
| |
| | |
| // Weapon types
| |
| var $wtWrap = el( 'div', { 'class': 'eqlis-field' } );
| |
| $wtWrap.append( el( 'label', null, 'Weapon Type: ' ) );
| |
| $wtWrap.append( multiSelect( 'eqlis-wtypes', WEAPON_TYPES, state.weaponTypes ) );
| |
| $controls.append( $wtWrap );
| |
| | |
| // Zones (currently-available list from ZONE_AVG_LEVEL)
| |
| var $zoneWrap = el( 'div', { 'class': 'eqlis-field' } );
| |
| $zoneWrap.append( el( 'label', null, 'Zone: ' ) );
| |
| $zoneWrap.append( multiSelect( 'eqlis-zones', zoneDisplayNames(), state.zones ) );
| |
| $controls.append( $zoneWrap );
| |
| | |
| // Level range
| |
| 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' } ) );
| |
| $controls.append( $lr );
| |
| | |
| // Score components
| |
| var $score = el( 'div', { 'class': 'eqlis-field eqlis-scorebox' } );
| |
| $score.append( el( 'div', { 'class': 'eqlis-legend' }, 'Item Score components' ) );
| |
| SCORE_COMPONENTS.forEach( function ( c ) {
| |
| var $lab = el( 'label', { 'class': 'eqlis-check' } );
| |
| var $cb = el( 'input', { type: 'checkbox', 'data-comp': c.key } );
| |
| if ( state.components[ c.key ] ) { $cb.prop( 'checked', true ); }
| |
| $lab.append( $cb ).append( document.createTextNode( ' ' + c.label ) );
| |
| $score.append( $lab );
| |
| } );
| |
| // Total SV toggle
| |
| var $totalLab = el( 'label', { 'class': 'eqlis-check' } );
| |
| var $totalCb = el( 'input', { type: 'checkbox', 'data-comp': 'totalSV' } );
| |
| if ( state.components.totalSV ) { $totalCb.prop( 'checked', true ); }
| |
| $totalLab.append( $totalCb ).append( document.createTextNode( ' Total SV (sum of saves)' ) );
| |
| $score.append( $totalLab );
| |
| SV_COMPONENTS.forEach( function ( c ) {
| |
| var $lab = el( 'label', { 'class': 'eqlis-check eqlis-sv' } );
| |
| var $cb = el( 'input', { type: 'checkbox', 'data-comp': c.key } );
| |
| if ( state.components[ c.key ] ) { $cb.prop( 'checked', true ); }
| |
| $lab.append( $cb ).append( document.createTextNode( ' ' + c.label ) );
| |
| $score.append( $lab );
| |
| } );
| |
| // disable individual saves when Total SV is on
| |
| function syncSvDisabled() {
| |
| var on = $totalCb.prop( 'checked' );
| |
| $score.find( '.eqlis-sv input[data-comp]' ).each( function () {
| |
| if ( $( this ).attr( 'data-comp' ) !== 'svVoid' ) {
| |
| $( this ).prop( 'disabled', on );
| |
| }
| |
| } );
| |
| }
| |
| $totalCb.on( 'change', syncSvDisabled );
| |
| syncSvDisabled();
| |
| | |
| // Effects bonus
| |
| 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 );
| |
| $controls.append( $score );
| |
| | |
| // Column show/hide + reorder. Changes apply on the next List Results (no live
| |
| // re-render), consistent with the rest of the panel.
| |
| normalizeColumnOrder( state );
| |
| var $cols = el( 'div', { 'class': 'eqlis-field eqlis-colbox' } );
| |
| $cols.append( el( 'div', { 'class': 'eqlis-legend' }, 'Columns (▲▼ to reorder)' ) );
| |
| var $colList = el( 'div', { 'class': 'eqlis-collist' } );
| |
| $cols.append( $colList );
| |
| $controls.append( $cols );
| |
| | |
| function moveColumn( idx, dir ) {
| |
| var j = idx + dir;
| |
| if ( j < 0 || j >= state.columnOrder.length ) {
| |
| return;
| |
| }
| |
| var tmp = state.columnOrder[ idx ];
| |
| state.columnOrder[ idx ] = state.columnOrder[ j ];
| |
| state.columnOrder[ j ] = tmp;
| |
| saveState( state );
| |
| renderColumnList();
| |
| }
| |
| | |
| function renderColumnList() {
| |
| $colList.empty();
| |
| state.columnOrder.forEach( function ( key, idx ) {
| |
| var c = columnByKey( key );
| |
| if ( !c ) { return; }
| |
| var $row = el( 'div', { 'class': 'eqlis-colrow' } );
| |
| var $cb = el( 'input', { type: 'checkbox', 'data-col': c.key } );
| |
| if ( c.always ) {
| |
| $cb.prop( 'checked', true ).prop( 'disabled', true );
| |
| } else if ( state.columns[ c.key ] ) {
| |
| $cb.prop( 'checked', true );
| |
| }
| |
| var $up = el( 'button', { type: 'button', 'class': 'eqlis-colmove', title: 'Move up' }, '▲' );
| |
| var $down = el( 'button', { type: 'button', 'class': 'eqlis-colmove', title: 'Move down' }, '▼' );
| |
| $up.on( 'click', function () { moveColumn( idx, -1 ); } );
| |
| $down.on( 'click', function () { moveColumn( idx, 1 ); } );
| |
| $row
| |
| .append( $cb )
| |
| .append( document.createTextNode( ' ' + c.label + ( c.enrich ? ' *' : '' ) + ' ' ) )
| |
| .append( $up ).append( $down );
| |
| $colList.append( $row );
| |
| } );
| |
| }
| |
| | |
| renderColumnList();
| |
| | |
| $root.append( $controls );
| |
| | |
| // List Results button + status + results container
| |
| 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 );
| |
| | |
| // ---- read UI back into state ----
| |
| 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' );
| |
| } );
| |
| $eff.find( 'input[data-effbonus]' ).each( function () {
| |
| state.effectBonus[ $( this ).attr( 'data-effbonus' ) ] = parseFloat( $( this ).val() ) || 0;
| |
| } );
| |
| $cols.find( 'input[data-col]' ).each( function () {
| |
| state.columns[ $( this ).attr( 'data-col' ) ] = $( this ).prop( 'checked' );
| |
| } );
| |
| 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 );
| |
| // let the wiki instrument popovers + item-level sliders inside results
| |
| if ( mw.hook ) { mw.hook( 'wikipage.content' ).fire( $results ); }
| |
| if ( window.eqlItemLevelSliderRefresh ) { window.eqlItemLevelSliderRefresh( $results[ 0 ] ); }
| |
| if ( mw.loader ) {
| |
| mw.loader.using( [ 'jquery.tablesorter' ] ).then( function () {
| |
| try { $results.find( 'table.sortable' ).tablesorter(); } catch ( e ) {}
| |
| } );
| |
| }
| |
| }
| |
| | |
| $btn.on( 'click', function () {
| |
| collect();
| |
| var sel = state.classes.filter( Boolean );
| |
| if ( !sel.length ) {
| |
| $status.text( 'Select at least one class.' ).addClass( 'eqlis-err' );
| |
| return;
| |
| }
| |
| $status.removeClass( 'eqlis-err' ).text( 'Loading…' );
| |
| $btn.prop( 'disabled', true );
| |
| ACTIVE_DATASOURCE.fetchItems( { classes: state.classes, slots: state.slots } )
| |
| .then( function ( items ) {
| |
| lastItems = items;
| |
| $status.text( '' );
| |
| render();
| |
| }, function ( e ) {
| |
| $status.text( 'Load failed: ' + ( e && e.message ? e.message : 'unknown error' ) ).addClass( 'eqlis-err' );
| |
| } )
| |
| .always( function () { $btn.prop( 'disabled', false ); } );
| |
| } );
| |
| | |
| // NOTE: intentionally no live re-render on control changes. Per spec, the
| |
| // table only (re)builds when List Results is pressed — moving the upgrade
| |
| // slider or toggling score/columns changes persisted state but does not
| |
| // touch the results until the next List Results click.
| |
| }
| |
| | |
| // ---- mount --------------------------------------------------------------
| |
| | |
| function init( root ) {
| |
| var host = ( root || document ).querySelector ?
| |
| ( root || document ).querySelector( '#' + MOUNT_ID ) :
| |
| document.getElementById( MOUNT_ID );
| |
| if ( !host || host.getAttribute( 'data-eqlis-ready' ) === '1' ) {
| |
| return;
| |
| }
| |
| host.setAttribute( 'data-eqlis-ready', '1' );
| |
| buildUI( $( host ), loadState() );
| |
| }
| |
| | |
| $( function () { init( document ); } );
| |
| if ( mw.hook ) {
| |
| mw.hook( 'wikipage.content' ).add( function ( $c ) {
| |
| // don't recurse into our own results
| |
| if ( $c && $c.closest && $c.closest( '.eqlis-results' ).length ) { return; }
| |
| init( document );
| |
| } );
| |
| }
| |
| | |
| window.eqlItemScoreInit = init;
| |
| | |
| }( jQuery, mediaWiki ) );
| |