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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Known limits: boss mechanics, movement, deaths and crowd control are not simulat

Protection Warrior, Protection Paladin, Blood Death Knight, Guardian Druid, Brewmaster Monk and Vengeance Demon Hunter are detected on import. Every mode (Quick Sim, Enchant Lab, Gear Compare, Upgrade Finder, Crest Planner, Great Vault and Talent Search) then ranks results on DPS and survival.

**Tank Sim** has its own place in the menu: the imported tank against a boss and its adds, one simulation per fight you tick (the boss alone, the boss with 2 adds, the boss with 4 adds), with the numbers a tank cares about: damage taken per second and as a share of your health, healing and absorbs, how much of the fight you survive, how often you die, and your DPS. It needs a tank specialization. Tank Sim can also **find gear upgrades** (on by default): Upgrade Finder's sources appear in the same mode, and every item from them is simulated in its slot in each fight you ticked and the best ones again at full precision, ranked on the survival and damage score. Each fight in the result shows what your current gear takes per second, how much of the fight it survives and how often it dies, then the upgrades for that fight. The same upgrades can be run from Upgrade Finder itself with the same number of targets.
**Tank Sim** has its own place in the menu: the imported tank against a boss and its adds, one simulation per fight you tick (the boss alone, the boss with 2 adds, the boss with 4 adds), with the numbers a tank cares about: damage taken per second and as a share of your health, healing and absorbs, how much of the fight you survive, how often you die, and your DPS. It needs a tank specialization. Tank Sim is a page of its own and is kept apart from the other simulations: its fights, fight style, duration, iterations, target error, CPU threads, raid buffs and consumables, boss and gear sources are all on the page, and the settings column the other modes share is not used. It can also **find gear** from three sources you switch on or off: the gear from your bags (read from the addon export, with the equipped gear always the baseline), upgrades with crests (each equipped item at every higher level of its track, within the crests you have), and loot from instances (Upgrade Finder's raid, Mythic+, Great Vault, delve and crafted sources, kept as a separate copy of its settings). Every candidate from all three is simulated in its slot in each fight you ticked and the best ones again at full precision, ranked on the survival and damage score. Each fight in the result shows what your current gear takes per second, how much of the fight it survives and how often it dies, then the gear that does better for that fight. With all three off it runs the fights alone.

SimC's built-in tank dummy uses level-70 damage values and never threatens a Midnight tank, and SimC gives players infinite health by default. SimC Lab replaces it with a calibrated boss, tuned once per job against the imported gear and then shared by every variant and scenario:

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.43.0",
"version": "1.44.0",
"description": "Local SimulationCraft workbench with upgrade finder and tank simulation.",
"author": "Roburmaster",
"private": true,
Expand Down
11 changes: 10 additions & 1 deletion lib/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {normalizeEnvironment,environmentLines} from './environment.mjs';
import {prepareTalents} from './optimizer.mjs';
import {buildCandidates,profilesetLines,profilesetResults,screenSettings,selectFinalists,embellishmentPairs,upgradeSteps} from './upgrades.mjs';
import {buildCrestCandidates} from './crests.mjs';
import {buildTankGear} from './tankgear.mjs';
import {buildVaultCandidates} from './vault.mjs';
import {prepareWeapons,publicSpec,weaponSteps,specSteps,selectTop,rankWeaponRows,actorGear,pairCount,withPair,bestPair,profilePair,scaleStats,scaleOnly} from './weapons.mjs';
import {prepareTrinkets,trinketSteps,specSteps as trinketSpecSteps,topRows,selectTopTrinkets,trinketCount,gainOf,pairPool,trinketPairs,selectPairs,tiedPairs,bestPartners,pairScreenSettings,resolveSettings,pairStages,isPairStage,presetScenarios,groupOf,parity as parityRun} from './trinkets.mjs';
Expand Down Expand Up @@ -162,6 +163,14 @@ export async function prepare(request,catalog,talentData,season) {
const name=`Weapon tier list · ${plan.specs.length} spec${plan.specs.length===1?'':'s'}`;
return {engine,profile:{text:'',info:{name},warnings:[]},settings,scenarios,variants:[],weapons:plan};
}
// Tank Sim tries the gear a tank carries and could earn, from its own list of sources (see tankgear.mjs); with none chosen it is plain fights.
if(request.mode==='tank'){
const upgrade=buildTankGear(profile,request.profile,request.tankGear,season,catalog,talentData.find(profile.info).specId);
if(upgrade){
upgrade.season=season.season;upgrade.itemLimits=season.itemLimits;
return {engine,profile:{...profile,warnings:[...(profile.warnings||[]),...upgrade.warnings]},settings,scenarios,variants:[{name:'Current gear',text:profile.text,baseline:true}],upgrade};
}
}
if(request.mode==='upgrades'){
if(!season)throw new Error('Season loot data is not loaded.');
const upgrade=buildCandidates(profile,request.upgrades,season,catalog,talentData.find(profile.info).specId);
Expand Down Expand Up @@ -243,7 +252,7 @@ export class Jobs {
if([...this.jobs.values()].filter(j=>['queued','running'].includes(j.status)).length>=5)throw new Error('The queue is full (maximum 5 jobs).');
const steps=plan.trinkets?trinketSteps(plan.trinkets,plan.scenarios.length):plan.weapons?weaponSteps(plan.weapons,plan.scenarios.length):plan.crests||plan.vault?plan.scenarios.length:(plan.upgrade?upgradeSteps(plan.upgrade,plan.scenarios.length):plan.variants.length*plan.scenarios.length);
const job={id:randomUUID(),name:plan.profile.info.name,mode:request.mode || 'quick',created:new Date().toISOString(),status:'queued',done:0,total:steps+(plan.settings.tank?1:0)+plan.scenarios.filter(calibrates).length,engine:plan.engine,settings:plan.settings,scenarios:plan.scenarios,search:plan.search,results:[],log:'',warnings:plan.profile.warnings,armory:isArmoryProfile(request.profile)};
if(plan.upgrade)Object.assign(job,{upgrade:{season:plan.upgrade.season,finalists:plan.upgrade.finalists,embellished:plan.upgrade.embellished,limitsUsed:plan.upgrade.limitsUsed,blocked:plan.upgrade.blocked,candidates:plan.upgrade.candidates.map(({line,...c})=>c),screen:screenSettings(plan.settings)},stages:[]});
if(plan.upgrade)Object.assign(job,{upgrade:{season:plan.upgrade.season,finalists:plan.upgrade.finalists,embellished:plan.upgrade.embellished,limitsUsed:plan.upgrade.limitsUsed,blocked:plan.upgrade.blocked,counts:plan.upgrade.counts,candidates:plan.upgrade.candidates.map(({line,...c})=>c),screen:screenSettings(plan.settings)},stages:[]});
if(plan.crests){const {candidates,...rest}=plan.crests;Object.assign(job,{crests:{...rest,candidates:candidates.map(({line,...c})=>c)},stages:[]});}
if(plan.vault){const {candidates,...rest}=plan.vault;Object.assign(job,{vault:{...rest,candidates:candidates.map(({line,...c})=>c)},stages:[]});}
if(plan.trinkets)Object.assign(job,{trinkets:{...plan.trinkets,screen:screenSettings(plan.settings),pairScreen:pairScreenSettings(plan.settings),resolve:resolveSettings(plan.settings),repro:reproduction(plan),
Expand Down
75 changes: 75 additions & 0 deletions lib/tankgear.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Tank Sim's gear: what to try on a tank, from three sources that Upgrade Finder, Gear Compare and Crest Planner each know
// one of. Every source ends up as the same kind of candidate, so one screening round and one final round can rank all of
// them together, with the tank's own score (survival and DPS) in each fight.
import {slots as allSlots} from './profile.mjs';
import {buildCandidates,equippedLimits,overLimit,itemLimitsOf,bonusIdsOf,limits as upgradeLimits} from './upgrades.mjs';
import {buildCrestCandidates} from './crests.mjs';

// Items the addon export lists beside the equipped gear: bags, the Great Vault, anything saved. Equipped gear is the
// baseline and always runs, so it is never a candidate of its own.
export function bagCandidates(profile,season,catalog){
const out=[];let blocked=0;
const limits=season?.itemLimits,equipped=equippedLimits(profile,limits);
for(const alt of profile.alternatives||[]){
const slot=alt.slot;if(!slot||!allSlots.includes(slot))continue;
const value=String(alt.text).slice(slot.length+1);
const id=Number(value.match(/(?:^|,)id=(\d+)/)?.[1]);if(!id||profile.gear[slot]?.value===value)continue;
const item=catalog.items.get(id);
// A bag item that would break an equip limit next to what is worn is left out, as in Upgrade Finder.
const uses=itemLimitsOf(id,bonusIdsOf(value),limits);
if(uses.size&&overLimit(equipped,{[slot]:uses},[],limits).length){blocked++;continue;}
// The addon names each bag item with its item level, "Pledgebearer's Girdle (295)"; an override in the line wins.
const level=Number(value.match(/(?:^|,)ilevel=(\d+)/)?.[1])||Number(String(alt.name).match(/\((\d+)\)\s*$/)?.[1])||'?';
const section=alt.section||'Bags';
out.push({slot,itemId:id,name:item?.name||String(alt.name).replace(/\s*\(\d+\)\s*$/,'')||`Item ${id}`,itemLevel:level,value,line:`${slot}=${value}`,...(uses.size?{limits:[...uses]}:{}),
sources:[{origin:'bags',group:`bags:${section}`,groupName:section,label:`${section} · item level ${level}`}]});
}
return {list:out,blocked};
}

// Each equipped item at every higher level of its upgrade track, within the crests that can be spent.
export function crestCandidates(profile,text,request,season,catalog){
const crest=buildCrestCandidates(profile,text,request,season,catalog);
const list=crest.candidates.map(c=>({slot:c.slot,itemId:c.itemId,name:c.name,itemLevel:c.itemLevel,value:c.value,line:c.line,
upgrade:{track:c.track.name,from:c.from,to:c.to,max:c.max,fromItemLevel:c.fromItemLevel,crests:c.crests,fullCrests:c.fullCrests,currencyId:c.currencyId},
sources:[{origin:'crests',group:`crests:${c.slot}`,groupName:`Upgrading ${c.name}`,label:`${c.track.name} ${c.from}/${c.max} to ${c.to}/${c.max} · ${c.crests} crest${c.crests===1?'':'s'}`}]}));
return {list,budget:crest.budget,currencies:crest.currencies};
}

// The three sources of a tank job, merged. `request` is {loot, bags, crests, finalists}: a source is on when its key is
// there. Returns null when none is asked for, which leaves the job as plain fights.
export function buildTankGear(profile,text,request,season,catalog,specId){
const asked=request&&typeof request==='object'?request:null;
if(!asked||!(asked.loot||asked.bags||asked.crests))return null;
if(!season)throw new Error('Season loot data is not loaded.');
const finalists=Number(asked.finalists??48);if(!upgradeLimits.finalists.includes(finalists))throw new Error('Choose a supported final round size.');
const warnings=[],counts={loot:0,bags:0,crests:0};let blocked=0,freed=0;
const pool=[];
if(asked.loot){
const loot=buildCandidates(profile,{...asked.loot,finalists},season,catalog,specId);
pool.push(...loot.candidates);blocked+=loot.blocked;freed+=loot.freed;counts.loot=loot.candidates.length;
}
if(asked.bags){
const bags=bagCandidates(profile,season,catalog);
pool.push(...bags.list);blocked+=bags.blocked;counts.bags=bags.list.length;
if(!bags.list.length)warnings.push((profile.alternatives||[]).some(a=>a.slot)?'Every bag item is already worn, or would break an equip limit, so there is nothing from your bags to try.':'This export lists no bag gear. Export again with the SimulationCraft addon and bags open to try what you carry.');
}
if(asked.crests){
// A character whose gear is not on an upgrade track cannot be upgraded; with other sources on, that is a note, not a failure.
try{const crests=crestCandidates(profile,text,asked.crests,season,catalog);pool.push(...crests.list);counts.crests=crests.list.length;}
catch(e){if(!asked.loot&&!asked.bags)throw e;warnings.push(`Crest upgrades: ${e.message}`);}
}
// The same item line from two places is one candidate with both sources.
const byLine=new Map();
for(const c of pool){
const id=c.freed?`${c.line}|${c.freed.slot}`:c.line,known=byLine.get(id);
if(!known)byLine.set(id,c);
else for(const s of c.sources)if(!known.sources.some(x=>x.label===s.label))known.sources.push(s);
}
const list=[...byLine.values()].sort((a,b)=>allSlots.indexOf(a.slot)-allSlots.indexOf(b.slot)||(Number(b.itemLevel)||0)-(Number(a.itemLevel)||0)||a.name.localeCompare(b.name));
if(!list.length)throw new Error(warnings.length?warnings.join(' '):'No usable items were found in the selected sources for this character.');
list.forEach((c,i)=>c.key='c'+String(i+1).padStart(3,'0'));
const equipped=equippedLimits(profile,season.itemLimits);
const holders=Object.entries(equipped).map(([slot,used])=>({slot,name:catalog.items.get(profile.gear[slot].id)?.name||`Item ${profile.gear[slot].id}`,limits:[...used]}));
return {candidates:list,finalists,embellished:list.some(c=>c.limits),limitsUsed:holders,blocked,freed,counts,warnings};
}
2 changes: 1 addition & 1 deletion lib/upgradepage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(
const signed=(n,digits=2)=>`${n>=0?'+':''}${Number(n).toFixed(digits)}`;
const classColors={deathknight:'#C41E3A',demonhunter:'#A330C9',druid:'#FF7C0A',evoker:'#33937F',hunter:'#AAD372',mage:'#3FC7EB',monk:'#00FF98',paladin:'#F48CBA',priest:'#DFDFDF',rogue:'#FFF468',shaman:'#0070DD',warlock:'#8788EE',warrior:'#C69B6D'};
const classNames={deathknight:'Death Knight',demonhunter:'Demon Hunter',druid:'Druid',evoker:'Evoker',hunter:'Hunter',mage:'Mage',monk:'Monk',paladin:'Paladin',priest:'Priest',rogue:'Rogue',shaman:'Shaman',warlock:'Warlock',warrior:'Warrior'};
const originNames={raid:'Raid',mplus:'Mythic+',vault:'Great Vault',delves:'Delves',crafted:'Crafted'};
const originNames={raid:'Raid',mplus:'Mythic+',vault:'Great Vault',delves:'Delves',crafted:'Crafted',bags:'Bags',crests:'Crest upgrades'};
const families=[['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']];
const slotNames={finger1:'Ring 1',finger2:'Ring 2',trinket1:'Trinket 1',trinket2:'Trinket 2',...Object.fromEntries(families)};
const family=slot=>String(slot||'').replace(/[12]$/,'');
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.43.0",
"version": "1.44.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading