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.29.1",
"version": "1.30.0",
"description": "Local SimulationCraft workbench with upgrade finder and tank simulation.",
"author": "Roburmaster",
"private": true,
Expand Down
30 changes: 30 additions & 0 deletions lib/dummies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// The five Cleave Training Dummies in Silvermoon City (NPC 243208), measured from a combat log taken beside them on
// WoW 12.1.0 (2026-10-02):
// - five dummies stacked within 4 yards, 3,537,050 health each, level 90 like the player;
// - armor 1,470: every physical hit lost exactly 30%, which SimC already gives (armor 1,470 against the attacker's
// level-90 constant 3,430), so armor needs no option;
// - they cannot die. Busy as they always are, each sat at 1 health 78% of the time and never rose above 8%, so
// every execute effect is up the whole fight;
// - nothing but the player's own effects raised the damage they took: no Mystic Touch, Chaos Brand or Hunter's
// Mark; the player had no Fortitude either (maximum health exactly 5% under SimC's), and no Bloodlust or potion;
// - they never attack (SimC's default enemy hits the player, which feeds damage-taken procs, so they only wait).
// So the page starts this fight with every buff, debuff and consumable off (environment.js, bare()); the user turns
// 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.
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 isDummyStyle=style=>Object.hasOwn(dummyStyles,style);

export function dummyLines(style){
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');
return lines;
}
// How many enemies are declared before the player in a fight of this style.
export const dummyCount=style=>isDummyStyle(style)?silvermoon.count:0;
// The dummies are always five; buffs and consumables are the environment's, as the user sets them.
export const dummyScenario={targets:silvermoon.count};
26 changes: 19 additions & 7 deletions lib/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +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 {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 @@ -65,8 +66,9 @@ export async function installedWowVersion(){
return row?.[headers.indexOf('Version')]||null;
}catch{return null;}
}
// CastingPatchwerk is Patchwerk with a boss that casts, for effects that react to enemy casts.
export const fightStyles=['Patchwerk','CastingPatchwerk','HecticAddCleave','DungeonSlice','HeavyMovement','LightMovement'];
// CastingPatchwerk is Patchwerk with a boss that casts, for effects that react to enemy casts. The Silvermoon styles
// are the city's five training dummies, measured from a combat log (see dummies.mjs).
export const fightStyles=['Patchwerk','CastingPatchwerk','HecticAddCleave','DungeonSlice','HeavyMovement','LightMovement','SilvermoonDummies'];
function integer(value, fallback, min, max, label) {
const n=Number(value ?? fallback); if(!Number.isInteger(n)||n<min||n>max) throw new Error(`${label} must be ${min}–${max}.`); return n;
}
Expand All @@ -89,11 +91,17 @@ export async function prepare(request,catalog,talentData,season) {
const presets=request.mode==='trinkets'&&request.trinkets?.scenarioSet?.length;
// Bloodmallet parity fixes the fight and the precision to Bloodmallet's own (see parity in trinkets.mjs).
const parity=request.mode==='trinkets'&&!!request.trinkets?.parity;
const scenarios=parity?[{style:parityRun.fightStyle,targets:parityRun.targets,duration:parityRun.duration}]:presets?presetScenarios(request.trinkets.scenarioSet):request.scenarios || [{style:'Patchwerk',targets:1}];
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);
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);
}
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);
Expand Down Expand Up @@ -150,11 +158,14 @@ export function reproduction(plan){
}
// What a scenario turns off after the environment: a Mythic+ pull that gets no Bloodlust or no potion.
export const scenarioLines=scenario=>[...(scenario.bloodlust===false?['override.bloodlust=0']:[]),...(scenario.potion===false?['potion=disabled','override.allow_potions=0']:[])].map(l=>l+'\n').join('');
// Enemies declared before the player make it a later actor, which profilesets must be told.
export const playerActorIndex=(settings,scenario)=>settings.tank?.boss?1:dummyCount(scenario.style);
export function inputFor(variant,settings,scenario,paths) {
const boss=settings.tank?.boss;
const boss=settings.tank?.boss,dummies=isDummyStyle(scenario.style);
// fight_style=Patchwerk clears every raid event, including the tank healer; the unset style is the same fight.
const style=boss&&scenario.style==='Patchwerk'?'':`fight_style=${scenario.style}\n`;
return `${boss?bossLines(boss).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`;
// 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`;
}
export function resultFrom(report) {
const p=report.sim?.players?.[0]; const dps=p?.collected_data?.dps;
Expand Down Expand Up @@ -548,7 +559,8 @@ export class Jobs {
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';
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.
await fs.writeFile(input,inputFor({text:profile.text},settings,scenario,paths)+(settings.tank?`profileset_metric=${tankMetricList}\nprofileset_main_actor_index=1\n`:'')+(spec?.support?'profileset_metric=raid_dps,dps\n':'')+profilesetLines(list,profile,this.catalog).join('\n')+'\n');
const actor=playerActorIndex(settings,scenario);
await fs.writeFile(input,inputFor({text:profile.text},settings,scenario,paths)+(settings.tank?`profileset_metric=${tankMetricList}\n`:'')+(actor?`profileset_main_actor_index=${actor}\n`:'')+(spec?.support?'profileset_metric=raid_dps,dps\n':'')+profilesetLines(list,profile,this.catalog).join('\n')+'\n');
await this.save(this.public(job));
try{
await this.execute(input,dir,job);
Expand Down
7 changes: 4 additions & 3 deletions lib/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import path from 'node:path';
import {dataDir} from './paths.mjs';

const file=path.join(dataDir,'settings.json');
// Checking for a newer official SimC build at start, and installing it, is on unless the user turns it off.
// Following the latest commit builds SimC from source (Windows otherwise takes the official nightly, a day behind at most).
export const defaults={autoUpdateSimc:true,followLatestCommit:false};
// SimC is never installed by itself, so there is no setting for that: the app only says when a newer build is out.
export const defaults={followLatestCommit:false};

export async function loadSettings(){
try{return {...defaults,...JSON.parse(await fs.readFile(file,'utf8'))};}catch{return {...defaults};}
// Settings that no longer exist (autoUpdateSimc before 1.30.0) are dropped.
try{const saved=JSON.parse(await fs.readFile(file,'utf8'));return {...defaults,...Object.fromEntries(Object.entries(saved).filter(([key])=>key in defaults))};}catch{return {...defaults};}
}
export async function saveSettings(changes){
const current=await loadSettings(),next={...current};
Expand Down
Loading
Loading