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 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.30.0",
"version": "1.31.0",
"description": "Local SimulationCraft workbench with upgrade finder and tank simulation.",
"author": "Roburmaster",
"private": true,
Expand Down
53 changes: 48 additions & 5 deletions lib/dummies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,59 @@
// on what they had, and nothing here forces any of it.
// SimC creates extra targets with its defaults, so every dummy is declared on its own, before the player: options
// that follow belong to the player again, and the player becomes actor `count` for profilesets.
// A fresh pull of five at full health was tried and left out: SimC ends a fixed-health fight when the first target
// dies, and its DungeonRoute pulls drop raid buffs and target debuffs, so neither measures the city's dummies.
//
// Health has two modes:
// - 'held': at 1% the whole fight, as the busy city dummies were;
// - 'falling': each dummy starts at 100% and the player's own damage takes it down to 1%, where it stays because it
// cannot die. SimC cannot do that directly (real health ends the fight when the first target dies, and the other
// four would die at 0), so it is measured: short calibration runs hold the dummies in each health band that
// changes a rotation (above 80%, above 35%, above 20%, execute) and read how much damage each dummy takes per
// second there (SimC reports damage taken only for tanks, hence role=tank, which changes nothing else). From those
// rates every dummy gets its own health timeline, in shares of the fight as SimC counts it.
export const silvermoon={npc:243208,name:'Cleave_Training_Dummy',count:5,health:3537050,heldAt:1};
export const dummyStyles={SilvermoonDummies:{label:'Silvermoon dummies · 5 in execute'}};
export const dummyStyles={SilvermoonDummies:{label:'Silvermoon dummies'}};
export const isDummyStyle=style=>Object.hasOwn(dummyStyles,style);
export const dummyHealthModes=['held','falling'];
// The bands a falling dummy passes through, and the health it is held at to measure each one.
export const bands=[{from:100,to:80,at:90},{from:80,to:35,at:50},{from:35,to:20,at:30},{from:20,to:silvermoon.heldAt,at:silvermoon.heldAt}];

export function dummyLines(style){
export function normalizeDummyHealth(input){
const mode=input?.mode??'held';
if(!dummyHealthModes.includes(mode))throw new Error('Choose how the dummies’ health behaves.');
return {mode};
}
export const needsCalibration=scenario=>isDummyStyle(scenario?.style)&&scenario.dummyHealth?.mode==='falling'&&!scenario.dummyHealth.timelines;

// One dummy's timeline from its damage taken per second in each band (`rates`, in bands order) over `duration`.
// Returns the SimC option value and the second it reaches 1% (null when the fight ends first).
export function fallTimeline(rates,duration){
const points=[[100,0]];let t=0,reached=null;
for(const [i,band] of bands.entries()){
const rate=Math.max(1,rates[i]);const span=(band.from-band.to)/100*silvermoon.health/rate;
if(t+span>=duration){points.push([band.from-(band.from-band.to)*(duration-t)/span,1]);t=duration;break;}
t+=span;points.push([band.to,t/duration]);
if(band.to===silvermoon.heldAt)reached=t;
}
// The last point must be at 1.0, or SimC adds one at 0% health.
if(points.at(-1)[1]<1)points.push([silvermoon.heldAt,1]);
return {timeline:points.map(([h,s])=>`${Number(h.toFixed(2))}:${Number(s.toFixed(4))}`).join('/'),reached:reached===null?null:Math.round(reached*10)/10};
}
// From the calibration reports (one per band, in bands order): every dummy's timeline.
export function calibrate(reports,duration){
const perBand=reports.map(r=>r.sim.targets.slice(0,silvermoon.count).map(t=>(t.collected_data?.dtps?.mean||0)/Math.max(1,t.collected_data?.fight_length?.mean||duration)));
const fits=Array.from({length:silvermoon.count},(_,i)=>fallTimeline(perBand.map(b=>b[i]),duration));
return {mode:'falling',timelines:fits.map(f=>f.timeline),reached:fits.map(f=>f.reached),rates:perBand.map(b=>b.map(Math.round))};
}

function healthLines(health,i){
if(health?.calibrate!==undefined)return [`enemy_fixed_health_percentage=${health.calibrate}`,'role=tank'];
if(health?.mode==='falling'&&health.timelines)return [`enemy_custom_health_timeline=${health.timelines[i]}`];
return [`enemy_fixed_health_percentage=${silvermoon.heldAt}`];
}
export function dummyLines(style,health=null){
if(!isDummyStyle(style))return [];
const lines=['target_level+=0'];
for(let i=1;i<=silvermoon.count;i++)lines.push(`enemy=${silvermoon.name}_${i}`,`enemy_fixed_health_percentage=${silvermoon.heldAt}`,'actions=wait,sec=10');
for(let i=0;i<silvermoon.count;i++)lines.push(`enemy=${silvermoon.name}_${i+1}`,...healthLines(health,i),'actions=wait,sec=10');
return lines;
}
// How many enemies are declared before the player in a fight of this style.
Expand Down
32 changes: 26 additions & 6 deletions lib/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {promisify} from 'node:util';
import {randomUUID} from 'node:crypto';
import {parseProfile,createVariants} from './profile.mjs';
import {isArmoryProfile} from './armory.mjs';
import {isDummyStyle,dummyLines,dummyCount,dummyScenario} from './dummies.mjs';
import {isDummyStyle,dummyLines,dummyCount,dummyScenario,normalizeDummyHealth,needsCalibration,bands as dummyBands,calibrate as calibrateDummies} from './dummies.mjs';
import {normalizeTank,bossLines,survivalLines,actorTank,calibrate,tankComparison,tankMetricList} from './tank.mjs';
import {appRoot,home,runsDir,readEngineMetadata,enginePaths} from './paths.mjs';
export {runsDir};
Expand Down Expand Up @@ -94,17 +94,19 @@ export async function prepare(request,catalog,talentData,season) {
let scenarios=parity?[{style:parityRun.fightStyle,targets:parityRun.targets,duration:parityRun.duration}]:presets?presetScenarios(request.trinkets.scenarioSet):request.scenarios || [{style:'Patchwerk',targets:1}];
if(!Array.isArray(scenarios)||!scenarios.length||scenarios.length>8) throw new Error('Choose 1–8 scenarios.');
// The Silvermoon dummies are always five, so a target matrix collapses to one fight there.
scenarios=scenarios.filter((s,i)=>!isDummyStyle(s?.style)||scenarios.findIndex(o=>o?.style===s.style&&(o.duration??null)===(s.duration??null))===i);
scenarios=scenarios.filter((s,i)=>!isDummyStyle(s?.style)||scenarios.findIndex(o=>o?.style===s.style&&(o.duration??null)===(s.duration??null)&&(o.dummyHealth?.mode??'held')===(s.dummyHealth?.mode??'held'))===i);
const unique=new Set();
for(const s of scenarios) {
if(!fightStyles.includes(s.style)) throw new Error('Unknown fight style.');
if(isDummyStyle(s.style)){
if(settings.tank)throw new Error('The Silvermoon dummies never attack, so there is nothing to tank. Turn tank simulation off for this fight style.');
Object.assign(s,dummyScenario);
Object.assign(s,dummyScenario,{dummyHealth:normalizeDummyHealth(s.dummyHealth)});
// Falling health is measured on the imported character; the labs simulate SimC's reference profiles instead.
if(s.dummyHealth.mode==='falling'&&weapons)throw new Error('Falling dummy health is measured on your own character. Use held health in Weapon Lab and Trinket Lab.');
}
s.targets=integer(s.targets,1,1,20,'Targets');
if(s.duration!==undefined)s.duration=integer(s.duration,300,10,1200,'Scenario duration');
const key=`${s.style}-${s.targets}-${s.duration||''}`; if(unique.has(key)) throw new Error('The same scenario was selected more than once.'); unique.add(key);
const key=`${s.style}-${s.targets}-${s.duration||''}-${s.dummyHealth?.mode||''}`; if(unique.has(key)) throw new Error('The same scenario was selected more than once.'); unique.add(key);
}
if(request.mode==='trinkets'){
if(!season)throw new Error('Season loot data is not loaded.');
Expand Down Expand Up @@ -165,7 +167,7 @@ export function inputFor(variant,settings,scenario,paths) {
// fight_style=Patchwerk clears every raid event, including the tank healer; the unset style is the same fight.
// The dummies are their own enemies in that unset (Patchwerk) fight.
const style=dummies||boss&&scenario.style==='Patchwerk'?'':`fight_style=${scenario.style}\n`;
return `${boss?bossLines(boss).join('\n')+'\n':''}${dummies?dummyLines(scenario.style).join('\n')+'\n':''}${variant.text}\n\n# Controlled by SimC Lab\nptr=0\nitem_db_source=local\niterations=${settings.iterations}\ntarget_error=${settings.targetError}\nmax_time=${scenario.duration||settings.duration}\nvary_combat_length=0.2\nthreads=${settings.threads}\n${style}desired_targets=${scenario.targets}\ncalculate_scale_factors=0\n${environmentLines(settings.environment,variant.text).join('\n')}\n${scenarioLines(scenario)}${boss?survivalLines(boss).join('\n')+'\n':''}json2="${paths.json.replaceAll('\\','/')}"\nhtml="${paths.html.replaceAll('\\','/')}"\n`;
return `${boss?bossLines(boss).join('\n')+'\n':''}${dummies?dummyLines(scenario.style,scenario.dummyHealth).join('\n')+'\n':''}${variant.text}\n\n# Controlled by SimC Lab\nptr=0\nitem_db_source=local\niterations=${settings.iterations}\ntarget_error=${settings.targetError}\nmax_time=${scenario.duration||settings.duration}\nvary_combat_length=0.2\nthreads=${settings.threads}\n${style}desired_targets=${scenario.targets}\ncalculate_scale_factors=0\n${environmentLines(settings.environment,variant.text).join('\n')}\n${scenarioLines(scenario)}${boss?survivalLines(boss).join('\n')+'\n':''}json2="${paths.json.replaceAll('\\','/')}"\nhtml="${paths.html.replaceAll('\\','/')}"\n`;
}
export function resultFrom(report) {
const p=report.sim?.players?.[0]; const dps=p?.collected_data?.dps;
Expand Down Expand Up @@ -197,7 +199,7 @@ export class Jobs {
async add(plan,request){
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),engine:plan.engine,settings:plan.settings,scenarios:plan.scenarios,search:plan.search,results:[],log:'',warnings:plan.profile.warnings,armory:isArmoryProfile(request.profile)};
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(needsCalibration).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.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:[]});}
Expand Down Expand Up @@ -228,6 +230,7 @@ export class Jobs {
const plan=job._plan;const current=await engineStatus();
if(!current.ready || (current.installed && !current.compatible) || current.commit!==plan.engine.commit)throw new Error('SimC or WoW changed while the job was queued. Submit a new job.');
if(plan.settings.tank)plan.settings.tank.boss=await this.calibrateTank(job,plan.profile.text,plan.settings,'Calibrating the tank boss','tank');
for(const [s,scenario] of plan.scenarios.entries())if(needsCalibration(scenario))scenario.dummyHealth=await this.calibrateDummyHealth(job,plan,s);
if(plan.trinkets)await this.runTrinkets(job,plan);
else if(plan.weapons)await this.runWeapons(job,plan);
else if(plan.upgrade)await this.runUpgrades(job,plan);
Expand Down Expand Up @@ -580,6 +583,23 @@ export class Jobs {
}
// The tank boss is calibrated once against the gear it will judge and then shared by every variant and scenario.
// Weapon Lab calibrates one boss per tank specialization, because each reference profile has its own health pool.
// Falling dummy health: one short run per health band with the dummies held there, measuring how much damage each
// dummy takes per second; every dummy then gets its own timeline from 100% to 1% (see dummies.mjs).
async calibrateDummyHealth(job,plan,s){
const dir=path.join(runsDir,job.id),scenario=plan.scenarios[s],duration=scenario.duration||plan.settings.duration;
const settings={...plan.settings,iterations:Math.min(plan.settings.iterations,1000),targetError:Math.max(plan.settings.targetError,0.5)};
const reports=[];
for(const band of dummyBands){
if(job.status==='cancelled')throw new Error('Cancelled.');
job.current={name:`Measuring how fast the dummies fall · ${band.from}–${band.to}%`,scenario,index:job.results.length};await this.save(this.public(job));
const stem=`dummies-${s}-${band.at}`;const paths={json:path.join(dir,stem+'.json'),html:path.join(dir,stem+'.html')};const input=path.join(dir,stem+'.simc');
await fs.writeFile(input,inputFor({text:plan.profile.text},settings,{...scenario,dummyHealth:{calibrate:band.at}},paths).replace(/^html=.*\n/m,''));
await this.execute(input,dir,job);if(job.status==='cancelled')throw new Error('Cancelled.');
reports.push(JSON.parse(await fs.readFile(paths.json,'utf8')));
}
job.done++;await this.save(this.public(job));
return calibrateDummies(reports,duration);
}
async calibrateTank(job,text,base,label,prefix){
const dir=path.join(runsDir,job.id);let n=0;
job.current={name:label,scenario:{style:'Patchwerk',targets:1},index:job.stages?.length||0};await this.save(this.public(job));
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.30.0",
"version": "1.31.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading