diff --git a/README.md b/README.md index b0e86f3..1a4d145 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Upgrade Finder searches the active season's loot and simulates what each item wo - Mythic+: every dungeon in the current rotation, selectable per dungeon, including reissued older dungeons. - Great Vault: raid, dungeon and world rows, each with its own track. Vault rows use all bosses and dungeons, without trash drops. - Delves: the season's delve loot table. -- Crafted: epic profession gear at a chosen item level with a chosen pair of secondary stats. +- Crafted: epic profession gear at a chosen item level. A piece with open secondary stats is tried in every legal pair (or just the one you pick), and the results keep the best pair of each piece; a piece with fixed stats is tried as it is. The season is read from the pinned client data: its bonus-roll group names the raid encounters and Mythic+ dungeons, and its upgrade tracks (Adventurer to Myth) supply the bonus IDs SimC uses to set item level. No instance, item or bonus ID is hardcoded. Reissued older-expansion items are accepted only when they appear in the active season's raid or Mythic+ loot tables; delve and crafted items must be current expansion. @@ -51,7 +51,7 @@ Candidates are filtered for your class and specialization: allowed classes, loot Results can be filtered by slot: pick Neck, Rings or Main hand and every item measured for that slot is listed — the upgrades, the ones that were no better, and the ones screening cut — so "why is this item not here?" has an answer on the page. The unfiltered lists are unchanged. -Each scenario runs in two SimC profileset runs. Screening simulates every candidate with at most 2,000 iterations and a 0.5% target error. Candidates whose screened DPS could beat the current gear within the combined uncertainty then go to a final round with your iteration and target-error settings. The final round takes up to 24, 48 or 96 candidates, with a quota per slot. Results show the best upgrade per boss, dungeon or source, every measured upgrade, and screening results that were not simulated again. Screening-only numbers are labelled. A finished search also has its own **upgrade report** page: the character, the gear you wear slot by slot with the best upgrade for each, the best item per boss and dungeon, every final-round upgrade and the embellishment pairs. Like the weapon tier list it has no script of its own, so it can be opened from the app or downloaded as one HTML file and sent on. A search is limited to 800 candidates. Some crafted pieces require the matching profession to equip, and the data does not say which ones. +Each scenario runs in two SimC profileset runs. Screening simulates every candidate with at most 2,000 iterations and a 0.5% target error. Candidates whose screened DPS could beat the current gear within the combined uncertainty then go to a final round with your iteration and target-error settings. The final round takes up to 24, 48 or 96 items, with a quota per slot; every stat pair of a crafted piece that could win goes on with it. A slot where nothing won still lists the best item measured for it, so a slot that was tried and lost does not look like one that was never searched. Results show the best upgrade per boss, dungeon or source, every measured upgrade, and screening results that were not simulated again. Screening-only numbers are labelled. A finished search also has its own **upgrade report** page: the character, the gear you wear slot by slot with the best upgrade for each, the best item per boss and dungeon, every final-round upgrade and the embellishment pairs. Like the weapon tier list it has no script of its own, so it can be opened from the app or downloaded as one HTML file and sent on. A search is limited to 800 candidates. Some crafted pieces require the matching profession to equip, and the data does not say which ones. ## Great Vault diff --git a/desktop/package-lock.json b/desktop/package-lock.json index dd52c9d..fbb5ff8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.35.0", + "version": "1.36.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.35.0", + "version": "1.36.0", "license": "GPL-3.0-or-later", "dependencies": { "electron-updater": "^6.8.9" diff --git a/desktop/package.json b/desktop/package.json index 3e28475..4c1cdb7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -2,7 +2,7 @@ "name": "simc-lab", "productName": "SimC Lab", "desktopName": "simc-lab.desktop", - "version": "1.35.0", + "version": "1.36.0", "description": "Local SimulationCraft workbench with upgrade finder and tank simulation.", "author": "Roburmaster", "private": true, diff --git a/lib/upgradepage.mjs b/lib/upgradepage.mjs index db3537c..89f425d 100644 --- a/lib/upgradepage.mjs +++ b/lib/upgradepage.mjs @@ -2,6 +2,7 @@ // slot by slot with the best upgrade for each, the best item per boss and dungeon, and every measured upgrade. // Like the weapon tier list it carries no script of its own, so it survives the app's content policy and reads // the same after it is saved and sent on; only Wowhead's tooltip script is fetched. +import {variantOf} from './upgrades.mjs'; const escape=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); const signed=(n,digits=2)=>`${n>=0?'+':''}${Number(n).toFixed(digits)}`; @@ -37,13 +38,22 @@ export function measured(job,scenario){ const delta=row.dps-base.dps,error=row.error95!=null&&base.error95!=null?Math.hypot(row.error95,base.error95):null; const percent=100*delta/Math.max(1,base.dps),tanky=Number.isFinite(row.score); const entry={...row,c,delta,percent,rank:tanky?row.score:percent,tanky,uncertain:tanky?Math.abs(row.score)<=(row.scoreError||0):error!==null&&Math.abs(delta)<=error}; - const id=family(c.slot)+'|'+c.value;const previous=best.get(id); + const id=family(c.slot)+'|'+variantOf(c);const previous=best.get(id); if(!previous||row.stage>previous.stage||row.stage===previous.stage&&entry.rank>previous.rank)best.set(id,entry); } const rows=[...best.values()]; return {rows,upgrades:rows.filter(r=>r.stage===2&&r.rank>0).sort((a,b)=>b.rank-a.rank),baselines,candidates}; } +// For a slot where nothing won: the best item measured for it anyway, final round over screening, so a slot that +// was tried and lost does not read like one that was never searched. +export function bestTested(rows,key){ + const tried=rows.filter(r=>family(r.c.slot)===key);if(!tried.length)return null; + const stage=Math.max(...tried.map(r=>r.stage)); + const row=tried.filter(r=>r.stage===stage).sort((a,b)=>b.rank-a.rank)[0]; + return {row,count:tried.length}; +} + function pairRows(job,scenario,base){ const pairs=(job.upgrade.pairs||[]).filter(p=>p.scenario===scenario);if(!pairs.length||!base)return []; return job.results.filter(r=>r.scenario===scenario&&r.stage===3&&r.status==='complete').map(r=>{ @@ -65,6 +75,12 @@ function scenarioSection(job,scenario,index,equipped){ const max=Math.max(...upgrades.map(r=>r.rank),0.01); const notes=stages.filter(s=>s.status==='failed'||s.status==='skipped').map(s=>`

${escape(s.status==='failed'?`A stage failed: ${s.error}`:s.reason)}

`).join(''); + const testedNote=key=>{ + const t=bestTested(rows,key);if(!t)return ''; + const {row}=t,c=row.c; + return `
Best tested${link(c.itemId,c.name,c.value)}${escape(c.sources[0]?.label||c.itemLevel)}${c.embellishment&&!c.sources[0]?.label?.includes(c.embellishment)?` · ${escape(c.embellishment)}`:''} · ${signed(row.rank)}${unit}${row.uncertain?' · within noise':''}${row.stage===1?' · screening only':''}${t.count>1?` · ${t.count} tested`:''}
`; + }; + // Slot by slot: what you wear and the best measured upgrade for it. const slotCards=families.map(([key,name])=>{ const worn=Object.entries(equipped).filter(([slot])=>family(slot)===key); @@ -75,7 +91,7 @@ function scenarioSection(job,scenario,index,equipped){
${escape(name)}${best?`${signed(best.rank)}${unit}`:''}
${worn.map(([,g])=>`${link(g.id,g.name,g.value)}`).join('')||'Empty'}
${best?`
↑${link(best.c.itemId,best.c.name,best.c.value)}${escape(best.c.sources[0]?.label||best.c.itemLevel)}${best.c.embellishment&&!best.c.sources[0]?.label?.includes(best.c.embellishment)?` · ${escape(best.c.embellishment)}`:''}${best.uncertain?' · within noise':''}
` - :`

${tried?'Nothing measured beat it.':'Not searched.'}

`} + :`

${tried?'Nothing measured beat it.':'Not searched.'}

${testedNote(key)}`} `; }).join(''); @@ -156,6 +172,9 @@ h3 span{color:var(--muted);font-weight:500;letter-spacing:0;order:1} .best .arrow{color:var(--green);font-weight:800} .best small,td small,.item small{display:block;color:var(--muted);font-size:11px;font-weight:400;margin-top:2px} .none{margin:0;font-size:12px;color:var(--muted);padding-top:8px;border-top:1px dashed var(--line)} +.tested{display:grid;gap:2px;font-size:12.5px;color:#c3ccdb} +.tested>span{font-size:10px;letter-spacing:1.4px;text-transform:uppercase;color:var(--muted)} +.tested small{display:block;color:var(--muted);font-size:11px} .sources{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px} .origin{background:var(--surface);border:1px solid var(--line);border-radius:13px;padding:14px 16px} .origin h4{font-size:13px;color:var(--amber);margin-bottom:8px} diff --git a/lib/upgrades.mjs b/lib/upgrades.mjs index 8b41677..9e5d8c2 100644 --- a/lib/upgrades.mjs +++ b/lib/upgrades.mjs @@ -13,6 +13,9 @@ const armorInventory=new Set([1,3,5,6,7,8,9,10,20]); const shieldClasses=new Set(['warrior','paladin','shaman']); const statNames={32:'Critical Strike',36:'Haste',40:'Versatility',49:'Mastery'}; export const weaponKinds={17:'two',15:'two',26:'two',13:'one',21:'one',22:'one',14:'shield',23:'held'}; +// What makes two candidates the same item to rank: one crafted piece in several stat pairs is a single item, anything +// else is told apart by its whole line. Results keep the best pair and drop the rest from the lists. +export const variantOf=c=>c.craftedStat?`${c.itemId}|${c.embellishment||''}`:c.value; export const sourceKinds={raid:'Raid',mplus:'Mythic+',delves:'Delves',vault:'Great Vault',crafted:'Crafted'}; export const limits={finalists:[24,48,96]}; @@ -224,15 +227,18 @@ export function buildCandidates(profile,request,data,catalog,specId){ } if(options.crafted?.enabled){ if(!data.crafted)throw new Error('No crafted gear table is available.'); - const itemLevel=Number(options.crafted.itemLevel),stat=data.craftedStats.find(s=>s.bonusId===Number(options.crafted.stats)); + const itemLevel=Number(options.crafted.itemLevel); if(!Number.isInteger(itemLevel)||itemLevel<1||itemLevel>1000)throw new Error('Crafted item level must be between 1 and 1000.'); - if(!stat)throw new Error('Choose the two secondary stats for crafted gear.'); + // Every legal pair of secondary stats by default; a pair or a list of them narrows the search. + const wantedStats=options.crafted.stats,asked=wantedStats===undefined||wantedStats==='all'?null:(Array.isArray(wantedStats)?wantedStats:[wantedStats]).map(Number); + const stats=asked?data.craftedStats.filter(s=>asked.includes(s.bonusId)):data.craftedStats; + if(!stats.length||asked&&stats.length!==new Set(asked).size)throw new Error('Choose the secondary stats for crafted gear.'); // Every embellishment by default; an empty list means plain crafted gear only. const all=data.embellishments||[],wantedEmb=options.crafted.embellishments; if(wantedEmb!==undefined&&!Array.isArray(wantedEmb))throw new Error('Embellishments: send a list.'); for(const id of wantedEmb||[])if(!all.some(e=>e.id===Number(id)))throw new Error(`Embellishments: unknown selection ${id}.`); const embellishments=wantedEmb===undefined?all:all.filter(e=>wantedEmb.map(Number).includes(e.id)); - pools.push({kinds:['crafted'],groups:null,crafted:{stat,itemLevel,embellishments},itemLevel,label:s=>`Crafted · ${s.groupName} · ${stat.name} · ${itemLevel}`,origin:'crafted'}); + pools.push({kinds:['crafted'],groups:null,crafted:{stats,itemLevel,embellishments},itemLevel,label:(s,d,stat)=>`Crafted · ${s.groupName}${stat?` · ${stat.name}`:''} · ${itemLevel}`,origin:'crafted'}); } const equipped=equippedLimits(profile,data.itemLimits);const blocked=new Map(); if(!pools.length)throw new Error('Select at least one gear source.'); @@ -243,22 +249,26 @@ export function buildCandidates(profile,request,data,catalog,specId){ // A crafted piece is tried plain and with every chosen embellishment its crafting slots accept. const craftSlots=new Set((item.profession?.optionalCraftingSlots||[]).map(s=>s.id)); const variants=pool.crafted?[null,...pool.crafted.embellishments.filter(e=>e.slots.some(id=>craftSlots.has(id)))]:[null]; - for(const slot of placements(item,profile,catalog))for(const emb of variants){ + // Only a piece with open secondary stats (the placeholder stats 24 and 25) takes a pair, and then each legal pair + // is a candidate of its own. A piece with fixed stats is one candidate: a pair bonus would change nothing on it. + const open=!!pool.crafted&&!!item.stats?.some(s=>s.id===24||s.id===25); + const pairs=open?pool.crafted.stats:[null]; + for(const slot of placements(item,profile,catalog))for(const emb of variants)for(const stat of pairs){ if(!wanted.includes(slot))continue; const drop=pool.drop?.(source); // Drops keep their default bonus list: it carries effects such as Venomcursed and the jewelry socket. - const bonuses=pool.crafted?[pool.crafted.stat.bonusId,...(emb?.bonusIds||[])]:[...(item.bonusLists||[]),drop?drop.bonusId:pool.bonus]; - const bonus=`bonus_id=${bonuses.join('/')}`+(pool.crafted?`,ilevel=${pool.crafted.itemLevel}`:''); - const value=[`,id=${item.id}`,bonus,...carried(profile.gear[slot]?.value,item,bonuses,data.bonusSockets)].join(','); + const bonuses=pool.crafted?[...(stat?[stat.bonusId]:[]),...(emb?.bonusIds||[])]:[...(item.bonusLists||[]),drop?drop.bonusId:pool.bonus]; + const bonus=[...(bonuses.length?[`bonus_id=${bonuses.join('/')}`]:[]),...(pool.crafted?[`ilevel=${pool.crafted.itemLevel}`]:[])]; + const value=[`,id=${item.id}`,...bonus,...carried(profile.gear[slot]?.value,item,bonuses,data.bonusSockets)].join(','); const key=`${slot}=${value}`; if(profile.gear[slot]?.value===value)continue; // Nothing that breaks an equip limit is offered: two embellishments at most, counting what stays equipped. const uses=itemLimitsOf(item.id,bonuses,data.itemLimits); if(uses.size){const over=overLimit(equipped,{[slot]:uses},clearedBy(item,slot,profile),data.itemLimits);if(over.length){blocked.set(`${item.id}|${emb?.id||0}`,over);continue;}} - const label=pool.label(source,drop)+(emb?` · ${emb.name}`:'')+(source.token?` · ${source.token}`:''); + const label=pool.label(source,drop,stat)+(emb?` · ${emb.name}`:'')+(source.token?` · ${source.token}`:''); const existing=candidates.get(key); if(existing){if(!existing.sources.some(s=>s.label===label))existing.sources.push({origin:pool.origin,group:`${pool.origin}:${source.group}`,groupName:source.groupName,label});continue;} - candidates.set(key,{slot,itemId:item.id,name:item.name,itemLevel:drop?drop.itemLevel:pool.itemLevel,value,line:key,...(emb?{embellishment:emb.name}:{}),...(uses.size?{limits:[...uses]}:{}),sources:[{origin:pool.origin,group:`${pool.origin}:${source.group}`,groupName:source.groupName,label}]}); + candidates.set(key,{slot,itemId:item.id,name:item.name,itemLevel:drop?drop.itemLevel:pool.itemLevel,value,line:key,...(stat?{craftedStat:stat.name}:{}),...(emb?{embellishment:emb.name}:{}),...(uses.size?{limits:[...uses]}:{}),sources:[{origin:pool.origin,group:`${pool.origin}:${source.group}`,groupName:source.groupName,label}]}); } } const list=[...candidates.values()].sort((a,b)=>allSlots.indexOf(a.slot)-allSlots.indexOf(b.slot)||b.itemLevel-a.itemLevel||a.name.localeCompare(b.name)); @@ -293,7 +303,7 @@ export function embellishmentPairs(candidates,rows,baseline,profile,data,catalog const byKey=new Map(candidates.map(c=>[c.key,c])); const best=[],seen=new Set(); for(const r of rows.filter(r=>byKey.get(r.key)?.limits&&Number.isFinite(gain(r))&&gain(r)>0).sort((a,b)=>gain(b)-gain(a))){ - const c=byKey.get(r.key),id=c.slot.replace(/[12]$/,'')+'|'+c.value;if(seen.has(id))continue;seen.add(id);best.push(c);if(best.length>=pick)break; + const c=byKey.get(r.key),id=c.slot.replace(/[12]$/,'')+'|'+variantOf(c);if(seen.has(id))continue;seen.add(id);best.push(c);if(best.length>=pick)break; } const equipped=equippedLimits(profile,data.itemLimits),pairs=[]; for(let i=0;itank?.boss?Number.isFinite(r.score)&&r.score+(r.scoreError||0)>=0:r.dps+Math.hypot(r.error95||0,screen.baseline.error95||0)>=screen.baseline.dps; const promising=candidates.map(c=>({c,r:byKey.get(c.key)})).filter(({r})=>r&&could(r)).sort((a,b)=>tank?.boss?b.r.score-a.r.score:b.r.dps-a.r.dps); - const chosen=[],placed=new Set(); + // One crafted piece in several stat pairs is one item: it takes one place in the slot's share and in the size, and + // every pair of it that could win goes on to the final round, where the better pair is found. + const chosen=[],placed=new Set(),items=new Set(); for(const {c} of promising){ - const g=group(c.slot),item=`${g}|${c.value}`;if(placed.has(item))continue; - const n=perGroup.get(g)||0;if(n>=Math.max(4,Math.ceil(size/8)))continue; - placed.add(item);perGroup.set(g,n+1);chosen.push(c);if(chosen.length>=size)break; + const g=group(c.slot),exact=`${g}|${c.value}`;if(placed.has(exact))continue; + const item=`${g}|${variantOf(c)}`; + if(!items.has(item)){ + const n=perGroup.get(g)||0;if(n>=Math.max(4,Math.ceil(size/8)))continue; + if(items.size>=size)continue; + items.add(item);perGroup.set(g,n+1); + } + placed.add(exact);chosen.push(c); } return chosen; } diff --git a/package-lock.json b/package-lock.json index bfc0c01..b2ad293 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.35.0", + "version": "1.36.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.35.0", + "version": "1.36.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 998747c..47e0ef8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.35.0", + "version": "1.36.0", "private": true, "type": "module", "scripts": { diff --git a/public/upgrades.js b/public/upgrades.js index f61327e..250df60 100644 --- a/public/upgrades.js +++ b/public/upgrades.js @@ -29,7 +29,7 @@ export function upgradeUI({api,notice,updateCount}){

${data.dungeons.length} dungeons in the current rotation, including reissued older dungeons.

${trackPicker('mplus',hero,1)}
Dungeons
${groupChecks('mplus',data.dungeons)}

Any boss or dungeon item from the full season loot tables, one track per vault row.

${[['raid','Raid row'],['mplus','Dungeon row'],['delves','World row']].filter(([row])=>row!=='delves'||data.delves).map(([row,label])=>`
${trackPicker('vault-'+row,myth,1)}
`).join('')}
${data.delves?`

${esc(data.delves.name)} loot table.

${trackPicker('delves',hero,1)}
`:''} - ${data.crafted?`

Epic profession gear. Some pieces require the matching profession to equip.

${embellishmentPicker()}
`:''} + ${data.crafted?`

Epic profession gear. Some pieces require the matching profession to equip. Pieces with open secondary stats are tried in every legal pair unless you pick one.

${embellishmentPicker()}
`:''}
Slots to search
${gearGroups.flatMap(([,slots])=>slots).map(s=>``).join('')}
Import a character to count candidates.
@@ -45,7 +45,7 @@ export function upgradeUI({api,notice,updateCount}){ result.mplus={enabled:on('mplus'),...pick('mplus'),dungeons:checked('mplus')}; result.vault={enabled:on('vault')};for(const row of ['raid','mplus','delves'])if($(`[data-vault-row="${row}"]`))result.vault[row]={enabled:$(`[data-vault-row="${row}"]`).checked,...pick('vault-'+row)}; if(data.delves)result.delves={enabled:on('delves'),...pick('delves')}; - if(data.crafted)result.crafted={enabled:on('crafted'),itemLevel:Number($('#crafted-ilevel').value),stats:Number($('#crafted-stats').value),...(data.embellishments?.length?{embellishments:checked('emb')}:{})}; + if(data.crafted)result.crafted={enabled:on('crafted'),itemLevel:Number($('#crafted-ilevel').value),stats:$('#crafted-stats').value==='all'?'all':Number($('#crafted-stats').value),...(data.embellishments?.length?{embellishments:checked('emb')}:{})}; return result; } function changed(event){ @@ -69,6 +69,8 @@ export function upgradeUI({api,notice,updateCount}){ } // Rings and trinkets are simulated in both of their slots; the filter treats each pair as one slot. const family=slot=>String(slot||'').replace(/[12]$/,''); + // The same identity as the server's: one crafted piece in several stat pairs is a single item, and the list keeps its best pair. + const variantOf=c=>c.craftedStat?`${c.itemId}|${c.embellishment||''}`:c.value; const familyNames={head:'Head',neck:'Neck',shoulder:'Shoulders',back:'Back',chest:'Chest',wrist:'Wrists',hands:'Hands',waist:'Waist',legs:'Legs',feet:'Feet',finger:'Rings',trinket:'Trinkets',main_hand:'Main hand',off_hand:'Off hand'}; // Picking a slot keeps every measured item for it, upgrades and the rest, in every list below. function slotFilterBar(rows){ @@ -96,7 +98,7 @@ export function upgradeUI({api,notice,updateCount}){ const percent=100*delta/Math.max(1,base.dps),tanky=Number.isFinite(row.score); const entry={...row,c,delta,percent,rank:tanky?row.score:percent,tanky,uncertain:tanky?Math.abs(row.score)<=(row.scoreError||0):error!==null&&Math.abs(delta)<=error}; // Rings and trinkets are tried in both slots: keep one placement per item, preferring the final round. - const id=c.slot.replace(/[12]$/,'')+'|'+c.value;const previous=best.get(id); + const id=family(c.slot)+'|'+variantOf(c);const previous=best.get(id); if(!previous||row.stage>previous.stage||row.stage===previous.stage&&entry.rank>previous.rank)best.set(id,entry); } const measured=[...best.values()].sort((a,b)=>b.stage-a.stage||b.rank-a.rank); @@ -120,6 +122,17 @@ export function upgradeUI({api,notice,updateCount}){ // "why is this item not here?". const losers=rows.filter(r=>r.stage===2&&r.rank<=0); if(slotFilter!=='all'&&losers.length)html+=`

Measured, but no better than your gear

${table(losers.sort((a,b)=>b.rank-a.rank))}`; + // In the overview, a slot where nothing won still shows the best item measured for it, final round over + // screening: a slot that was tried and lost must not look like one that was never searched. + if(slotFilter==='all'){ + const won=new Set(upgrades.map(r=>family(r.c.slot))); + const bestLost=Object.keys(familyNames).filter(f=>!won.has(f)).map(f=>{ + const tried=measured.filter(r=>family(r.c.slot)===f);if(!tried.length)return null; + const stage=Math.max(...tried.map(r=>r.stage)); + return tried.filter(r=>r.stage===stage).sort((a,b)=>b.rank-a.rank)[0]; + }).filter(Boolean); + if(bestLost.length)html+=`

Tested, but no better than your gear

The best item measured for each slot without an upgrade. Pick a slot above to see everything measured for it.

${table(bestLost)}`; + } if(slotFilter==='all')html+=pairsSection(job,s,candidates,baselines[3]); const screened=rows.filter(r=>r.stage===1); if(screened.length)html+=`
Screening results not simulated again (${screened.length})${table(screened.sort((a,b)=>b.rank-a.rank))}
`; diff --git a/tests/upgradepage.test.mjs b/tests/upgradepage.test.mjs index 9f85bd3..8c15b59 100644 --- a/tests/upgradepage.test.mjs +++ b/tests/upgradepage.test.mjs @@ -49,3 +49,22 @@ test('upgrade report: character, slots, sources and every upgrade, escaped and s assert.equal((html.match(/