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 is a page of its own and is kept apart from the other simulations: its fights (always Patchwerk: the fights you tick are the whole description of the encounter), 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.
**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 (always Patchwerk: the fights you tick are the whole description of the encounter), 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. The best trinkets and rings then go on to a round of **pairs**: every pair of them, together with the two you wear, is simulated as a pair, because one trinket measured alone cannot say whether to wear 1 and 2 or 1 and 4. The result opens with **Gear to wear**: for each fight, the best measured item in every slot (with the upgrade level and crest cost for crest upgrades, or the source and item level for drops and bag gear) and the best trinket and ring pair, at a ranking weight you move with a slider. Each result keeps its DPS and survival gains, so moving the slider ranks again at once without a new simulation. Each slot is measured on its own against your worn gear, so the list is not one combined set; only the pairs are measured together. The final round was chosen with the weight you ran with, and the page says so when the slider is far from it. With all three sources 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.44.1",
"version": "1.45.0",
"description": "Local SimulationCraft workbench with upgrade finder and tank simulation.",
"author": "Roburmaster",
"private": true,
Expand Down
21 changes: 16 additions & 5 deletions lib/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +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 {buildTankGear,jewelryPairs} 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 @@ -252,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,counts:plan.upgrade.counts,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,worn:plan.upgrade.worn,jewelry:!!plan.upgrade.jewelry,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 Expand Up @@ -322,9 +322,20 @@ export class Jobs {
if(job.status==='cancelled')return;
const finalists=screen?selectFinalists(all,screen,plan.upgrade.finalists,plan.settings.tank):[];
const skip=async(stage,reason)=>{job.stages.push({scenario:s,stage,status:'skipped',count:0,reason});job.done++;await this.save(this.public(job));};
if(!finalists.length){await skip(2,screen?'No candidate could beat the current gear within screening uncertainty.':'Screening failed, so there was no final round.');if(plan.upgrade.embellished)await skip(3,'There was no final round to pair embellishments from.');continue;}
if(!finalists.length){await skip(2,screen?'No candidate could beat the current gear within screening uncertainty.':'Screening failed, so there was no final round.');if(plan.upgrade.embellished)await skip(3,'There was no final round to pair embellishments from.');if(plan.upgrade.jewelry)await skip(4,'There was no final round to pair trinkets and rings from.');continue;}
const final=await this.stage(job,plan,s,2,finalists,plan.settings);
if(!plan.upgrade.embellished||job.status==='cancelled')continue;
if(job.status==='cancelled')continue;
// Tank Sim: the best trinkets and rings, each pair simulated together with the two worn, which one item at a time cannot say.
if(plan.upgrade.jewelry){
const pairs=final?jewelryPairs(finalists,final.rows,plan.profile,{itemLimits:plan.upgrade.itemLimits},this.catalog):[];
if(!pairs.length)await skip(4,final?'No trinket or ring pair could be built from the final round.':'The final round failed, so there were no pairs.');
else{
job.upgrade.jewelryPairs=[...(job.upgrade.jewelryPairs||[]),...pairs.map(p=>({scenario:s,key:p.key,family:p.family,parts:p.parts.map(({line,sources,...part})=>part)}))];
await this.stage(job,plan,s,4,pairs,plan.settings);
if(job.status==='cancelled')continue;
}
}
if(!plan.upgrade.embellished)continue;
// Third round: the best embellished upgrades worn two at a time, within the equip limit.
const pairs=final?embellishmentPairs(finalists,final.rows,final.baseline,plan.profile,{itemLimits:plan.upgrade.itemLimits},this.catalog,{tank:plan.settings.tank}):[];
if(!pairs.length){const worn=(plan.upgrade.limitsUsed||[]).map(h=>h.name).join(', ');await skip(3,!final?'The final round failed, so there were no embellishments to pair.':worn?`No two of the best embellished upgrades can be worn together next to ${worn}, so the best single one is the answer.`:'Fewer than two embellished upgrades beat your gear, so there was nothing to pair.');continue;}
Expand Down Expand Up @@ -616,7 +627,7 @@ export class Jobs {
const scenario=plan.scenarios[s];
// Enough to run the same stage again: the fight, its length and variation, and the precision it asked for.
const record={...(spec?{spec:spec.key}:{}),scenario:s,stage,stem,count:list.length,iterations:settings.iterations,targetError:settings.targetError,fightStyle:scenario.style,targets:scenario.targets,duration:scenario.duration||settings.duration,variation:settings.environment?.variation??20};
const title=job.crests?'Upgrades':job.vault?'Great Vault choices':{1:'Screening',3:'Embellishment pairs',[pairStages.screen]:'Pair screening',[pairStages.final]:'Pair final',[pairStages.resolve]:'Resolving tied pairs'}[stage]||'Final round';
const title=job.crests?'Upgrades':job.vault?'Great Vault choices':{1:'Screening',3:'Embellishment pairs',4:'Trinket and ring pairs',[pairStages.screen]:'Pair screening',[pairStages.final]:'Pair final',[pairStages.resolve]:'Resolving tied pairs'}[stage]||'Final round';
job.current={name:`${spec?spec.label+' · ':''}${title} · ${list.length} ${isPairStage(stage)?'pairs':'candidates'}`,scenario,index:job.stages.length};
// Profilesets go last so each one inherits every option of the base profile and environment.
const actor=playerActorIndex(settings,scenario);
Expand Down
3 changes: 2 additions & 1 deletion lib/tank.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,6 @@ export function tankComparison(row,base,boss,weight){
// SimC gives no usable error for survived time; a death-or-not bound (at most the binomial spread) stands in.
const aliveError=(m,n)=>m.aliveError||(n?z*Math.sqrt(Math.max(m.alive*(1-m.alive),1/n)/n):0);
const survivalError=Math.hypot(100*Math.hypot(row.tank.netError||0,base.tank.netError||0)/pressure,100*Math.hypot(aliveError(row.tank,row.iterations),aliveError(base.tank,base.iterations)));
return {dpsGain,survival,score,scoreError:Math.hypot((1-weight/100)*dpsError,weight/100*survivalError)};
// The two errors are kept so a result can be weighed again with another weight without a new simulation.
return {dpsGain,survival,score,scoreError:Math.hypot((1-weight/100)*dpsError,weight/100*survivalError),dpsError,survivalError};
}
43 changes: 41 additions & 2 deletions lib/tankgear.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 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 {buildCandidates,equippedLimits,overLimit,itemLimitsOf,bonusIdsOf,variantOf,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
Expand Down Expand Up @@ -71,5 +71,44 @@ export function buildTankGear(profile,text,request,season,catalog,specId){
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};
// What is worn now, so the result can name what each recommendation replaces.
const worn=Object.fromEntries(Object.entries(profile.gear||{}).filter(([,g])=>g?.id).map(([slot,g])=>[slot,{itemId:g.id,name:catalog.items.get(g.id)?.name||`Item ${g.id}`}]));
return {candidates:list,finalists,embellished:list.some(c=>c.limits),limitsUsed:holders,blocked,freed,counts,warnings,worn,jewelry:true};
}

// A single item measured on its own says nothing about which two trinkets or rings to wear together: the answer is a pair.
// The best candidates of each pair of slots go together with the two worn pieces, and every pair that can be worn at once
// becomes one simulation. A pair of the two worn pieces is the baseline, so it is not run.
const twin={finger1:'finger2',finger2:'finger1',trinket1:'trinket2',trinket2:'trinket1'};
export function jewelryPairs(candidates,rows,profile,data,catalog,{pick=5}={}){
const byKey=new Map(candidates.map(c=>[c.key,c]));
const equipped=equippedLimits(profile,data.itemLimits),pairs=[];
for(const family of ['trinket','finger']){
const worn=[family+'1',family+'2'].filter(slot=>profile.gear[slot]?.id).map(slot=>{
const g=profile.gear[slot];
return {slot,itemId:g.id,name:catalog.items.get(g.id)?.name||`Item ${g.id}`,value:g.value,line:`${slot}=${g.value}`,worn:true,limits:[...itemLimitsOf(g.id,bonusIdsOf(g.value),data.itemLimits)]};
});
const best=[],seen=new Set();
const ranked=rows.filter(r=>Number.isFinite(r.score)&&byKey.get(r.key)?.slot.startsWith(family)&&!byKey.get(r.key).freed).sort((a,b)=>b.score-a.score);
for(const r of ranked){
const c=byKey.get(r.key),id=variantOf(c);if(seen.has(id))continue;seen.add(id);
best.push({slot:c.slot,itemId:c.itemId,name:c.name,itemLevel:c.itemLevel,value:c.value,line:c.line,limits:c.limits||[],sources:c.sources,...(c.upgrade?{upgrade:c.upgrade}:{})});
if(best.length>=pick)break;
}
const pool=[...worn,...best];
for(let i=0;i<pool.length;i++)for(let j=i+1;j<pool.length;j++){
const [a,b]=[pool[i],pool[j]];
if(a.worn&&b.worn||a.itemId===b.itemId)continue;
// A worn piece keeps its slot; a new one takes the other. Two new ones keep theirs, or share the two slots.
const place=(part,slot)=>part.slot===slot?part:{...part,slot,line:part.line.replace(/^[a-z_0-9]+=/,slot+'=')};
let first=a,second=b;
if(a.worn)second=place(b,twin[a.slot]);
else if(b.worn)first=place(a,twin[b.slot]);
else if(a.slot===b.slot)second=place(b,twin[b.slot]);
if(first.slot===second.slot)continue;
if(overLimit(equipped,{[first.slot]:new Set(first.limits),[second.slot]:new Set(second.limits)},[],data.itemLimits).length)continue;
pairs.push({key:'j'+String(pairs.length+1).padStart(3,'0'),family,parts:[first,second]});
}
}
return pairs;
}
2 changes: 1 addition & 1 deletion lib/upgrades.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -403,4 +403,4 @@ export function selectFinalists(candidates,screen,size,tank){
}

// Screening and the final round per scenario, and the embellishment pairs when anything embellished is searched.
export const upgradeSteps=(upgrade,scenarios)=>(upgrade.embellished?3:2)*scenarios;
export const upgradeSteps=(upgrade,scenarios)=>((upgrade.embellished?3:2)+(upgrade.jewelry?1:0))*scenarios;
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.44.1",
"version": "1.45.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading