Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 21 additions & 2 deletions lib/upgradepage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n);
const signed=(n,digits=2)=>`${n>=0?'+':''}${Number(n).toFixed(digits)}`;
Expand Down Expand Up @@ -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=>{
Expand All @@ -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=>`<p class="note">${escape(s.status==='failed'?`A stage failed: ${s.error}`:s.reason)}</p>`).join('');

const testedNote=key=>{
const t=bestTested(rows,key);if(!t)return '';
const {row}=t,c=row.c;
return `<div class="tested"><span>Best tested</span>${link(c.itemId,c.name,c.value)}<small>${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`:''}</small></div>`;
};

// 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);
Expand All @@ -75,7 +91,7 @@ function scenarioSection(job,scenario,index,equipped){
<header><span>${escape(name)}</span>${best?`<em>${signed(best.rank)}${unit}</em>`:''}</header>
<div class="worn">${worn.map(([,g])=>`<span>${link(g.id,g.name,g.value)}</span>`).join('')||'<span class="muted">Empty</span>'}</div>
${best?`<div class="best"><span class="arrow">↑</span><span>${link(best.c.itemId,best.c.name,best.c.value)}<small>${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':''}</small></span></div>`
:`<p class="none">${tried?'Nothing measured beat it.':'Not searched.'}</p>`}
:`<p class="none">${tried?'Nothing measured beat it.':'Not searched.'}</p>${testedNote(key)}`}
</article>`;
}).join('');

Expand Down Expand Up @@ -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}
Expand Down
45 changes: 31 additions & 14 deletions lib/upgrades.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]};

Expand Down Expand Up @@ -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.');
Expand All @@ -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));
Expand Down Expand Up @@ -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;i<best.length;i++)for(let j=i+1;j<best.length;j++){
Expand Down Expand Up @@ -337,11 +347,18 @@ export function selectFinalists(candidates,screen,size,tank){
// Tanks rank by the weighted DPS and survival score instead of DPS alone.
const could=r=>tank?.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;
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simc-lab",
"version": "1.35.0",
"version": "1.36.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading