diff --git a/README.md b/README.md index 7d6be88..2b8aacf 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ Known limits: boss mechanics, movement, deaths and crowd control are not simulat 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. +**Tank Sim** has its own place in the menu: the imported tank against a boss and its adds, one simulation per fight you tick (the boss alone, the boss with 2 adds, the boss with 4 adds), with the numbers a tank cares about: damage taken per second and as a share of your health, healing and absorbs, how much of the fight you survive, how often you die, and your DPS. It needs a tank specialization. + SimC's built-in tank dummy uses level-70 damage values and never threatens a Midnight tank, and SimC gives players infinite health by default. SimC Lab replaces it with a calibrated boss, tuned once per job against the imported gear and then shared by every variant and scenario: - Sustained damage: melee swings plus a magic damage-over-time effect, scaled until the imported gear takes the chosen share of its maximum health per second after its own mitigation. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 42db347..2dc17bc 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.41.0", + "version": "1.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.41.0", + "version": "1.42.0", "license": "GPL-3.0-or-later", "dependencies": { "electron-updater": "^6.8.9" diff --git a/desktop/package.json b/desktop/package.json index ae1b036..5d3b116 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.41.0", + "version": "1.42.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 fcccd7a..eff7aaa 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -98,6 +98,11 @@ export async function prepare(request,catalog,talentData,season) { if(!Number.isFinite(settings.targetError)||settings.targetError<0||settings.targetError>5) throw new Error('Target error must be between 0 and 5%.'); settings.environment=normalizeEnvironment(request.environment,catalog); settings.tank=weapons?null:normalizeTank(request.tank,profile.info); + // Tank Sim runs one imported tank against a boss and adds that all hit it. + if(request.mode==='tank'){ + if(!settings.tank)throw new Error('Tank Sim needs a tank specialization: Protection Warrior or Paladin, Blood Death Knight, Guardian Druid, Brewmaster Monk or Vengeance Demon Hunter.'); + settings.tank.adds=true; + } // Trinket Lab can name its scenarios instead: raid and Mythic+ fights of their own length (see scenarioPresets). const presets=request.mode==='trinkets'&&request.trinkets?.scenarioSet?.length; // Bloodmallet parity fixes the fight and the precision to Bloodmallet's own (see parity in trinkets.mjs). diff --git a/lib/profile.mjs b/lib/profile.mjs index a2503c5..3d89d13 100644 --- a/lib/profile.mjs +++ b/lib/profile.mjs @@ -78,7 +78,7 @@ export function createVariants(profile, request, catalog) { if (!patch.text) throw new Error(`Variant «${v.name}» is empty.`); variants.push({name:v.name,text:applyOverrides(profile.text,patch.text)}); } - } else if (mode !== 'quick') throw new Error('Unknown simulation mode.'); + } else if (mode !== 'quick' && mode !== 'tank') throw new Error('Unknown simulation mode.'); if (variants.length > 129) throw new Error('Too many variants.'); return variants; } diff --git a/package-lock.json b/package-lock.json index b993ba1..131404d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.41.0", + "version": "1.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.41.0", + "version": "1.42.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index d4ff914..22bec17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.41.0", + "version": "1.42.0", "private": true, "type": "module", "scripts": { diff --git a/public/activity.js b/public/activity.js index 0ae66ee..f2cb3a8 100644 --- a/public/activity.js +++ b/public/activity.js @@ -1,7 +1,7 @@ // What the engine is doing right now: a bar in the header on every page, and the shared wording for a job's // progress. Jobs run one at a time; everything else waits in the queue. const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const names={quick:'Quick Sim',enchants:'Enchant Lab',compare:'Gear Compare',upgrades:'Upgrade Finder',weapons:'Weapon Lab',trinkets:'Trinket Lab',talents:'Talent Search'}; +const names={quick:'Quick Sim',enchants:'Enchant Lab',compare:'Gear Compare',upgrades:'Upgrade Finder',tank:'Tank Sim',weapons:'Weapon Lab',trinkets:'Trinket Lab',talents:'Talent Search'}; export function duration(seconds){ if(!Number.isFinite(seconds)||seconds<0)return ''; diff --git a/public/app.js b/public/app.js index db07fe4..f88d096 100644 --- a/public/app.js +++ b/public/app.js @@ -2,6 +2,7 @@ import {itemLink,importedCards} from '/items.js'; import {environmentUI} from '/environment.js'; import {featureUI} from '/features.js'; import {upgradeUI} from '/upgrades.js'; +import {tankSimUI} from '/tanksim.js'; import {crestUI} from '/crests.js'; import {vaultUI} from '/vault.js'; import {weaponUI} from '/weapons.js'; @@ -22,14 +23,14 @@ const $$=s=>[...document.querySelectorAll(s)]; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); const labels={head:'Head',neck:'Neck',shoulder:'Shoulders',back:'Back',chest:'Chest',wrist:'Wrists',hands:'Hands',waist:'Waist',legs:'Legs',feet:'Feet',finger1:'Ring 1',finger2:'Ring 2',main_hand:'Main hand',off_hand:'Offhand'}; -const modes={logs:['Log Analysis','Read a combat log and see what your rotation did, and what to change.'],quick:['Quick Sim','Import your character. Find out what your gear can do.'],enchants:['Enchant Lab','Test Midnight enchants, ranks and combinations on your character.'],compare:['Gear Compare','Compare current-expansion gear, gems, talents and consumables.'],upgrades:['Upgrade Finder','Search raid, Mythic+, Great Vault, delve and crafted loot for your best simulated upgrades.'],crests:['Crest Planner','Find which equipped item to upgrade first with the crests you have.'],vault:['Great Vault','Simulate every choice in your Great Vault and see which one to take.'],weapons:['Weapon Lab','Rank this season’s weapons, off-hands and shields for every specialization at one item level.'],trinkets:['Trinket Lab','Find the best two trinkets for every specialization, per raid and Mythic+ scenario.'],talents:['Talent Search','Automatically generate legal builds and find the strongest tested talents for your gear.'],history:['History','Open previous results and download complete SimC reports.'],wow:['WoW addon','Bring your sims and gear farm into the game.']}; +const modes={logs:['Log Analysis','Read a combat log and see what your rotation did, and what to change.'],quick:['Quick Sim','Import your character. Find out what your gear can do.'],enchants:['Enchant Lab','Test Midnight enchants, ranks and combinations on your character.'],compare:['Gear Compare','Compare current-expansion gear, gems, talents and consumables.'],tank:['Tank Sim','Your tank against a boss and its adds, all hitting you: damage taken, healing, deaths and how long you live.'],upgrades:['Upgrade Finder','Search raid, Mythic+, Great Vault, delve and crafted loot for your best simulated upgrades.'],crests:['Crest Planner','Find which equipped item to upgrade first with the crests you have.'],vault:['Great Vault','Simulate every choice in your Great Vault and see which one to take.'],weapons:['Weapon Lab','Rank this season’s weapons, off-hands and shields for every specialization at one item level.'],trinkets:['Trinket Lab','Find the best two trinkets for every specialization, per raid and Mythic+ scenario.'],talents:['Talent Search','Automatically generate legal builds and find the strongest tested talents for your gear.'],history:['History','Open previous results and download complete SimC reports.'],wow:['WoW addon','Bring your sims and gear farm into the game.']}; const states={queued:'Queued',running:'Running',complete:'Complete',partial:'Partially complete',failed:'Failed',cancelled:'Cancelled',interrupted:'Interrupted on restart'}; let token='',engine=null,mode='quick',profile=null,importedText='',selections={},currentJob=null,pollTimer=null,previewTimer=null; let variants=[{name:'Alternative 1',text:''}]; async function api(url,data){const res=await fetch(url,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-SimC-Token':token},body:JSON.stringify(data)});const result=await res.json();if(!res.ok)throw new Error(result.error||'The request failed.');return result;} function notice(message){$('#notice').hidden=!message;$('#notice').textContent=message||'';} function safeStore(key,value){try{localStorage.setItem(key,value);}catch{}} -async function setMode(value){mode=value;$$('.nav').forEach(b=>b.classList.toggle('active',b.dataset.mode===value));$('#page-title').innerHTML=`${esc(modes[value][0])}.`;$('#breadcrumb').textContent=modes[value][0];$('#page-description').textContent=modes[value][1];$('#workspace').hidden=['history','wow','logs'].includes(value);$('#history').hidden=value!=='history';$('#logs-panel').hidden=value!=='logs';if(value==='logs')logs.show();$('#wow-panel').hidden=value!=='wow';$('#enchant-panel').hidden=value!=='enchants';$('#compare-panel').hidden=value!=='compare';$('#quick-info').hidden=value!=='quick';$('#talent-panel').hidden=value!=='talents';$('#upgrade-panel').hidden=value!=='upgrades';$('#crest-panel').hidden=value!=='crests';$('#vault-panel').hidden=value!=='vault';$('#weapon-panel').hidden=value!=='weapons';$('#trinket-panel').hidden=value!=='trinkets'; +async function setMode(value){mode=value;$$('.nav').forEach(b=>b.classList.toggle('active',b.dataset.mode===value));$('#page-title').innerHTML=`${esc(modes[value][0])}.`;$('#breadcrumb').textContent=modes[value][0];$('#page-description').textContent=modes[value][1];$('#workspace').hidden=['history','wow','logs'].includes(value);$('#history').hidden=value!=='history';$('#logs-panel').hidden=value!=='logs';if(value==='logs')logs.show();$('#wow-panel').hidden=value!=='wow';$('#enchant-panel').hidden=value!=='enchants';$('#compare-panel').hidden=value!=='compare';$('#quick-info').hidden=value!=='quick';$('#talent-panel').hidden=value!=='talents';$('#upgrade-panel').hidden=value!=='upgrades';$('#tanksim-panel').hidden=value!=='tank';$('#crest-panel').hidden=value!=='crests';$('#vault-panel').hidden=value!=='vault';$('#weapon-panel').hidden=value!=='weapons';$('#trinket-panel').hidden=value!=='trinkets'; // Weapon Lab and Trinket Lab run on SimC's reference profiles, so they hide the character panel and always offer tank settings. const lab=['weapons','trinkets'].includes(value); // Trinket Lab carries every setting it uses in its own panel, so the rest of the app's settings are out of sight. @@ -60,7 +61,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];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 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:mode==='tank'?tankSim.scenarios($('#fight-style').value):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`; @@ -70,6 +71,7 @@ function updateCount(){ clearTimeout(previewTimer); if(mode==='crests'){$('#run-summary').textContent=profile?'Every upgrade level in one run per scenario':'Waiting for character import';crests.count(request,!!profile);} if(mode==='vault'){$('#run-summary').textContent=profile?'Every vault choice in one run per scenario':'Waiting for character import';vault.count(request,!!profile);} + if(mode==='tank'){const n=tankSim.chosen().length;$('#run-summary').textContent=!profile?'Waiting for character import':!profile.isTank?'Import a tank specialization':n?'One simulation per fight':'Choose at least one fight';$('#run-count').textContent=profile?`${n} run${n===1?'':'s'}`:'';} if(mode==='upgrades'){$('#run-summary').textContent=profile?'Screening + final round per scenario':'Waiting for character import';upgrades.count(request,!!profile);} // Weapon Lab needs no import: the reference profiles are always there, so the run summary never waits for one. if(mode==='weapons'){$('#run-summary').textContent='One tier list per specialization';$('#run-count').textContent='';weapons.count(request);} @@ -119,9 +121,9 @@ function renderJob(job){ 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); + let html=wow.sendButton(job)+tank.summary(job)+features.results(job)+upgrades.results(job)+tankSim.results(job)+crests.results(job)+vault.results(job)+weapons.results(job)+trinkets.results(job); if(job.settings.environment){const env=job.settings.environment;html+=`
Environment used for this job

External buffs: ${esc(Object.entries(env.buffs).filter(([,on])=>on).map(([key])=>key.replaceAll('_',' ')).join(', ')||'None')}
Bloodlust: ${env.buffs.bloodlust?esc(env.bloodlust.mode+' '+env.bloodlust.value):'No external override'} · Duration variation: ±${env.variation}%
Consumables: ${esc(Object.entries(env.consumables).map(([key,value])=>key.replaceAll('_',' ')+': '+value).join('; ')||'From profile / SimC')}

`;} - 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]; if(scenario.style===routeStyle)html+=`

${mplus.title(scenario)}

${mplus.details(scenario,baseline)}`; @@ -136,7 +138,7 @@ function renderJob(job){ } html+=`
`; } - if(job.results.length&&!['upgrades','crests','vault','weapons','trinkets'].includes(job.mode))html+='

± is an approximate 95% confidence interval for mean DPS. “Uncertain” means the difference is within combined statistical uncertainty, not that the builds are equal. Increase precision for small differences.

'; + if(job.results.length&&!['upgrades','tank','crests','vault','weapons','trinkets'].includes(job.mode))html+='

± is an approximate 95% confidence interval for mean DPS. “Uncertain” means the difference is within combined statistical uncertainty, not that the builds are equal. Increase precision for small differences.

'; html+=`

SimC ${esc(job.engine.version)} · WoW ${esc(job.engine.wowVersion)} · commit ${esc(job.engine.commit?.slice(0,12))}
${number(job.settings.iterations)} max iterations · target error ${job.settings.targetError} % · ${job.settings.threads} CPU threads · Download job settings

`; $('#result-content').innerHTML=html; } @@ -144,7 +146,7 @@ async function loadHistory(){const jobs=await api('/api/jobs');$('#history-list' // SimC's rotation for a finished run, shown under its scenario. document.addEventListener('click',async e=>{const link=e.target.closest('[data-rotation]');if(!link)return;e.preventDefault();const [job,stem]=link.dataset.rotation.split('/');const slot=link.closest('.result-scenario').querySelector('.rotation-slot');slot.innerHTML='

Loading SimC’s rotation …

';try{slot.innerHTML=await rotationView(api,job,stem);}catch(err){slot.innerHTML=`

${esc(err.message)}

`;}}); $('#refresh-history').addEventListener('click',()=>loadHistory().catch(e=>notice(e.message))); -$('#history-list').addEventListener('click',async event=>{const row=event.target.closest('[data-job]');if(row){await setMode(['weapons','trinkets','crests','vault'].includes(row.dataset.jobMode)?row.dataset.jobMode:'quick');await watchJob(row.dataset.job);$('#results').scrollIntoView({behavior:'smooth'});}}); +$('#history-list').addEventListener('click',async event=>{const row=event.target.closest('[data-job]');if(row){await setMode(['weapons','trinkets','crests','vault','tank'].includes(row.dataset.jobMode)?row.dataset.jobMode:'quick');await watchJob(row.dataset.job);$('#results').scrollIntoView({behavior:'smooth'});}}); async function init(){ try{const status=await api('/api/status');token=status.token;engine=status.engine;engineView.render(status); $('#wow-version').textContent=engine.installed||'Not found';$('#side-version').textContent=engine.version?`SimulationCraft ${engine.version}`:'Not installed';$('#engine-badge').textContent=engine.compatible&&engine.ready?'Live build verified':engine.ready?'Update SimC for your WoW version':'SimC not installed';$('#engine-badge').classList.toggle('warning',!engine.compatible||!engine.ready); @@ -166,6 +168,7 @@ $('#imported-choices').addEventListener('keydown',event=>{if(event.target.matche const features=featureUI({api,getProfile:()=>profile,notice,updateCount,addVariant:v=>{if(variants.length===1&&!variants[0].text)variants=[];variants.push(v);renderVariants();updateCount();}}); const upgrades=upgradeUI({api,notice,updateCount}); +const tankSim=tankSimUI({updateCount}); const crests=crestUI({api,notice,updateCount}); const vault=vaultUI({api,updateCount}); const weapons=weaponUI({api,notice,updateCount}); diff --git a/public/index.html b/public/index.html index 6710fff..d86f474 100644 --- a/public/index.html +++ b/public/index.html @@ -1,7 +1,7 @@ SimC Lab · mpcombat - +
Workspace/Quick Sim
Checking engine
SIMULATIONCRAFT WORKBENCH

Quick Sim.

Import your character. Find out what your gear can do.

WOW LIVE—
diff --git a/public/tanksim.js b/public/tanksim.js new file mode 100644 index 0000000..a7d3848 --- /dev/null +++ b/public/tanksim.js @@ -0,0 +1,36 @@ +import {signed} from '/tank.js'; +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); + +// The fights a tank can be run through: the boss alone, and the boss with mobs that all hit the tank. +export const fights=[ + {id:'boss',label:'Boss alone',targets:1,note:'One enemy: the boss swings, busts you every 30 seconds and puts a damage-over-time on you.'}, + {id:'pack',label:'Boss + 2 adds',targets:3,note:'A small pack: two mobs swing at you for the whole fight on top of the boss.'}, + {id:'pull',label:'Boss + 4 adds',targets:5,note:'A big pull: four mobs swing at you for the whole fight on top of the boss.'} +]; + +// Tank Sim: one imported tank, run against a boss and its adds, with the numbers a tank cares about. +export function tankSimUI({updateCount}){ + $('#quick-info').insertAdjacentHTML('beforebegin',``); + $('#tanksim-panel').addEventListener('change',()=>updateCount()); + const chosen=()=>fights.filter(f=>$(`[data-tanksim="${f.id}"]`)?.checked); + const row=(job,s)=>job.results.find(r=>r.scenario===s&&r.baseline&&r.status==='complete'); + return { + chosen, + // One scenario per fight, in the fight style chosen in the settings. + scenarios(style,extra={}){return chosen().map(f=>({style,targets:f.targets,...extra}));}, + results(job){ + if(job.mode!=='tank')return ''; + const t=job.settings?.tank; + let html=`
Tank Sim · ${esc(job.name)}

${chosen().length||job.scenarios.length} fight${job.scenarios.length===1?'':'s'}, each one simulation of ${number(job.settings.iterations)} iterations at most, ${job.settings.duration} sec. The adds hit for ${t?.addDamage??40}% of a boss swing each, for the whole fight.

`; + const rows=job.scenarios.map((sc,s)=>({sc,r:row(job,s)})).filter(x=>x.r?.tank); + if(!rows.length)return html+'

The fights show here as they finish.

'; + html+=`${rows.map(({sc,r})=>{ + const x=r.tank,health=x.health||t?.boss?.health,fight=fights.find(f=>f.targets===sc.targets); + const pct=n=>health?`${(100*n/health).toFixed(1)}% of your health`:''; + return ``;}).join('')}
FightDamage takenHealing and absorbsSurvivedDeathsYour DPS
${esc(fight?.label||`${sc.targets} targets`)}${esc(sc.style)} · ${sc.targets-1} add${sc.targets===2?'':'s'}${number(x.dtps)} / sec${pct(x.dtps)} per second${number(x.hps)} healed + ${number(x.aps)} absorbed / secnet after healing ${number(x.dtps-x.hps)} / sec${(100*x.alive).toFixed(0)}%of the fight, on average${x.deaths===undefined?'—':`${(100*x.deaths).toFixed(0)}%`}of ${number(r.iterations||job.settings.iterations)} fights${number(r.dps)}±${number(r.error95||0)}

Damage taken is after your armor, avoidance and absorbs, as SimC counts it. More adds means more damage and more deaths, and that is the point: the same gear holds up differently against a pack than against a single hit. Deaths are against a boss whose tank-busters were sized so your current gear dies in about ${t?.deathTarget??25}% of boss-alone fights.

`; + return html; + } + }; +} diff --git a/server.mjs b/server.mjs index 45c4140..757fefb 100644 --- a/server.mjs +++ b/server.mjs @@ -246,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','/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'}; + 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','/tanksim.js':'tanksim.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});}