From 012419b0071d34ea7d7c1881f8e0a8938dc408bf Mon Sep 17 00:00:00 2001 From: Roburmaster <25195137+Roburmaster@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:22:56 +0200 Subject: [PATCH] 1.35.0: Mythic+ route fight style (experimental, made by AI) A whole season dungeon simulated pull by pull with SimC's DungeonRoute, built at run time from Mythic Dungeon Tools (enemy health, forces, bosses, saved routes) and Raider.IO (replays of the player's own runs). - Routes from an MDT route or one of your runs; bosses matched to the game's encounters by name, since MDT's encounter IDs are wrong for new dungeons. - Pace from a run: each killed boss lasts as long as it did there, trash fills the rest of the run's time; two measuring runs place Bloodlust. - Results show the run against yours, your share on bosses and trash, and every pull; variants show run time. - Warriors no longer force fixed_time on this fight style. - Marked experimental and made by AI in the app and the README. - Ships Electron 44.4.5 (Dependabot bump merged after 1.34.0). Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++ desktop/package-lock.json | 4 +- desktop/package.json | 2 +- lib/engine.mjs | 67 ++++++-- lib/mplus.mjs | 345 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- public/app.js | 17 +- public/index.html | 2 +- public/mplus.js | 91 ++++++++++ server.mjs | 10 +- tests/mplus.test.mjs | 82 +++++++++ 12 files changed, 619 insertions(+), 25 deletions(-) create mode 100644 lib/mplus.mjs create mode 100644 public/mplus.js create mode 100644 tests/mplus.test.mjs diff --git a/README.md b/README.md index dbc64bb..b0e86f3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index c071386..dd52c9d 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.34.0", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.34.0", + "version": "1.35.0", "license": "GPL-3.0-or-later", "dependencies": { "electron-updater": "^6.8.9" diff --git a/desktop/package.json b/desktop/package.json index 859b480..3e28475 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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, diff --git a/lib/engine.mjs b/lib/engine.mjs index 45f0798..fb9e7f6 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -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. @@ -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)||nmax) throw new Error(`${label} must be ${min}–${max}.`); return n; } @@ -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)}); @@ -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.'); @@ -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; @@ -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:[]});} @@ -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); @@ -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)); @@ -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){ diff --git a/lib/mplus.mjs b/lib/mplus.mjs new file mode 100644 index 0000000..74fa0f3 --- /dev/null +++ b/lib/mplus.mjs @@ -0,0 +1,345 @@ +// EXPERIMENTAL, MADE BY AI: written by an AI assistant (Claude) and not yet validated against Mythic+ combat logs. +// Treat its numbers as a comparison between choices on the same route, not as a forecast of a real run. +// Mythic+ routes: a whole dungeon as SimC's DungeonRoute fight style, one pull event per pull, built from what is on +// this PC and never shipped with the app: +// - Mythic Dungeon Tools (installed addon): every enemy's health at keystone level 1, its enemy forces and whether it +// is a boss, and the player's own routes from its saved variables. MDT's health formula is used as it is; checked +// against a +14 combat log, it matched the log's maximum health to 0.1%. +// - Raider.IO (installed addon): replays of the player's own recent runs, which hold when every enemy-forces mob +// died (with its forces) and when every boss was pulled and killed. That is the run's real pace. +// SimC only simulates the player, but five players kill the mobs. So every mob gets the player's share of its health: +// either a share the user sets, or the share that makes the simulated bosses last as long as they did in the run. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {parseLua} from './lua.mjs'; + +export const routeStyle='MythicPlusRoute'; +export const isRouteStyle=style=>style===routeStyle; +// Bloodlust's debuff lasts ten minutes, so a group can lust again on the first pull after that. +export const lustCooldown=600; +// Kills further apart than this are separate pulls in a replay: the first kill of a new pull takes longer than this +// from the last of the one before, while a big pull's kills come closer together. +export const pullGap=20; +export const defaults={share:25,gap:10,level:10}; + +// MDT 6.2: health at a keystone level. Fortified and Tyrannical are both on from +10 in Midnight; from +11 Xal'atath's +// Guile scales by 10% a level instead of 7%. The scaling is rounded to two decimals first, as MDT does. +export function healthAt(base,level,boss){ + let mult=1; + if(level>=10)mult*=boss?1.25:1.2; + const scaling=Math.round(mult*1.07**Math.min(level-1,9)*1.1**Math.max(0,level-10)*100)/100; + return Math.round(scaling*base); +} + +// A Lua table constructor that follows `marker`, cut out with its braces balanced (strings and comments skipped). +export function tableAfter(text,marker){ + const at=text.indexOf(marker);if(at<0)return null; + let i=text.indexOf('{',at);if(i<0)return null; + const start=i;let depth=0; + for(;iArray.isArray(table)?table[i-1]:table?.[i]; +const list=table=>Array.isArray(table)?table:Object.values(table||{}); +const entries=table=>Array.isArray(table)?table.map((v,i)=>[i+1,v]):Object.entries(table||{}).map(([k,v])=>[Number(k),v]); + +// One dungeon file from MDT's Midnight folder. +export function readMdtDungeon(text){ + const index=Number(/local dungeonIndex\s*=\s*(\d+)/.exec(text)?.[1]); + const name=/englishName\s*=\s*"([^"]+)"/.exec(text)?.[1]; + const table=tableAfter(text,'MDT.dungeonEnemies[dungeonIndex]'); + if(!index||!name||!table)return null; + const total=Number(/dungeonTotalCount\[dungeonIndex\]\s*=\s*\{\s*normal\s*=\s*(\d+)/.exec(text)?.[1])||null; + const enemies=entries(parseLua(`e=${table}`).e).map(([i,e])=>({index:i,name:e.name,id:e.id,count:e.count||0,health:e.health||0,boss:!!e.isBoss,encounter:e.encounterID||null, + clones:entries(e.clones).map(([c,v])=>({index:c,x:v.x,y:v.y,group:v.g??null}))})); + return {index,name,total,enemies}; +} +// Routes from MDT's saved variables, per dungeon index; only routes with at least one enemy in them. +export function readMdtRoutes(text){ + const db=parseLua(text).MythicDungeonToolsDB;const routes=new Map(); + for(const [index,presets] of entries(db?.global?.presets)){ + for(const [n,preset] of entries(presets)){ + const pulls=list(preset?.value?.pulls).map(pull=>entries(pull).filter(([k])=>Number.isInteger(k)).map(([enemy,clones])=>({enemy,clones:list(clones).map(Number)}))); + if(!pulls.some(p=>p.some(e=>e.clones.length)))continue; + if(!routes.has(index))routes.set(index,[]); + routes.get(index).push({id:`mdt-${index}-${n}`,name:preset.text||`Route ${n}`,level:preset.difficulty||null,pulls}); + } + } + return routes; +} +// Raider.IO's replays: kills (time and forces), boss pulls and deaths, in seconds from the key's start. +export function readReplays(text){ + const data=parseLua(text.replace(/^\s*local\s+_\s*,\s*ns\s*=\s*\.\.\.\s*$/m,'').replace(/\bns\.REPLAYS\s*=/,'REPLAYS =')).REPLAYS; + return list(data).filter(r=>r?.dungeon&&r.events).map(r=>{ + const events=list(r.events).map(list); + const bosses=list(r.encounters).map(e=>({journal:e.journal_encounter_id,ordinal:e.ordinal,start:null,end:null,killed:false})); + for(const [t,type,ordinal,,,success] of events){ + const boss=bosses.find(b=>b.ordinal===ordinal);if(!boss)continue; + if(type===3)boss.start=t/1000; + if(type===4){boss.end=t/1000;boss.killed=!!success;} + } + return {id:`rio-${r.keystone_run_id}`,dungeon:r.dungeon.name,short:r.dungeon.short_name,level:r.mythic_level,clearTime:r.clear_time_ms/1000,date:r.date, + kills:events.filter(e=>e[1]===2).map(([t,,count])=>({t:t/1000,count})),deaths:events.filter(e=>e[1]===1).map(e=>e[0]/1000),bosses}; + }); +} + +// Where MDT and Raider.IO keep their data, below WoW's _retail_ folder. +export async function readSources(retail){ + const addons=path.join(retail,'Interface','AddOns'); + const dungeons=new Map(),routes=new Map();let replays=[];const missing=[]; + const mdt=path.join(addons,'MythicDungeonTools','Midnight'); + const files=await fs.readdir(mdt).catch(()=>null); + if(!files)missing.push('Mythic Dungeon Tools is not installed, so there is no enemy health or route data.'); + for(const file of (files||[]).filter(f=>f.endsWith('.lua'))){ + try{const d=readMdtDungeon(await fs.readFile(path.join(mdt,file),'utf8'));if(d)dungeons.set(d.index,d);}catch{} + } + for(const account of await fs.readdir(path.join(retail,'WTF','Account')).catch(()=>[])){ + const text=await fs.readFile(path.join(retail,'WTF','Account',account,'SavedVariables','MythicDungeonTools.lua'),'utf8').catch(()=>null); + if(!text)continue; + try{for(const [index,list] of readMdtRoutes(text))routes.set(index,[...(routes.get(index)||[]),...list.map(r=>({...r,id:`${r.id}-${account}`}))]);}catch{} + } + const rio=await fs.readFile(path.join(addons,'RaiderIO','db','db_client_replays.lua'),'utf8').catch(()=>null); + if(rio)try{replays=readReplays(rio);}catch{} + else missing.push('Raider.IO is not installed, so there are no runs to take the pace from.'); + return {dungeons,routes,replays,missing}; +} +// The season's dungeons: the game data's Mythic+ list names them, and each name is an instance with its encounters. +export async function seasonPool(upstream){ + const instances=JSON.parse(await fs.readFile(path.join(upstream,'instances.json'),'utf8')); + const names=instances.find(i=>i.id===-1)?.encounters||[]; + return names.map(n=>instances.find(i=>i.id>0&&i.name===n.name)).filter(Boolean); +} +export async function loadRoutes(retail,upstream){ + const sources=await readSources(retail); + return {sources,season:seasonDungeons(await seasonPool(upstream),sources)}; +} +// The season's dungeons, by name from the game data's Mythic+ list, matched to MDT's files, with each boss encounter +// tied to MDT's enemies (see attachBosses). +const flat=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]/g,''); +export function seasonDungeons(instances,sources){ + return instances.map(({name,encounters})=>{ + const d=[...sources.dungeons.values()].find(x=>flat(x.name)===flat(name)); + if(d&&!d.bosses)attachBosses(d,encounters||[]); + const replays=sources.replays.filter(r=>flat(r.dungeon)===flat(name)).sort((a,b)=>String(b.date).localeCompare(String(a.date))); + return {name,index:d?.index??null,total:d?.total??null,bosses:d?.bosses||[],routes:d?(sources.routes.get(d.index)||[]):[],replays}; + }); +} +// MDT's encounter IDs are not reliable for new dungeons (every boss of Altar of Fangs says 2880), so encounters are +// matched by name first: a shared word that is not a title ("Dazar, The First King" and King Dazar). An MDT boss +// with no encounter at all is a mini-boss and counts as trash. The rest are placed by where they stand: next to a +// matched boss of the encounter MDT names (Dazar's T'zala), or as one group per encounter no name matched (the +// Lightblossom Trinity, the Council of Tribes). +const titles=new Set(['the','and','of','king','queen','lord','lady','first','high','avatar','council','tribes']); +const words=name=>String(name).toLowerCase().split(/[^a-z']+/).map(w=>w.replace(/'/g,'')).filter(w=>w.length>=4&&!titles.has(w)); +const near=(a,b)=>a.clones.some(c=>b.clones.some(d=>Math.hypot(c.x-d.x,c.y-d.y)<60)); +export function attachBosses(dungeon,encounters){ + const bosses=encounters.map(e=>({journal:e.id,name:e.name,enemies:[]})); + const left=[]; + for(const e of dungeon.enemies.filter(x=>x.boss&&x.encounter)){ + const mine=new Set(words(e.name)); + const match=bosses.find(b=>flat(b.name).includes(flat(e.name))||flat(e.name).includes(flat(b.name))||words(b.name).some(w=>mine.has(w))); + if(match)match.enemies.push(e);else left.push(e); + } + const groups=[]; + for(const e of left){ + const beside=bosses.find(b=>b.journal===e.encounter&&b.enemies.some(x=>near(x,e))); + if(beside){beside.enemies.push(e);continue;} + const group=groups.find(g=>g.some(x=>near(x,e)));if(group)group.push(e);else groups.push([e]); + } + const open=bosses.filter(b=>!b.enemies.length); + if(open.length===groups.length)open.forEach((b,i)=>b.enemies.push(...groups[i])); + else for(const g of groups){const b=open.find(o=>o.journal===g[0].encounter&&!o.enemies.length);if(b)b.enemies.push(...g);} + dungeon.bosses=bosses.map(b=>({journal:b.journal,name:b.name,enemies:b.enemies.map(e=>e.index)})); + const of=new Map(dungeon.bosses.flatMap(b=>b.enemies.map(i=>[i,b.journal]))); + for(const e of dungeon.enemies)e.journal=of.get(e.index)??null; + return dungeon.bosses; +} + +const centre=mobs=>({x:mobs.reduce((s,m)=>s+m.x,0)/mobs.length,y:mobs.reduce((s,m)=>s+m.y,0)/mobs.length}); +// A mob in a pull. `boss` is MDT's flag, which decides between Tyrannical and Fortified health; `journal` is the boss +// encounter it belongs to, if any. +const mob=(e,clone)=>({name:e.name,id:e.id,count:e.count,health:e.health,boss:e.boss,journal:e.journal??null,...(clone?{x:clone.x,y:clone.y}:{})}); +// A boss encounter's enemies (a council is several) and where it stands. +function bossPull(dungeon,journal){ + const b=dungeon.bosses?.find(x=>x.journal===journal); + const mobs=(b?.enemies||[]).map(i=>at(dungeon.enemies,i)).filter(Boolean).flatMap(e=>e.clones.length?e.clones.map(c=>mob(e,c)):[mob(e,{x:0,y:0})]); + return {boss:journal,name:b?.name||mobs.map(m=>m.name).join(' & '),mobs}; +} +// An MDT route as pulls. Routes usually leave the bosses out; each missing boss goes after the pull nearest to it. +export function routeFromMdt(dungeon,route){ + const pulls=route.pulls.map(p=>({mobs:p.flatMap(({enemy,clones})=>{const e=at(dungeon.enemies,enemy);return e?clones.map(c=>mob(e,e.clones.find(x=>x.index===c))):[];})})).filter(p=>p.mobs.length); + for(const p of pulls){const boss=p.mobs.find(m=>m.journal);if(boss){p.boss=boss.journal;p.name=dungeon.bosses.find(b=>b.journal===boss.journal)?.name||boss.name;}} + for(const encounter of (dungeon.bosses||[]).filter(b=>b.enemies.length).map(b=>b.journal).filter(e=>!pulls.some(p=>p.boss===e))){ + const boss=bossPull(dungeon,encounter),where=centre(boss.mobs); + let best=-1,distance=Infinity; + for(const [i,p] of pulls.entries()){const placed=p.mobs.filter(m=>m.x!==undefined);if(!placed.length)continue;const c=centre(placed);const d=Math.hypot(c.x-where.x,c.y-where.y);if(d({...p,mobs:mobs.map(({x,y,...m})=>m)}))}; +} +// The enemies a replay kill was. Raider.IO adds up the forces of mobs that die at the same moment (two 5s make a +// 10), so a count no enemy has is split: into mobs of one kind if it divides evenly, else into the fewest mobs that +// add up to it. Each is the most common enemy with that many forces. Enemies worth no forces never show in a +// replay, so a replay pull misses them. +function enemiesFor(dungeon,count){ + const pool=dungeon.enemies.filter(e=>!e.journal&&e.count>0); + const byCount=new Map(); + for(const e of pool){const best=byCount.get(e.count);if(!best||e.clones.length>best.clones.length||e.clones.length===best.clones.length&&e.health>best.health)byCount.set(e.count,e);} + const counts=[...byCount.keys()].sort((x,y)=>y-x); + if(byCount.has(count))return [mob(byCount.get(count))]; + const even=counts.find(k=>count%k===0); + if(even)return Array.from({length:count/even},()=>mob(byCount.get(even))); + const fewest=[[]]; + for(let c=1;c<=count;c++){let best=null;for(const k of counts)if(k<=c&&fewest[c-k]&&(!best||fewest[c-k].length+1mob(byCount.get(k))); + const near=pool.sort((x,y)=>Math.abs(x.count-count)-Math.abs(y.count-count))[0]; + return near?[{...mob(near),health:Math.round(near.health*count/Math.max(1,near.count)),guessed:true}]:[]; +} +// A replay that kept too few of its kills (some hold none at all) can still pace a route, but is no route itself. +export const replayHasRoute=(replay,total)=>replay.kills.reduce((s,k)=>s+k.count,0)>=0.5*(total||1); +// A Raider.IO run as pulls: boss pulls as they happened, and the kills between them grouped into pulls wherever more +// than `pullGap` seconds pass without one. Each pull keeps when it ended in the run (bosses also when they started). +export function routeFromReplay(dungeon,replay){ + const windows=replay.bosses.filter(b=>b.start!==null); + const inBoss=t=>windows.some(b=>t>=b.start&&t<=(b.end??Infinity)+1); + const pulls=[];let current=null; + for(const kill of replay.kills.filter(k=>!inBoss(k.t))){ + if(!current||kill.t-current.end>pullGap){current={mobs:[],first:kill.t,end:kill.t};pulls.push(current);} + current.mobs.push(...enemiesFor(dungeon,kill.count));current.end=kill.t; + } + for(const b of windows){ + const boss=bossPull(dungeon,b.journal); + if(boss.mobs.length)pulls.push({...boss,mobs:boss.mobs.map(({x,y,...m})=>m),start:b.start,end:b.end??replay.clearTime,killed:b.killed}); + } + pulls.sort((a,b)=>a.end-b.end); + return {source:'replay',id:replay.id,name:`+${replay.level} ${replay.short||replay.dungeon} · ${String(replay.date).slice(0,10)}`,level:replay.level,clearTime:replay.clearTime,deaths:replay.deaths.length,pulls:pulls.filter(p=>p.mobs.length)}; +} + +// Names SimC accepts in an enemies= string. +const simName=(name,boss)=>(boss?'BOSS_':'')+(String(name).normalize('NFKD').replace(/[^A-Za-z0-9]+/g,'_').replace(/^_|_$/g,'')||'Enemy'); +// Each mob's health for the player: its health at the key level times the player's share. +export const pullHealth=(pull,level,share)=>pull.mobs.map(m=>Math.max(1,Math.round(healthAt(m.health,level,m.boss)*share))); +// A pull's share: bosses and trash each have their own once a run has paced them. +export const shareOf=(route,i)=>route.shares?.[i]??route.share; +export function routeLines(route){ + const {pulls,level,delays,lust}=route; + return pulls.map((p,i)=>{ + const health=pullHealth(p,level,shareOf(route,i)); + const enemies=p.mobs.map((m,j)=>`${simName(m.name,!!m.journal)}:${health[j]}`).join('|'); + return `raid_events+=/pull,pull=${String(i+1).padStart(2,'0')},bloodlust=${lust[i]?1:0},delay=${Math.max(0,Math.round((delays[i]||0)*10)/10)},enemies=${enemies}`; + }); +} +// Bloodlust on the first pull and then on the first pull that starts once the debuff has run out. +export function lustPlan(starts){ + const lust=starts.map(()=>false);let last=-Infinity; + for(const [i,t] of starts.entries())if(t-last>=lustCooldown){lust[i]=true;last=t;} + return lust; +} +// When each pull starts, from the delays before them and the pull lengths. +export function timeline(delays,lengths){ + const starts=[];let t=0; + for(const [i,d] of delays.entries()){t+=d||0;starts.push(t);t+=lengths[i]||0;} + return {starts,end:t}; +} +// The mean length of every pull, from SimC's HTML report ("Pull 3 (35.8): …"); the JSON only has the last iteration's. +export function pullLengths(html){ + const lengths=[]; + for(const m of String(html).matchAll(/Pull (\d+) \(([\d.]+)\)/g))lengths[Number(m[1])-1]=Number(m[2]); + return lengths; +} + +// The route a scenario asks for, with its data read from the sources: dungeon, pulls and the run it is paced from. +export function resolveRoute(input,sources){ + const dungeon=sources.dungeons.get(Number(input?.dungeon)); + if(!dungeon)throw new Error('Choose a dungeon that Mythic Dungeon Tools knows.'); + const level=Number(input.level??defaults.level); + if(!Number.isInteger(level)||level<2||level>40)throw new Error('Keystone level must be 2–40.'); + const replay=id=>sources.replays.find(r=>r.id===id&&flat(r.dungeon)===flat(dungeon.name)); + let route; + if(String(input.route||'').startsWith('rio-')){ + const r=replay(input.route);if(!r)throw new Error('That Raider.IO run is no longer in the addon’s data.'); + if(!replayHasRoute(r,dungeon.total))throw new Error('Raider.IO kept too few of that run’s kills to rebuild its route. Use it for the pace with an MDT route instead.'); + route=routeFromReplay(dungeon,r); + } + else{const r=(sources.routes.get(dungeon.index)||[]).find(x=>x.id===input.route);if(!r)throw new Error('Choose a route: one of your Raider.IO runs or a route saved in Mythic Dungeon Tools.');route=routeFromMdt(dungeon,r);} + if(!route.pulls.length)throw new Error('That route has no pulls.'); + const pace=input.pace==='share'?{mode:'share'}:replay(input.pace)?{mode:'replay',run:input.pace}:null; + if(!pace)throw new Error('Choose how fast the group goes: one of your runs in this dungeon, or a share of the group’s damage.'); + const share=Number(input.share??defaults.share),gap=Number(input.gap??defaults.gap); + if(!(share>=1&&share<=100))throw new Error('Your share of the group’s damage must be 1–100%.'); + if(!(gap>=0&&gap<=300))throw new Error('Time between pulls must be 0–300 seconds.'); + const paced=pace.mode==='replay'?replay(pace.run):null; + return {dungeon:dungeon.index,dungeonName:dungeon.name,level,source:route.source,routeId:route.id,routeName:route.name,lust:input.lust!==false, + pace:{...pace,...(paced?{level:paced.level,clearTime:paced.clearTime,bosses:paced.bosses.filter(b=>b.start!==null&&b.end!==null).map(b=>({journal:b.journal,length:b.end-b.start,killed:b.killed}))}:{})}, + shareSet:share/100,gap,pulls:route.pulls,...(route.clearTime?{run:{clearTime:route.clearTime,deaths:route.deaths,level:route.level}}:{})}; +} + +// The first, shorter run measures how long each pull lasts at a trial share; from it come the shares, the delays +// between pulls and where Bloodlust goes. A pull's length is its health over the player's damage per second, so it +// scales with the share. +// - Pace from a run: bosses and trash apart, because a group is relatively stronger on trash, where everyone hits +// everything. Every boss killed in the run gets the share that makes it last as long as it did there, which +// carries its own mechanics and downtime; a boss of the route the run did not kill gets their overall share +// (`share`, all their time over all their simulated time). The trash share +// makes the trash take the rest of the run's time, after the boss fights and `gap` seconds of walking before every +// pull. Both are measured at the run's own level, so a different key level keeps the group's pace and only the +// health changes. +// - Replay routes put each pull where it ended in the run (a boss where it was pulled); MDT routes spread the time +// out of combat evenly. +export function trialRoute(route){ + const share=route.pace.mode==='replay'?0.25:route.shareSet; + const level=route.pace.mode==='replay'?route.pace.level:route.level; + return {...route,level,share,shares:null,delays:route.pulls.map((p,i)=>i?route.gap:0),lust:route.pulls.map((p,i)=>route.lust&&i===0)}; +} +const sum=list=>list.reduce((a,b)=>a+b,0); +const clampShare=x=>Math.min(1,Math.max(0.02,x)); +export function finishRoute(route,trial,lengths){ + if(lengths.length!(lengths[i]>0)))throw new Error('The trial run did not report every pull’s length.'); + let share=route.shareSet,trashShare=route.shareSet,shares=route.pulls.map(()=>route.shareSet),scaled=lengths; + if(route.pace.mode==='replay'){ + let sim=0,real=0;const own=new Map(); + for(const b of route.pace.bosses.filter(b=>b.killed)){const i=route.pulls.findIndex(p=>p.boss===b.journal);if(i>=0&&b.length>0){sim+=lengths[i];real+=b.length;own.set(i,clampShare(trial.share*b.length/lengths[i]));}} + if(!(sim>0))throw new Error('The run has no killed boss to take the pace from.'); + share=clampShare(trial.share*real/sim); + const trash=route.pulls.map((p,i)=>p.boss?0:lengths[i]); + const trashTime=route.pace.clearTime-sum(route.pace.bosses.map(b=>b.length))-route.gap*route.pulls.length; + trashShare=trashTime>0&&sum(trash)>0?clampShare(trial.share*trashTime/sum(trash)):share; + shares=route.pulls.map((p,i)=>p.boss?own.get(i)??share:trashShare); + scaled=lengths.map((l,i)=>l*shares[i]/trial.share); + } + // Health at the chosen level: a higher key makes every pull longer by its health. + const levelScale=p=>route.level===trial.level?1:sum(pullHealth(p,route.level,1))/Math.max(1,sum(pullHealth(p,trial.level,1))); + const expected=scaled.map((l,i)=>l*levelScale(route.pulls[i])); + let delays=route.pulls.map((p,i)=>i?route.gap:0); + if(route.pace.mode==='replay'&&route.source==='replay'&&route.routeId===route.pace.run){ + let end=0; + delays=route.pulls.map((p,i)=>{const start=p.start??(p.end-scaled[i]);const d=Math.max(0,start-end);end=Math.max(end,start)+scaled[i];return d;}); + }else if(route.pace.mode==='replay'){ + const out=Math.max(0,route.pace.clearTime-sum(scaled)); + delays=route.pulls.map(()=>out/route.pulls.length); + } + const {starts,end}=timeline(delays,expected); + return {...route,share,trashShare,shares,delays,lust:route.lust?lustPlan(starts):route.pulls.map(()=>false),expected:{lengths:expected,starts,end}}; +} +// What a finished run says about the route: every pull's mean length, the health it had and the player's damage per +// second in it (the player is the only one hitting, so a pull's damage is its health), and the totals. +export function routeResult(route,lengths,runLength){ + const pulls=route.pulls.map((p,i)=>{ + const health=sum(pullHealth(p,route.level,shareOf(route,i))); + const length=lengths[i]||null; + return {name:p.name||summary(p.mobs),boss:!!p.boss,mobs:p.mobs.length,share:shareOf(route,i),health,realHealth:sum(pullHealth(p,route.level,1)),length,dps:length?health/length:null,lust:!!route.lust[i],delay:route.delays[i]||0,...(p.end!==undefined&&route.source==='replay'?{runEnd:p.end}:{})}; + }); + const combat=sum(pulls.map(p=>p.length||0)),damage=sum(pulls.map(p=>p.health)); + return {pulls,combat,run:runLength,activeDps:combat?damage/combat:null,share:route.share,trashShare:route.trashShare??route.share}; +} +export function summary(mobs){ + const counts=new Map();for(const m of mobs)counts.set(m.name,(counts.get(m.name)||0)+1); + return [...counts].sort((a,b)=>b[1]-a[1]).map(([n,c])=>c>1?`${c}× ${n}`:n).join(', '); +} diff --git a/package-lock.json b/package-lock.json index 5bbb59d..bfc0c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.34.0", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.34.0", + "version": "1.35.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index e959908..998747c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.34.0", + "version": "1.35.0", "private": true, "type": "module", "scripts": { diff --git a/public/app.js b/public/app.js index a57ae38..0a845af 100644 --- a/public/app.js +++ b/public/app.js @@ -11,6 +11,7 @@ import {engineUI} from '/engine.js'; import {activityUI,describeProgress,duration} from '/activity.js'; import {wowUI} from '/wow.js'; import {logsUI,rotationView} from '/logs.js'; +import {mplusUI,routeStyle} from '/mplus.js'; // The Silvermoon dummies are always five, measured from a combat log beside them (lib/dummies.mjs). const dummyStyles={ SilvermoonDummies:'Five Cleave Training Dummies stacked together, 3,537,050 health each and level 90, as measured in Silvermoon. They cannot die. Held: they sit at 1% the whole fight, as busy dummies in the city do, so execute is always up. From 100%: four short runs first measure how fast your damage takes each dummy down, then each one falls from 100% to 1% at that pace and stays there. Every raid buff, target debuff, Bloodlust and consumable starts off; turn on under Buffs what you had.' @@ -59,7 +60,7 @@ function renderVariants(){$('#variants').innerHTML=variants.map((v,i)=>`
{variants.push({name:`Alternative ${variants.length+1}`,text:''});renderVariants();updateCount();}); $('#variants').addEventListener('input',event=>{const el=event.target;if(el.dataset.field){variants[Number(el.closest('.variant').dataset.index)][el.dataset.field]=el.value;updateCount();}}); $('#variants').addEventListener('click',event=>{if(event.target.dataset.remove!==undefined){variants.splice(Number(event.target.dataset.remove),1);renderVariants();updateCount();}}); -function request(){if(mode==='trinkets')return trinkets.request();const targets=Number($('#targets').value);const scenarioTargets=dummyStyles[$('#fight-style').value]?[5]:$('#matrix').checked?[...new Set([targets,3,5])]:[targets];return {profile:$('#profile').value,mode,enchants:selections,combine:$('input[name=combine]:checked').value==='true',talentSearch:features.settings(),upgrades:upgrades.settings(),crests:crests.settings(),vault:vault.settings(),weapons:weapons.settings(),trinkets:trinkets.settings(),tank:tank.settings(),environment:environment.settings(),variants,iterations:Number($('#iterations').value),targetError:Number($('#target-error').value),duration:Number($('#duration').value),threads:Number($('#threads').value),scenarios:scenarioTargets.map(n=>({style:$('#fight-style').value,targets:n,...(dummyStyles[$('#fight-style').value]?{dummyHealth:{mode:$('#dummy-health').value}}:{}),...($('#bleed-tail-on').checked?{bleedTail:Number($('#bleed-tail').value)}:{})}))};} +function request(){if(mode==='trinkets')return trinkets.request();const targets=Number($('#targets').value);const scenarioTargets=dummyStyles[$('#fight-style').value]?[5]:$('#matrix').checked?[...new Set([targets,3,5])]:[targets];const route=$('#fight-style').value===routeStyle;return {profile:$('#profile').value,mode,enchants:selections,combine:$('input[name=combine]:checked').value==='true',talentSearch:features.settings(),upgrades:upgrades.settings(),crests:crests.settings(),vault:vault.settings(),weapons:weapons.settings(),trinkets:trinkets.settings(),tank:tank.settings(),environment:environment.settings(),variants,iterations:Number($('#iterations').value),targetError:Number($('#target-error').value),duration:Number($('#duration').value),threads:Number($('#threads').value),scenarios:route?mplus.scenarios():scenarioTargets.map(n=>({style:$('#fight-style').value,targets:n,...(dummyStyles[$('#fight-style').value]?{dummyHealth:{mode:$('#dummy-health').value}}:{}),...($('#bleed-tail-on').checked?{bleedTail:Number($('#bleed-tail').value)}:{})}))};} function updateCount(){ syncGearSelection(); const groups=Object.values(selections).filter(v=>v.length);const count=groups.reduce((n,v)=>n+v.length,0);$('#selection-count').textContent=`${count} selected`; @@ -74,10 +75,13 @@ function updateCount(){ if(mode==='weapons'){$('#run-summary').textContent='One tier list per specialization';$('#run-count').textContent='';weapons.count(request);} if(mode==='trinkets'){$('#run-summary').textContent='One trinket chart per specialization';$('#run-count').textContent='';trinkets.count(request);} } +const mplus=mplusUI({api,notice,onChange:()=>updateCount()}); const styleNames={SilvermoonDummies:'Silvermoon dummies'}; function syncFightStyle(){ - const note=dummyStyles[$('#fight-style').value]; - $('#targets').disabled=!!note;$('#matrix').disabled=!!note;if(note)$('#targets').value=5;$('#dummy-health-label').hidden=!note; + const note=dummyStyles[$('#fight-style').value],route=$('#fight-style').value===routeStyle; + $('#targets').disabled=!!note||route;$('#matrix').disabled=!!note||route;if(note)$('#targets').value=5;$('#dummy-health-label').hidden=!note; + // A Mythic+ route is as long as its pulls take, and its targets are the pulls' mobs. + $('#duration').disabled=route;$('#bleed-tail-on').disabled=route;mplus.show(route); $('#fight-style-hint').hidden=!note;$('#fight-style-hint').textContent=note||''; } // Choosing the dummies starts from nothing; what the user turns on afterwards is kept. @@ -95,7 +99,7 @@ async function poll(){clearTimeout(pollTimer);try{const id=currentJob;const job= function renderJob(job){ $('#job-status').textContent=states[job.status]||job.status;$('#job-log').textContent=job.log || (job.status==='queued'?'Waiting for earlier jobs to finish …':'Waiting for the engine …');$('#cancel').hidden=!['running','queued'].includes(job.status);trinkets.running(['running','queued'].includes(job.status)); const waiting=job.queue?.ahead?.length?`
Waiting in the queue behind ${job.queue.ahead.length} job${job.queue.ahead.length===1?'':'s'}. Simulations run one at a time.${job.queue.ahead.map(j=>`
${esc(modes[j.mode]?.[0]||j.mode)} · ${esc(j.name)} · ${j.status==='running'?`running, ${j.done} / ${j.total}${j.current?' · '+esc(j.current):''}`:'queued'} `).join('')}
`:''; - const progress=describeProgress(job);const scenario=job.current?.scenario?` · ${esc(job.current.scenario.style)}, ${job.current.scenario.targets} target${job.current.scenario.targets===1?'':'s'}`:''; + const progress=describeProgress(job);const scenario=job.current?.scenario?.route?.dungeonName?` · ${esc(job.current.scenario.route.dungeonName)} +${job.current.scenario.route.level}`:job.current?.scenario?` · ${esc(job.current.scenario.style)}, ${job.current.scenario.targets} target${job.current.scenario.targets===1?'':'s'}`:''; const took=job.started&&job.finished?` · took ${duration((new Date(job.finished)-new Date(job.started))/1000)}`:''; $('#job-progress').innerHTML=waiting+`
${esc(job.name)} · ${job.done} of ${job.total} steps done${scenario}${took}${progress.lines.map(l=>`
${l}`).join('')}${job.error?`
${esc(job.error)}`:''}
`; let html=wow.sendButton(job)+tank.summary(job)+features.results(job)+upgrades.results(job)+crests.results(job)+vault.results(job)+weapons.results(job)+trinkets.results(job); @@ -103,14 +107,15 @@ function renderJob(job){ for(let s=0;sr.scenario===s);if(!rows.length)continue; const baseline=rows.find(r=>r.baseline&&r.status==='complete');const tanky=!!job.settings.tank;const rank=r=>tanky?(r.baseline?0:r.score??-Infinity):(r.dps||0);const ordered=[...rows].sort((a,b)=>rank(b)-rank(a));const max=Math.max(...ordered.map(r=>r.dps||0),1);const scenario=job.scenarios[s]; - html+=`

${esc(styleNames[scenario.style]||scenario.style)} / ${scenario.targets} targets / ${job.settings.duration} sec${scenario.bleedTail?` + ${scenario.bleedTail} s of bleeds`:''}${scenario.dummyHealth?.mode==='falling'?' / dummies from 100%':scenario.dummyHealth?' / dummies held at 1%':''}

${scenario.dummyHealth?.reached?`

Your damage took the dummies to 1% after ${scenario.dummyHealth.reached.map(r=>r===null?'—':r.toFixed(0)+' s').join(', ')} (the first is your main target).

`:''}`; + if(scenario.style===routeStyle)html+=`

${mplus.title(scenario)}

${mplus.details(scenario,baseline)}`; + else html+=`

${esc(styleNames[scenario.style]||scenario.style)} / ${scenario.targets} targets / ${job.settings.duration} sec${scenario.bleedTail?` + ${scenario.bleedTail} s of bleeds`:''}${scenario.dummyHealth?.mode==='falling'?' / dummies from 100%':scenario.dummyHealth?' / dummies held at 1%':''}

${scenario.dummyHealth?.reached?`

Your damage took the dummies to 1% after ${scenario.dummyHealth.reached.map(r=>r===null?'—':r.toFixed(0)+' s').join(', ')} (the first is your main target).

`:''}`; if(rows.length===1&&job.total===1&&baseline)html+=`
${number(baseline.dps)}DPS${baseline.error95!==null?` · ±${number(baseline.error95)} (95 %)` : ''}
`; html+=`${tanky?'':''}`; for(const [i,row] of ordered.entries()){ const delta=baseline && row.status==='complete' ? row.dps-baseline.dps:null; const deltaError=baseline && row.error95!==null && baseline.error95!==null ? Math.hypot(row.error95,baseline.error95):null; const uncertain=!row.baseline && delta!==null && deltaError!==null && Math.abs(delta)<=deltaError; - html+=`${tanky?``:''}`; + html+=`${tanky?``:''}`; } html+=`
VariantDPS / 95 %vs baselineSurvivalScoreReport
${esc(row.baseline?(job.mode==='talents'?'Current talents':'Current gear'):row.name)}${row.baseline?'BASELINE':''}${row.status==='complete'?`
`:`${esc(row.error)}`}
${row.status==='complete'?number(row.dps):'Failed'}${row.status==='complete'&&row.error95!==null?`±${number(row.error95)}`:''}${delta===null?'—':row.baseline?'—':`${delta>=0?'+':''}${(100*delta/Math.max(1,baseline.dps)).toFixed(2)} %${delta>=0?'+':''}${number(delta)} DPS${uncertain?' · uncertain':''}`}${row.baseline&&row.tank?`${(100*row.tank.alive).toFixed(0)}% survived${row.tank.deaths!==undefined?(100*row.tank.deaths).toFixed(0)+'% deaths · ':''}net ${(100*(row.tank.dtps-row.tank.hps)/row.tank.health).toFixed(2)}%/s`:Number.isFinite(row.survival)?`${signed(row.survival)} %${(100*row.tank.alive).toFixed(0)}% survived`:'—'}${Number.isFinite(row.score)?`${signed(row.score)}±${row.scoreError.toFixed(2)}${Math.abs(row.score)<=row.scoreError?' · uncertain':''}`:'—'}${row.status==='complete'?`RotationHTMLJSON`:''}Input
${esc(row.baseline?(job.mode==='talents'?'Current talents':'Current gear'):row.name)}${row.baseline?'BASELINE':''}${row.status==='complete'?`
`:`${esc(row.error)}`}
${row.status==='complete'?number(row.dps):'Failed'}${row.status==='complete'&&row.error95!==null?`±${number(row.error95)}`:''}${mplus.runTime(row,baseline)}${delta===null?'—':row.baseline?'—':`${delta>=0?'+':''}${(100*delta/Math.max(1,baseline.dps)).toFixed(2)} %${delta>=0?'+':''}${number(delta)} DPS${uncertain?' · uncertain':''}`}${row.baseline&&row.tank?`${(100*row.tank.alive).toFixed(0)}% survived${row.tank.deaths!==undefined?(100*row.tank.deaths).toFixed(0)+'% deaths · ':''}net ${(100*(row.tank.dtps-row.tank.hps)/row.tank.health).toFixed(2)}%/s`:Number.isFinite(row.survival)?`${signed(row.survival)} %${(100*row.tank.alive).toFixed(0)}% survived`:'—'}${Number.isFinite(row.score)?`${signed(row.score)}±${row.scoreError.toFixed(2)}${Math.abs(row.score)<=row.scoreError?' · uncertain':''}`:'—'}${row.status==='complete'?`RotationHTMLJSON`:''}Input
`; } diff --git a/public/index.html b/public/index.html index 6f559e5..6710fff 100644 --- a/public/index.html +++ b/public/index.html @@ -11,7 +11,7 @@
◈

A baseline for your next upgrade

Quick Sim runs your imported gear and talents. Use Enchant Lab for enchant combinations, Gear Compare for item changes, or Talent Search to generate and simulate legal builds.

-
+
mpcombat / SimC LabIndependent local app · powered by SimulationCraft
diff --git a/public/mplus.js b/public/mplus.js new file mode 100644 index 0000000..d6f4adb --- /dev/null +++ b/public/mplus.js @@ -0,0 +1,91 @@ +const $=s=>document.querySelector(s); +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); +const clock=s=>`${Math.floor(s/60)}:${String(Math.round(s%60)).padStart(2,'0')}`; +const percent=x=>`${(100*x).toFixed(0)}%`; +const day=iso=>new Date(iso).toLocaleDateString('en-GB',{day:'numeric',month:'short'}); +export const routeStyle='MythicPlusRoute'; + +// EXPERIMENTAL, MADE BY AI: written by an AI assistant (Claude) and not yet validated against Mythic+ combat logs. +// Treat its numbers as a comparison between choices on the same route, not as a forecast of a real run. +// Mythic+ route: a whole dungeon, pull by pull, built from Mythic Dungeon Tools and Raider.IO on this PC (see +// lib/mplus.mjs). The route is the pulls; the pace is how fast the group went, which sets how much of each mob's +// health is the player's to take. +export function mplusUI({api,notice,onChange}){ + let data=null,loading=null; + $('#fight-style-hint').insertAdjacentHTML('afterend',``); + const dungeon=()=>data?.dungeons.find(d=>String(d.index)===$('#route-dungeon').value); + const usable=d=>d&&d.index!==null&&(d.runs.some(r=>r.route)||d.routes.length); + // The defaults for a dungeon: the latest run that holds a route, else an MDT route; paced on the latest run. + const pick=d=>({route:d.runs.find(r=>r.route)?.id||d.routes[0]?.id||'',pace:d.runs.find(r=>r.killed)?.id||'share'}); + function fill(){ + const d=dungeon();if(!d)return; + const runLabel=r=>`+${r.level} · ${day(r.date)} · ${clock(r.clearTime)}${r.deaths?` · ${r.deaths} death${r.deaths===1?'':'s'}`:''}`; + $('#route-source').innerHTML=[...d.runs.filter(r=>r.route).map(r=>``),...d.routes.map(r=>``)].join('')||''; + $('#route-pace').innerHTML=[...d.runs.filter(r=>r.killed).map(r=>``),''].join(''); + const p=pick(d);$('#route-source').value=p.route;$('#route-pace').value=p.pace; + sync(); + } + function sync(){ + const share=$('#route-pace').value==='share',all=$('#route-all').checked; + $('#route-share-label').hidden=!share&&!all; + for(const id of ['#route-dungeon','#route-source','#route-pace'])$(id).disabled=all; + const d=dungeon(); + $('#route-hint').textContent=all?`${data.dungeons.filter(usable).length} of ${data.dungeons.length} season dungeons have a route on this PC. Each runs on your latest run there and is paced on it; one without a finished run uses the share above.`: + share?'Every mob gets this share of its health at the key level: the rest of the group does the rest.': + `Bosses last as long as they did in that run and the trash takes the rest of its time, each at the share of the group’s damage that makes it so.${d?.runs.find(r=>r.id===$('#route-pace').value)?.level!==Number($('#route-level').value)?' At another key level the pace stays and the health changes.':''}`; + onChange(); + } + async function load(){ + loading??=api('/api/mplus').then(r=>{ + data=r; + $('#route-dungeon').innerHTML=r.dungeons.map(d=>``).join(''); + const first=r.dungeons.find(usable);if(first)$('#route-dungeon').value=String(first.index); + const latest=r.dungeons.flatMap(d=>d.runs).sort((a,b)=>String(b.date).localeCompare(String(a.date)))[0];if(latest)$('#route-level').value=latest.level; + if(r.missing.length)notice(r.missing.join(' ')); + fill(); + }).catch(e=>{loading=null;notice(e.message);}); + return loading; + } + $('#route-dungeon').addEventListener('change',fill); + for(const id of ['#route-source','#route-pace','#route-all','#route-level','#route-gap','#route-share','#route-lust'])$(id).addEventListener('change',sync); + const common=()=>({level:Number($('#route-level').value),gap:Number($('#route-gap').value),share:Number($('#route-share').value),lust:$('#route-lust').checked}); + return { + show(on){$('#route-settings').hidden=!on;if(on)void load();}, + // One scenario, or one per season dungeon that has a route. + scenarios(){ + if(!data)return [{style:routeStyle,route:{...common()}}]; + if($('#route-all').checked)return data.dungeons.filter(usable).map(d=>({style:routeStyle,route:{...common(),dungeon:d.index,...pick(d)}})); + return [{style:routeStyle,route:{...common(),dungeon:Number($('#route-dungeon').value),route:$('#route-source').value,pace:$('#route-pace').value}}]; + }, + title(scenario){ + const r=scenario.route;if(!r?.dungeonName)return 'Mythic+ route'; + return `Experimental · made by AI ${esc(r.dungeonName)} +${r.level} / ${esc(r.routeName)} / ${r.pace.mode==='replay'?`paced on your +${r.pace.level} run`:`${percent(r.shareSet)} of the group’s damage`}`; + }, + // Under a scenario: the run against the real one, and every pull of the baseline. + details(scenario,row){ + const r=scenario.route,res=row?.route;if(!r||!res)return ''; + const real=r.pace.mode==='replay'?r.pace.clearTime:null; + const shares=r.pace.mode==='replay'?`Your share of the group’s damage: ${percent(res.share)} on bosses, ${percent(res.trashShare)} on trash.`:`Your share of the group’s damage: ${percent(res.share)}.`; + let html=`

Simulated run ${clock(res.run)} (${clock(res.combat)} in combat)${real?` · your run took ${clock(real)}`:''} · ${number(res.activeDps)} DPS in combat, ${number(row.dps)} over the whole run. ${shares} Bloodlust on pull${res.pulls.filter(p=>p.lust).length===1?'':'s'} ${res.pulls.map((p,i)=>p.lust?i+1:null).filter(Boolean).join(', ')||'none'}.

`; + html+=`
Every pull${r.source==='replay'?'':''}`; + for(const [i,p] of res.pulls.entries())html+=`${r.source==='replay'?``:''}`; + return html+'
PullHealth at +${r.level}Your shareLengthYour DPSEnded in your run
${i+1}. ${esc(p.name)}${p.lust?'BLOODLUST':''}${number(p.realHealth)}${p.mobs} mob${p.mobs===1?'':'s'}${percent(p.share)}${p.length?p.length.toFixed(1)+' s':'—'}after ${p.delay.toFixed(0)} s${p.dps?number(p.dps):'—'}${p.runEnd!==undefined?clock(p.runEnd):'—'}
'; + }, + // In a variant's row: how long the run took, so gear and talents can be read as time saved. + runTime(row,base){ + if(!row?.route)return ''; + const d=base?.route&&row!==base?row.route.run-base.route.run:null; + return `Run ${clock(row.route.run)}${d!==null?` · ${d<=0?'−':'+'}${Math.abs(d).toFixed(0)} s`:''}`; + } + }; +} diff --git a/server.mjs b/server.mjs index b035a3e..786b12f 100644 --- a/server.mjs +++ b/server.mjs @@ -28,6 +28,7 @@ import {importArmory,isArmoryProfile} from './lib/armory.mjs'; import {logsDir,listLogs,logPath,scanLog,readPlayer} from './lib/combatlog.mjs'; import {analyserFor,supported as analysedSpecs,simCastsPerMinute,simRotation} from './lib/analysis/index.mjs'; import {fightSetup,align,simCasts,logCasts} from './lib/analysis/compare.mjs'; +import {replayHasRoute} from './lib/mplus.mjs'; const port=Number(process.env.PORT || 8642); const pkg=JSON.parse(await fs.readFile(new URL('./package.json',import.meta.url),'utf8')); const appInfo={name:'SimC Lab',version:pkg.version,desktop:!!process.env.SIMC_LAB_DESKTOP,platform:process.platform};const token=randomBytes(32).toString('hex'); @@ -105,6 +106,13 @@ const server=http.createServer(async(req,res)=>{ if(req.method==='POST'&&route==='/api/wow/settings')return json(res,200,await wow.settings(await body(req))); if(req.method==='POST'&&route==='/api/wow/remove'){const {id}=await body(req);return json(res,200,await wow.remove(String(id)));} if(req.method==='GET'&&route==='/api/wow/captures')return json(res,200,await wow.captures()); + // Mythic+ routes: the season's dungeons with the MDT routes and Raider.IO runs this PC has for each. + if(req.method==='GET'&&route==='/api/mplus'){ + const {sources,season}=await engine.routeData(); + return json(res,200,{missing:sources.missing,dungeons:season.map(d=>({name:d.name,index:d.index,total:d.total,bosses:d.bosses.map(b=>b.name), + routes:d.routes.map(r=>({id:r.id,name:r.name,pulls:r.pulls.length})), + runs:d.replays.map(r=>({id:r.id,route:replayHasRoute(r,d.total),level:r.level,clearTime:r.clearTime,date:r.date,deaths:r.deaths.length,killed:r.bosses.filter(b=>b.killed).length,bosses:r.bosses.length}))}))}); + } if(req.method==='GET'&&route==='/api/logs'){const dir=await logsDir(await engine.wowInstallDir());return json(res,200,{dir,files:await listLogs(dir),specs:analysedSpecs});} if(req.method==='POST'&&route==='/api/logs/scan'){const {file}=await body(req);return json(res,200,await startScan(file));} // One fight read and analysed, for the simulation and the comparison that follow an analysis. @@ -238,7 +246,7 @@ const server=http.createServer(async(req,res)=>{ // Reports are generated by SimC. Serve downloads so their scripts never share this app's origin. res.writeHead(200,{'Content-Type':ext==='.json'?'application/json':'application/octet-stream','Content-Disposition':`attachment; filename="${report[2]}"`});return res.end(data); } - const assets={'/':'index.html','/app.js':'app.js','/items.js':'items.js','/wowhead.js':'wowhead.js','/features.js':'features.js','/upgrades.js':'upgrades.js','/crests.js':'crests.js','/crestplan.js':'crestplan.js','/vault.js':'vault.js','/weapons.js':'weapons.js','/trinkets.js':'trinkets.js','/tank.js':'tank.js','/engine.js':'engine.js','/activity.js':'activity.js','/environment.js':'environment.js','/wow.js':'wow.js','/logs.js':'logs.js','/armory.js':'armory.js','/style.css':'style.css'}; + const assets={'/':'index.html','/app.js':'app.js','/mplus.js':'mplus.js','/items.js':'items.js','/wowhead.js':'wowhead.js','/features.js':'features.js','/upgrades.js':'upgrades.js','/crests.js':'crests.js','/crestplan.js':'crestplan.js','/vault.js':'vault.js','/weapons.js':'weapons.js','/trinkets.js':'trinkets.js','/tank.js':'tank.js','/engine.js':'engine.js','/activity.js':'activity.js','/environment.js':'environment.js','/wow.js':'wow.js','/logs.js':'logs.js','/armory.js':'armory.js','/style.css':'style.css'}; if(req.method==='GET'&&assets[route]){const file=assets[route];res.writeHead(200,{'Content-Type':file.endsWith('.js')?'text/javascript; charset=utf-8':file.endsWith('.css')?'text/css; charset=utf-8':'text/html; charset=utf-8'});return res.end(await fs.readFile(path.join(root,'public',file)));} json(res,404,{error:'Not found.'}); }catch(e){json(res,e.code==='ENOENT'?404:400,{error:e.message});} diff --git a/tests/mplus.test.mjs b/tests/mplus.test.mjs new file mode 100644 index 0000000..d419112 --- /dev/null +++ b/tests/mplus.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {healthAt,readMdtDungeon,readMdtRoutes,readReplays,attachBosses,routeFromReplay,routeFromMdt,routeLines,pullLengths,lustPlan,trialRoute,finishRoute,routeResult,replayHasRoute} from '../lib/mplus.mjs'; + +const dungeonText=`local _, MDT = ... +local dungeonIndex = 900 +MDT.mapInfo[dungeonIndex] = { englishName = "Test Hold", mapID = 1 }; +MDT.dungeonTotalCount[dungeonIndex] = { normal = 100 } +MDT.dungeonEnemies[dungeonIndex] = { + [1] = { ["name"] = "Grunt", ["id"] = 1, ["count"] = 5, ["health"] = 1000, ["clones"] = { [1] = { ["x"] = 10, ["y"] = -10, ["g"] = 1 }, [2] = { ["x"] = 12, ["y"] = -10, ["g"] = 1 } } }, + [2] = { ["name"] = "Brute", ["id"] = 2, ["count"] = 7, ["health"] = 2000, ["clones"] = { [1] = { ["x"] = 100, ["y"] = -50 } } }, + [3] = { ["name"] = "King Gorr", ["id"] = 3, ["count"] = 0, ["health"] = 10000, ["isBoss"] = true, ["encounterID"] = 77, ["clones"] = { [1] = { ["x"] = 200, ["y"] = -50 } } }, + [4] = { ["name"] = "Gorr's Pet", ["id"] = 4, ["count"] = 0, ["health"] = 3000, ["isBoss"] = true, ["encounterID"] = 77, ["clones"] = { [1] = { ["x"] = 210, ["y"] = -55 } } }, + [5] = { ["name"] = "Rare Thing", ["id"] = 5, ["count"] = 0, ["health"] = 500, ["isBoss"] = true, ["clones"] = { [1] = { ["x"] = 50, ["y"] = -50 } } }, +} +`; +const dungeon=()=>{const d=readMdtDungeon(dungeonText);attachBosses(d,[{id:77,name:'Gorr, The Old King'}]);return d;}; + +test('health follows MDT’s keystone scaling',()=>{ + assert.equal(healthAt(1000,2,false),1070); + // +14: Fortified 1.2 × 1.07^9 × 1.1^4, rounded to 3.23 as MDT does + assert.equal(healthAt(3645228,14,false),Math.round(3.23*3645228)); + assert.equal(healthAt(1000,10,true),Math.round(Math.round(1.25*1.07**9*100)/100*1000)); +}); + +test('an MDT dungeon file and its bosses are read',()=>{ + const d=dungeon(); + assert.equal(d.index,900);assert.equal(d.name,'Test Hold');assert.equal(d.total,100); + assert.equal(d.enemies.length,5); + // Matched by a shared name word; the pet stands beside the boss and joins it; the rare has no encounter. + assert.deepEqual(d.bosses,[{journal:77,name:'Gorr, The Old King',enemies:[3,4]}]); + assert.equal(d.enemies[4].journal,null); +}); + +test('routes come from MDT saved variables, and missing bosses are placed by position',()=>{ + const routes=readMdtRoutes(`MythicDungeonToolsDB = { ["global"] = { ["presets"] = { [900] = { { ["text"] = "Empty", ["value"] = { ["pulls"] = { { ["color"] = "fff" } } } }, { ["text"] = "Mine", ["value"] = { ["pulls"] = { { [1] = { 1, 2 }, ["color"] = "fff" }, { [2] = { 1 } } } } } } } } }`); + assert.deepEqual([...routes.keys()],[900]); + const [r]=routes.get(900);assert.equal(r.name,'Mine'); + const route=routeFromMdt(dungeon(),r); + assert.deepEqual(route.pulls.map(p=>p.boss??p.mobs.length),[2,1,77]); + assert.equal(route.pulls[2].mobs.length,2); +}); + +test('a Raider.IO replay becomes pulls; merged kills are split into whole mobs',()=>{ + const text=`local _, ns = ... +ns.REPLAYS = { { ["dungeon"] = { ["name"] = "Test Hold", ["short_name"] = "TH" }, ["keystone_run_id"] = 5, ["mythic_level"] = 12, ["clear_time_ms"] = 300000, ["date"] = "2026-09-21T17:07:18Z", + ["encounters"] = { { ["journal_encounter_id"] = 77, ["ordinal"] = 0 } }, + ["events"] = { { 10000, 1, 1 }, { 30000, 2, 5 }, { 35000, 2, 10 }, { 80000, 2, 7 }, { 100000, 3, 0, 1, true, false }, { 200000, 4, 0, 1, false, true } } } }`; + const [replay]=readReplays(text); + assert.equal(replay.id,'rio-5');assert.equal(replay.deaths.length,1); + assert.deepEqual(replay.bosses[0],{journal:77,ordinal:0,start:100,end:200,killed:true}); + assert.ok(replayHasRoute(replay,40));assert.ok(!replayHasRoute(replay,100)); + const route=routeFromReplay(dungeon(),replay); + assert.deepEqual(route.pulls.map(p=>[p.mobs.map(m=>m.name).join('+'),p.end]),[['Grunt+Grunt+Grunt',35],['Brute',80],['King Gorr+Gorr\'s Pet',200]]); + assert.equal(route.pulls[2].start,100); +}); + +test('pull lines, lengths and Bloodlust',()=>{ + const route={pulls:[{mobs:[{name:"Gorr's Pet",health:1000,boss:true,journal:77}],boss:77},{mobs:[{name:'Grunt',health:1000,boss:false}]}],level:2,share:0.5,shares:[0.5,0.25],delays:[0,12.34],lust:[true,false]}; + assert.deepEqual(routeLines(route),['raid_events+=/pull,pull=01,bloodlust=1,delay=0,enemies=BOSS_Gorr_s_Pet:535','raid_events+=/pull,pull=02,bloodlust=0,delay=12.3,enemies=Grunt:268']); + assert.deepEqual(pullLengths('Pull 2 (35.8): Grunt Pull 1 (12.5): x Pull 2 (35.8)'),[12.5,35.8]); + assert.deepEqual(lustPlan([0,300,620,900,1300]),[true,false,true,false,true]); +}); + +test('a run paces the route: each boss at its own length, trash in the time left',()=>{ + const d=dungeon(); + const route={pulls:[{mobs:[{name:'Grunt',health:1000,boss:false}],end:60},{boss:77,mobs:[{name:'King Gorr',health:10000,boss:true,journal:77}],start:100,end:200}], + level:12,source:'replay',routeId:'rio-5',gap:10,lust:true,shareSet:0.25,pace:{mode:'replay',run:'rio-5',level:12,clearTime:300,bosses:[{journal:77,length:100,killed:true}]}}; + const trial=trialRoute(route); + assert.equal(trial.share,0.25);assert.deepEqual(trial.lust,[true,false]); + // At the trial share the boss took 50 s and the trash 40 s; the run took 100 s on the boss and had + // 300 - 100 - 2×10 = 180 s for trash. + const done=finishRoute(route,trial,[40,50]); + assert.equal(done.shares[1],0.5);assert.equal(done.shares[0],Math.min(1,0.25*180/40)); + // The boss starts where it was pulled in the run. + const trashEnd=done.delays[0]+done.expected.lengths[0]; + assert.equal(done.delays[1],Math.max(0,100-trashEnd)); + const result=routeResult(done,[60,100],320); + assert.equal(result.pulls.length,2);assert.equal(result.combat,160); + assert.ok(result.pulls[1].boss&&result.pulls[1].share===0.5); + assert.ok(d); +});