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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ A specialization with more trinkets than the final round size (16, 32 or 64; 64

**The tier list page.** A finished job offers one page in the design of mythicpersona.com's weapon tier list, with its fonts inside the file: every class and specialization, each trinket in its tier with its split bar, source, item level and Wowhead tooltip, and the gain at every level in the row's data so the website can import it. When the job ran 1, 3 and 5 targets, tabs at the top switch between them, with no script. Two trinkets together are not the sum of their bars; the page says so.

## Mythic+ route (experimental, made by AI)

> **Experimental and made by AI.** This fight style was written by an AI assistant (Claude) and has not yet been checked against Mythic+ combat logs. Use it to compare choices on the same route; do not read its DPS or run time as what a real run will give.

The fight style **Mythic+ route** simulates a whole dungeon of the current season, pull by pull, with SimulationCraft's DungeonRoute. Everything it needs is read from addons on your PC at run time; nothing is shipped with the app:

- **Mythic Dungeon Tools**: every enemy's health, enemy forces and bosses, and the routes you have saved. Health at a keystone level uses MDT's own formula (checked against a +14 combat log to within 0.1%).
- **Raider.IO**: the replays of your own recent runs — when every mob died and when every boss was pulled and killed.

Choose a dungeon, a keystone level and a route (one of your runs, or an MDT route), then the group's pace:

- **As fast as one of your runs.** SimC simulates only you, so each mob gets your share of its health. Every boss you killed gets the share that makes it last as long as it did in that run; the trash gets the share that fills the rest of the run's time after walking to every pull. Two short measuring runs come first, the second with Bloodlust where the first placed it.
- **A share of the group's damage** that you set.

Bloodlust goes on the first pull and again on the first pull after it is ready. **Every season dungeon** runs all of them at once, each on your latest run. The result shows the simulated run time beside yours, your share of the group's damage on bosses and on trash, and every pull with its health, length and your DPS. Gear Compare, Enchant Lab, Talent Search and Upgrade Finder all work on a route; each row also shows the run time.

Known limits: boss mechanics, movement, deaths and crowd control are not simulated, so the simulated player is better than a real one and the measured share comes out high. Time saved assumes the whole group speeds up with you, so it overstates what one player's gain is worth. Raider.IO adds up mobs that die together and leaves out mobs worth no forces; some replays keep too few kills to rebuild a route (they still set the pace), and an incomplete replay can pace a route badly. Tank simulation, Weapon Lab and Trinket Lab do not use this fight style.

## Tank simulation

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.
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.34.0",
"version": "1.35.0",
"description": "Local SimulationCraft workbench with upgrade finder and tank simulation.",
"author": "Roburmaster",
"private": true,
Expand Down
67 changes: 56 additions & 11 deletions lib/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {randomUUID} from 'node:crypto';
import {parseProfile,createVariants} from './profile.mjs';
import {isArmoryProfile} from './armory.mjs';
import {isDummyStyle,dummyLines,dummyCount,dummyScenario,normalizeDummyHealth,needsCalibration,bands as dummyBands,calibrate as calibrateDummies} from './dummies.mjs';
import {isRouteStyle,loadRoutes,resolveRoute,routeLines,trialRoute,finishRoute,pullLengths,routeResult} from './mplus.mjs';
import {locate} from './wowaddon.mjs';
import {normalizeTank,bossLines,survivalLines,actorTank,calibrate,tankComparison,tankMetricList} from './tank.mjs';
import {appRoot,home,runsDir,readEngineMetadata,enginePaths} from './paths.mjs';
import {appRoot,home,runsDir,upstreamDir,readEngineMetadata,enginePaths} from './paths.mjs';
export {runsDir};
export const root = appRoot;
// The active engine can change while the server runs (one-click update), so these are live bindings.
Expand Down Expand Up @@ -67,8 +69,17 @@ export async function installedWowVersion(){
}catch{return null;}
}
// 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'];
// are the city's five training dummies, measured from a combat log (see dummies.mjs). A Mythic+ route is a whole
// dungeon, pull by pull, from Mythic Dungeon Tools and Raider.IO (see mplus.mjs).
export const fightStyles=['Patchwerk','CastingPatchwerk','HecticAddCleave','DungeonSlice','HeavyMovement','LightMovement','SilvermoonDummies','MythicPlusRoute'];
// The Mythic+ data on this PC: MDT's dungeons and routes and Raider.IO's runs, below WoW's _retail_ folder.
export async function routeData(){
const where=await locate(wowInstallDir);
if(!where)throw new Error('The WoW folder was not found, so Mythic Dungeon Tools and Raider.IO cannot be read.');
return loadRoutes(where.retail,upstreamDir);
}
// A scenario that needs a measuring run before the real ones: falling dummies, and every Mythic+ route.
export const calibrates=s=>needsCalibration(s)||isRouteStyle(s?.style)&&!s.route?.expected;
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 @@ -95,9 +106,16 @@ export async function prepare(request,catalog,talentData,season) {
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)&&(o.dummyHealth?.mode??'held')===(s.dummyHealth?.mode??'held'))===i);
const unique=new Set();
const unique=new Set();let routes=null;
for(const s of scenarios) {
if(!fightStyles.includes(s.style)) throw new Error('Unknown fight style.');
if(isRouteStyle(s.style)){
if(settings.tank)throw new Error('Tank simulation is not available on a Mythic+ route. Turn it off for this fight style.');
if(weapons)throw new Error('Weapon Lab and Trinket Lab simulate fixed fights. Choose another fight style for them.');
routes??=await routeData();
s.route=resolveRoute(s.route,routes.sources);
Object.assign(s,{targets:1});delete s.bleedTail;delete s.duration;delete s.dummyHealth;
}
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,{dummyHealth:normalizeDummyHealth(s.dummyHealth)});
Expand All @@ -108,7 +126,7 @@ export async function prepare(request,catalog,talentData,season) {
// Stop attacking at the end and let damage over time run out, as a meter on a dummy counts it.
if(s.bleedTail===undefined||s.bleedTail===null||s.bleedTail===false)delete s.bleedTail;else s.bleedTail=integer(s.bleedTail,15,3,60,'Seconds for bleeds to run out');
if(s.duration!==undefined)s.duration=integer(s.duration,300,10,1200,'Scenario duration');
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);
const key=`${s.style}-${s.targets}-${s.duration||''}-${s.dummyHealth?.mode||''}-${s.route?[s.route.dungeon,s.route.routeId,s.route.level,s.route.pace.mode,s.route.pace.run||s.route.shareSet,s.route.gap,s.route.lust].join(':'):''}`; 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 @@ -169,13 +187,16 @@ export const bleedTailLines=(scenario,duration)=>scenario.bleedTail?`vary_combat
// 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,dummies=isDummyStyle(scenario.style);
const boss=settings.tank?.boss,dummies=isDummyStyle(scenario.style),route=isRouteStyle(scenario.style)?scenario.route:null;
// 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.
// A bleed tail is a raid event, which fight_style=Patchwerk would clear too.
const style=dummies||(boss||scenario.bleedTail)&&scenario.style==='Patchwerk'?'':`fight_style=${scenario.style}\n`;
// A Mythic+ route is SimC's DungeonRoute: the pulls are its raid events, it ends when the last pull dies, and
// Bloodlust comes from the pulls. Warriors would switch fixed_time back on and end the run on a timer instead.
const style=route?'fight_style=DungeonRoute\n':dummies||(boss||scenario.bleedTail)&&scenario.style==='Patchwerk'?'':`fight_style=${scenario.style}\n`;
const pulls=route?`${/^warrior=/m.test(variant.text)?'warrior_fixed_time=0\n':''}${routeLines(route).join('\n')}\n`:'';
const duration=scenario.duration||settings.duration;
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=${duration+(scenario.bleedTail||0)}\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)}${bleedTailLines(scenario,duration)}${boss?survivalLines(boss).join('\n')+'\n':''}${paths.apl?`save_actions="${paths.apl.replaceAll('\\','/')}"\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=${duration+(scenario.bleedTail||0)}\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)}${bleedTailLines(scenario,duration)}${pulls}${boss?survivalLines(boss).join('\n')+'\n':''}${paths.apl?`save_actions="${paths.apl.replaceAll('\\','/')}"\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 @@ -207,7 +228,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)+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)};
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.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 @@ -238,7 +259,10 @@ 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);
for(const [s,scenario] of plan.scenarios.entries()){
if(needsCalibration(scenario))scenario.dummyHealth=await this.calibrateDummyHealth(job,plan,s);
else if(calibrates(scenario))scenario.route=await this.calibrateRoute(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 All @@ -255,7 +279,9 @@ export class Jobs {
try{
await this.execute(input,dir,job);
if(job.status==='cancelled')break;
Object.assign(row,resultFrom(JSON.parse(await fs.readFile(paths.json,'utf8'))),{status:'complete'});
const report=JSON.parse(await fs.readFile(paths.json,'utf8'));
Object.assign(row,resultFrom(report),{status:'complete'});
if(isRouteStyle(plan.scenarios[s].style))row.route=routeResult(plan.scenarios[s].route,pullLengths(await fs.readFile(paths.html,'utf8')),report.sim.players[0].collected_data.fight_length?.mean);
}catch(e){if(job.status==='cancelled')break;Object.assign(row,{status:'failed',error:e.message});}
const base=job.results.find(r=>r.baseline&&r.scenario===s&&r.status==='complete');
if(plan.settings.tank&&row.status==='complete'&&base&&!row.baseline&&row.tank&&base.tank)Object.assign(row,tankComparison(row,base,plan.settings.tank.boss,plan.settings.tank.weight));
Expand Down Expand Up @@ -591,6 +617,25 @@ 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.
// A Mythic+ route: one shorter run at a trial share measures every pull's length; the share, the time between
// pulls and the Bloodlust pulls come from it (see finishRoute in mplus.mjs).
// Bloodlust shortens the pull it lands on, so a second measuring run has it where the first one placed it.
async calibrateRoute(job,plan,s){
const dir=path.join(runsDir,job.id),scenario=plan.scenarios[s];
const settings={...plan.settings,iterations:Math.min(plan.settings.iterations,1000),targetError:Math.max(plan.settings.targetError,0.5)};
let route=null;
for(const pass of [1,2]){
const trial={...trialRoute(scenario.route),...(route?{lust:route.lust}:{})};
job.current={name:`Measuring the pace of ${scenario.route.dungeonName} · ${pass} of 2`,scenario,index:job.results.length};await this.save(this.public(job));
const stem=`route-${s}-${pass}`;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,route:trial},paths));
await this.execute(input,dir,job);if(job.status==='cancelled')throw new Error('Cancelled.');
route=finishRoute(scenario.route,trial,pullLengths(await fs.readFile(paths.html,'utf8')));
if(pass===2)route.lust=trial.lust;
}
job.done++;await this.save(this.public(job));
return route;
}
// 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){
Expand Down
Loading
Loading