diff --git a/README.md b/README.md index 1a4d145..7d6be88 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,8 @@ SimC's built-in tank dummy uses level-70 damage values and never threatens a Mid - Tank-busters: a heavy melee hit every 30 seconds. SimC does not time defensive cooldowns to incoming busters, so a fixed buster size either kills a spec every time or never. The buster is therefore resized until the imported gear dies in the chosen share of fights (25% by default). Better gear can then survive more often, worse gear less often. - Healing: healers top the tank up to full health on a fixed rhythm (every 5 seconds by default). Tanks can die (`infinite_health=0`). +- Adds: every target beyond the first is a mob that attacks the tank (Targets 3 or 5, or the "compare 3 and 5 targets" box), each with an auto attack of 40% of the boss's swing (adjustable under Boss details, "Each add hits for"). The boss is still calibrated alone, so calibration is the same for one target and for five, and the adds then add their damage on top: more targets means more damage taken and a higher chance to die, and armor, avoidance and self-healing are worth what they are against a pack, not against one hit. The adds stand and swing for the whole fight, and the tank's own damage cleaves them. SimC's own adds (Hectic Add Cleave, Dungeon Slice) are pets that never attack, and Dungeon Slice, Mythic+ route and the Silvermoon dummies have no incoming damage at all, so those fight styles are off for a tank; use Patchwerk, Casting Patchwerk or the movement styles with more targets. Weapon Lab and Trinket Lab keep the single-boss fight they always had. + Presets are Mythic+ dungeon (3% health per second, 45% starting buster), Heroic raid (4%, 50%) and Mythic raid (5%, 55%), all adjustable under Boss details. The job result states the calibrated boss and how often the current gear died during calibration. Survival is the sum of two percentages: the reduction in net damage taken (damage taken minus the tank's own healing, relative to the boss's sustained damage), and the change in the share of the fight survived. Absorbs are already removed from damage taken. Score = weight × survival + (1 − weight) × DPS change, with the weight set by a slider (50% by default). Upgrade Finder screens and selects finalists by score. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index b75b2e0..42db347 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.40.0", + "version": "1.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.40.0", + "version": "1.41.0", "license": "GPL-3.0-or-later", "dependencies": { "electron-updater": "^6.8.9" diff --git a/desktop/package.json b/desktop/package.json index 996cb55..ae1b036 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.40.0", + "version": "1.41.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 c71ccb2..fcccd7a 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -17,7 +17,7 @@ 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 {normalizeTank,addsOf,bossLines,survivalLines,actorTank,calibrate,tankComparison,tankMetricList} from './tank.mjs'; import {appRoot,home,runsDir,upstreamDir,readEngineMetadata,enginePaths} from './paths.mjs'; export {runsDir}; export const root = appRoot; @@ -125,9 +125,11 @@ export async function prepare(request,catalog,talentData,season) { } // Dungeon Slice builds its own fight and nothing in it attacks the tank boss's target: every tank metric comes back // zero (no damage taken, no deaths, no healing), so the survival half of the score would be noise. Raidbots says the same. - if(s.style==='DungeonSlice'&&settings.tank)throw new Error('Dungeon Slice does not work for tank specializations: nothing in that fight attacks the tank, so there is no survival to measure. Choose another fight style, such as Patchwerk or Hectic Add Cleave.'); + if(s.style==='DungeonSlice'&&settings.tank)throw new Error('Dungeon Slice does not work for tank specializations: nothing in that fight attacks the tank, so there is no survival to measure. Choose another fight style, such as Patchwerk with more targets (each extra target is a mob that attacks you) or Hectic Add Cleave.'); // SimC's Dungeon Slice sets its own 6 minutes and one boss, whatever max_time and desired_targets say, so say so here too. if(s.style==='DungeonSlice')Object.assign(s,{targets:1,duration:360}),delete s.bleedTail; + // SimC's own adds are pets that never attack, so with attacking adds on they would only be targets for the tank's damage. + if(s.style==='HecticAddCleave'&&settings.tank?.adds)throw new Error('Hectic Add Cleave does not work for tank specializations: SimC\u2019s adds in that fight never attack. Use Patchwerk with 3 or 5 targets instead: every extra target is a mob that attacks you.'); s.targets=integer(s.targets,1,1,20,'Targets'); // 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'); @@ -191,7 +193,8 @@ export const scenarioLines=scenario=>[...(scenario.bloodlust===false?['override. // so the stop lands where it is set. DPS is then counted over the whole time, as a meter does. export const bleedTailLines=(scenario,duration)=>scenario.bleedTail?`vary_combat_length=0\nraid_events+=/stun,first=${duration},duration=${scenario.bleedTail+30},cooldown=9999,players_only=1\n`:''; // 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); +// The boss and its adds are defined before the player, so the player is the actor after them. +export const playerActorIndex=(settings,scenario)=>settings.tank?.boss?1+addsOf(settings.tank,scenario):dummyCount(scenario.style); export function inputFor(variant,settings,scenario,paths) { 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. @@ -202,7 +205,7 @@ export function inputFor(variant,settings,scenario,paths) { 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)}${pulls}${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,addsOf(settings.tank,scenario),settings.tank.addDamage).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; diff --git a/lib/tank.mjs b/lib/tank.mjs index e1c5b91..b909eba 100644 --- a/lib/tank.mjs +++ b/lib/tank.mjs @@ -13,13 +13,18 @@ export const presets={ heroic:{name:'Heroic raid',pressure:4,buster:50,magic:25,healGap:5,deathTarget:25}, mythic:{name:'Mythic raid',pressure:5,buster:55,magic:25,healGap:5,deathTarget:25} }; -const limits={pressure:[0.5,20],buster:[0,150],magic:[0,100],healGap:[1,30],deathTarget:[5,60],weight:[0,100]}; +const limits={pressure:[0.5,20],buster:[0,150],magic:[0,100],healGap:[1,30],deathTarget:[5,60],weight:[0,100],addDamage:[0,100]}; + +// With `adds` on, every target beyond the first is a mob that attacks the tank, for `addDamage` percent of a boss swing +// each. Off, extra targets are only things for the tank's own damage to hit (what every job did before), which is what +// Weapon Lab and Trinket Lab still use. +export const addsOf=(tank,scenario)=>tank?.adds?Math.max(0,(Number(scenario?.targets)||1)-1):0; export function normalizeTank(input,info){ if(!isTank(info))return null; const options=input&&typeof input==='object'?input:{}; const preset=options.preset??'mythic';if(!presets[preset])throw new Error('Choose a tank boss preset.'); - const result={preset,...presets[preset],weight:50}; + const result={preset,...presets[preset],weight:50,adds:options.adds===true,addDamage:40}; for(const key of Object.keys(limits)){ if(options[key]===undefined||options[key]===null||options[key]==='')continue; const value=Number(options[key]),[min,max]=limits[key]; @@ -31,11 +36,15 @@ export function normalizeTank(input,info){ // Enemy lines go before the character so later gear lines apply to the player; profilesets then need // profileset_main_actor_index=1 because the boss is actor 0. -export function bossLines(boss){ +// `adds` mobs follow the boss, each its own enemy that swings at the tank with an auto attack of a share of the boss's. +// They are defined here, before the player, so the player is actor 1 + adds (see playerActorIndex). +export function bossLines(boss,adds=0,addDamage=0){ const r=n=>Math.max(1,Math.round(n)); const lines=['enemy=Tank_Boss','actions=/auto_attack,damage='+r(boss.auto)+',range='+r(boss.auto*0.1)+',attack_speed=2,aoe_tanks=1']; if(boss.buster>0)lines.push('actions+=/melee_nuke,damage='+r(boss.buster)+',range='+r(boss.buster*0.05)+',attack_speed=1,cooldown=30,aoe_tanks=1'); if(boss.dot>0)lines.push('actions+=/spell_dot,damage='+r(boss.dot)+',range='+r(boss.dot*0.1)+',tick_time=2,dot_duration=30,cooldown=30,aoe_tanks=1'); + const swing=boss.auto*addDamage/100; + for(let k=1;k<=adds;k++)lines.push(`enemy=Tank_Add_${k}`,'actions=/auto_attack,damage='+r(swing)+',range='+r(swing*0.1)+',attack_speed=2,aoe_tanks=1'); return lines; } export function survivalLines(boss){ diff --git a/package-lock.json b/package-lock.json index a8efb81..b993ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.40.0", + "version": "1.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.40.0", + "version": "1.41.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index f3092a9..d4ff914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.40.0", + "version": "1.41.0", "private": true, "type": "module", "scripts": { diff --git a/public/app.js b/public/app.js index d4cbe53..db07fe4 100644 --- a/public/app.js +++ b/public/app.js @@ -83,10 +83,14 @@ let beforeSlice=null; // Nothing in Dungeon Slice attacks the tank, so a tank specialization has no survival to measure there: the choice is off // for an imported tank (the labs run their own fights and are left alone). function syncTankStyles(){ - const option=[...$('#fight-style').options].find(o=>o.value==='DungeonSlice');if(!option)return; const off=!!profile?.isTank&&!['weapons','trinkets'].includes(mode); - option.disabled=off;option.title=off?'Not available for tank specializations: nothing in this fight attacks the tank.':''; - if(off&&$('#fight-style').value==='DungeonSlice'){$('#fight-style').value='Patchwerk';syncFightStyle();} + // SimC's own adds never attack, so a tank is simulated on the fights where every extra target is a mob that hits it. + const why={DungeonSlice:'Not available for tank specializations: nothing in this fight attacks the tank. Use Patchwerk with more targets: each extra target is a mob that attacks you.',HecticAddCleave:'Not available for tank specializations: SimC’s adds in this fight never attack. Use Patchwerk with more targets: each extra target is a mob that attacks you.'}; + for(const [style,reason] of Object.entries(why)){ + const option=[...$('#fight-style').options].find(o=>o.value===style);if(!option)continue; + option.disabled=off;option.title=off?reason:''; + if(off&&$('#fight-style').value===style){$('#fight-style').value='Patchwerk';syncFightStyle();} + } } function syncFightStyle(){ const note=dummyStyles[$('#fight-style').value],route=$('#fight-style').value===routeStyle,slice=$('#fight-style').value==='DungeonSlice'; diff --git a/public/tank.js b/public/tank.js index 7373add..e51bb79 100644 --- a/public/tank.js +++ b/public/tank.js @@ -1,6 +1,6 @@ const $=s=>document.querySelector(s); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const fields=[['pressure','Sustained damage (% health / sec)',0.5,20,0.5],['buster','Starting tank-buster (% health)',0,150,5],['magic','Magic share (%)',0,100,5],['healGap','Healer top-up every (sec)',1,30,1],['deathTarget','Current gear dies in (% of fights)',5,60,5]]; +const fields=[['pressure','Sustained damage (% health / sec)',0.5,20,0.5],['buster','Starting tank-buster (% health)',0,150,5],['magic','Magic share (%)',0,100,5],['healGap','Healer top-up every (sec)',1,30,1],['deathTarget','Current gear dies in (% of fights)',5,60,5],['addDamage','Each add hits for (% of a boss swing)',0,100,5]]; export const signed=(n,digits=2)=>`${n>=0?'+':''}${n.toFixed(digits)}`; export function tankUI({updateCount}){ @@ -14,14 +14,14 @@ export function tankUI({updateCount}){ } function render(){ $('#tank-content').innerHTML=`
-
Boss details
${fields.map(([key,label,min,max,step])=>``).join('')}

The sustained damage and tank-buster sizes are measured after your mitigation. The buster is then resized until your current gear dies in the chosen share of fights, so better gear can survive more often and worse gear less often. Healers top you up to full health on a fixed rhythm; SimC does not time your defensives to the tank-busters.

+
Boss details
${fields.map(([key,label,min,max,step])=>``).join('')}

The sustained damage and tank-buster sizes are measured after your mitigation. Every target beyond the first is a mob that attacks you, with an auto attack of the share of the boss's swing set under Each add hits for; the boss and its adds all hit you at once, which is why armor, avoidance and self-healing are worth what they are in a dungeon pull. The buster is then resized until your current gear dies in the chosen share of fights, so better gear can survive more often and worse gear less often. Healers top you up to full health on a fixed rhythm; SimC does not time your defensives to the tank-busters.

Survival adds two measures, in percent: net damage taken (damage taken minus your own healing) relative to the boss's damage, and the change in how much of the fight you survive. Score = weight × survival + (1 − weight) × DPS change.

`; fill(); $('#tank-preset').onchange=()=>{fill();updateCount();}; $('#tank-weight').oninput=label; label(); } - function fill(){const p=presets[$('#tank-preset').value];for(const [key] of fields)$(`[data-tank="${key}"]`).value=p[key];} + function fill(){const p=presets[$('#tank-preset').value];for(const [key] of fields)$(`[data-tank="${key}"]`).value=p[key]??(key==='addDamage'?40:'');} function label(){const w=Number($('#tank-weight').value);$('#tank-weight-label').textContent=`${w}% survival · ${100-w}% DPS`;} return { init(options){presets=options.tankPresets;render();}, @@ -29,14 +29,14 @@ export function tankUI({updateCount}){ forWeapons(on){weapons=!!on;scope();}, settings(){ if(!(active||weapons)||!presets)return undefined; - const result={preset:$('#tank-preset').value,weight:Number($('#tank-weight').value)}; + const result={preset:$('#tank-preset').value,weight:Number($('#tank-weight').value),adds:active&&!weapons}; for(const [key] of fields){const value=$(`[data-tank="${key}"]`).value;if(value!=='')result[key]=Number(value);} return result; }, summary(job){ const t=job.settings?.tank;if(!t)return ''; const m=t.boss?.measured; - return `
Tank boss · ${esc(presets?.[t.preset]?.name||t.preset)} · ${t.weight}% survival weight

${m?`Calibrated to ${Math.round(t.boss.health).toLocaleString('en-US')} health: ${m.sustained.toFixed(1)}% health per second sustained, tank-busters of ${m.buster.toFixed(0)}% every 30 seconds, healer top-up every ${t.boss.healGap} seconds. Current gear died in ${m.deaths.toFixed(0)}% of calibration fights (target ${m.deathTarget}%).${m.reached?'':' The target was not reached, so survival differences may be muted.'}`:'The boss is calibrated before the first simulation.'}

`; + return `
Tank boss · ${esc(presets?.[t.preset]?.name||t.preset)} · ${t.weight}% survival weight

${m?`Calibrated to ${Math.round(t.boss.health).toLocaleString('en-US')} health: ${m.sustained.toFixed(1)}% health per second sustained, tank-busters of ${m.buster.toFixed(0)}% every 30 seconds, healer top-up every ${t.boss.healGap} seconds.${t.adds?` Every target beyond the first is a mob that hits you for ${t.addDamage}% of a boss swing.`:''} Current gear died in ${m.deaths.toFixed(0)}% of calibration fights (target ${m.deathTarget}%).${m.reached?'':' The target was not reached, so survival differences may be muted.'}`:'The boss is calibrated before the first simulation.'}

`; } }; } diff --git a/tests/tank.test.mjs b/tests/tank.test.mjs index 7fe76cb..c954c42 100644 --- a/tests/tank.test.mjs +++ b/tests/tank.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {isTank,normalizeTank,bossLines,survivalLines,calibrate,survivalGain,tankComparison,profilesetTank,actorTank} from '../lib/tank.mjs'; import {selectFinalists} from '../lib/upgrades.mjs'; -import {inputFor} from '../lib/engine.mjs'; +import {inputFor,playerActorIndex} from '../lib/engine.mjs'; test('tank specs are detected and settings are validated',()=>{ assert.ok(isTank({class:'warrior',spec:'protection'}));assert.equal(isTank({class:'warrior',spec:'fury'}),false); @@ -82,3 +82,26 @@ test('tank finalists are chosen by score, not DPS',()=>{ assert.deepEqual(selectFinalists(candidates,screen,24,{boss:{}}).map(c=>c.key),['b','c']); assert.deepEqual(selectFinalists(candidates,screen,24).map(c=>c.key),['a']); }); + +test('with adds on, every target beyond the first is an enemy that hits the tank, and the player moves down the actor list',()=>{ + const info={class:'deathknight',spec:'blood'}; + assert.equal(normalizeTank({preset:'mythic'},info).adds,false,'off unless asked for, so the labs are unchanged'); + const tank=normalizeTank({preset:'mythic',adds:true,addDamage:25},info); + assert.equal(tank.adds,true);assert.equal(tank.addDamage,25); + assert.throws(()=>normalizeTank({adds:true,addDamage:150},info),/addDamage/); + const boss={auto:1000,dot:0,buster:0,healGap:5,immortal:false,health:1e6,pressure:5}; + const lines=bossLines(boss,3,25); + assert.equal(lines.filter(l=>l.startsWith('enemy=')).length,4,'the boss and three adds'); + assert.ok(lines.includes('enemy=Tank_Add_3')); + assert.match(lines.join('\n'),/enemy=Tank_Add_1\nactions=\/auto_attack,damage=250,range=25,attack_speed=2,aoe_tanks=1/); + const settings={iterations:1,targetError:0,duration:60,threads:1,environment:null,tank:{...tank,boss}}; + const text=inputFor({text:'deathknight="Tank"\nspec=blood'},settings,{style:'Patchwerk',targets:4},{json:'a',html:'b'}); + assert.ok(text.indexOf('enemy=Tank_Add_3')