diff --git a/desktop/package.json b/desktop/package.json index 4ec7488..3dc8f70 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.32.0", + "version": "1.33.0", "description": "Local SimulationCraft workbench with upgrade finder and tank simulation.", "author": "Roburmaster", "private": true, diff --git a/lib/analysis/fury.mjs b/lib/analysis/fury.mjs index 6f1f937..3286fcd 100644 --- a/lib/analysis/fury.mjs +++ b/lib/analysis/fury.mjs @@ -128,11 +128,17 @@ export function analyse(data,{sim=null,potions=[]}={}){ } for(const [key,cpm] of Object.entries(sim))if(cpm>=1&&!abilities.some(a=>simKey(a.name)===key))add('tip',`${title(key)} not used`,`SimC casts ${title(key)} ${round(cpm,2)} times a minute; you did not cast it.`,null); } + // The end on a dummy: after the last cast, bleeds and other damage over time tick on. A meter counts that time + // too, so its DPS is lower than while attacking; the sim's "Stop at the end, let bleeds run out" matches it. + const lastCast=casts.length?casts.at(-1).time:end,tailSeconds=Math.max(0,end-lastCast); + const attacking=Math.max(1,lastCast-start+1.5),attackingDamage=data.hits.filter(([t])=>t<=lastCast+1.5).reduce((n,[,a])=>n+a,0); + const attackingDps=Math.round(attackingDamage/attacking); + if(tailSeconds>=5)add('tip','Bleeds after you stopped',`Your last cast was ${round(tailSeconds)} s before the last tick. That time counts in the ${Math.round(damage/length).toLocaleString('en-US')} DPS; while attacking it was ${attackingDps.toLocaleString('en-US')}. To compare with a sim, turn on “Stop at the end, let bleeds run out” for ${Math.round(tailSeconds)} s.`,round(tailSeconds)); const order={warning:0,tip:1,good:2};findings.sort((a,b)=>order[a.severity]-order[b.severity]); const spells=[...data.damage.values()].sort((a,b)=>b.amount-a.amount).map(d=>({name:d.name,id:d.id,amount:d.amount,share:pct(d.amount,damage),hits:d.hits,crit:pct(d.crits,d.hits)})); return {spec:spec.label,name:data.name,length:round(length),dps:Math.round(damage/length),damage,targets:data.targets.size, uptime:{enrage:enrageUp,recklessness:pct(total(reckUp),length),whirlwind:pct(total(ww),length)},rage:{gained:Math.round(gained),wasted:Math.round(wasted)}, - findings,abilities,spells,cooldowns,potions:potionUses,casts:casts.map(c=>({t:round(c.time-start,2),name:c.name,id:c.id}))}; + tail:{seconds:round(tailSeconds),attackingDps},findings,abilities,spells,cooldowns,potions:potionUses,casts:casts.map(c=>({t:round(c.time-start,2),name:c.name,id:c.id}))}; } // SimC names actions after the spell: "Odyn's Fury" is odyns_fury. export const simKey=name=>String(name).toLowerCase().replace(/['’]/g,'').replace(/[^a-z0-9]+/g,'_').replace(/^_|_$/g,''); diff --git a/lib/combatlog.mjs b/lib/combatlog.mjs index 6913b67..62a9400 100644 --- a/lib/combatlog.mjs +++ b/lib/combatlog.mjs @@ -100,7 +100,7 @@ export async function scanLog(file,progress=()=>{}){ // One player's part of one fight: casts, damage per spell, buffs on them, resource gains, and what they hit. export async function readPlayer(file,fight,guid){ - const out={guid,name:null,start:null,end:null,casts:[],damage:new Map(),auras:[],energize:[],targets:new Map(),spec:null}; + const out={guid,name:null,start:null,end:null,casts:[],damage:new Map(),auras:[],energize:[],targets:new Map(),spec:null,hits:[]}; for await(const {line} of lines(file,fight.startOffset,fight.endOffset)){ const p=parseLine(line);if(!p)continue;const f=p.fields; if(p.event==='COMBATANT_INFO'&&f[1]===guid){const bracket=f.findIndex(x=>x.startsWith('['));if(bracket>1)out.spec=Number(f[bracket-1]);continue;} @@ -117,7 +117,7 @@ export async function readPlayer(file,fight,guid){ const swing=p.event==='SWING_DAMAGE';const id=swing?6603:Number(f[9]);const name=swing?'Melee':f[10]; const amount=damageOf(p);const at=advancedStart(p.event); const d=out.damage.get(id)||{id,name,amount:0,hits:0,crits:0};d.amount+=amount;d.hits++;if(f[at+26]==='1')d.crits++;out.damage.set(id,d); - out.start??=p.time;out.end=p.time; + out.start??=p.time;out.end=p.time;out.hits.push([p.time,amount]); const t=out.targets.get(f[5])||{guid:f[5],name:f[6],hits:0,times:[]};t.hits++;if(t.times.length<4000)t.times.push(p.time);out.targets.set(f[5],t); } } diff --git a/lib/engine.mjs b/lib/engine.mjs index b6f5c76..45f0798 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -105,6 +105,8 @@ export async function prepare(request,catalog,talentData,season) { if(s.dummyHealth.mode==='falling'&&weapons)throw new Error('Falling dummy health is measured on your own character. Use held health in Weapon Lab and Trinket Lab.'); } 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'); 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); } @@ -160,14 +162,20 @@ export function reproduction(plan){ } // What a scenario turns off after the environment: a Mythic+ pull that gets no Bloodlust or no potion. export const scenarioLines=scenario=>[...(scenario.bloodlust===false?['override.bloodlust=0']:[]),...(scenario.potion===false?['potion=disabled','override.allow_potions=0']:[])].map(l=>l+'\n').join(''); +// The end of a fight where the player stops and their damage over time keeps ticking: a stun from the set duration on +// (players only, so nothing is cast and no auto attack swings), the fight longer by the tail, and no length variation +// 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); export function inputFor(variant,settings,scenario,paths) { const boss=settings.tank?.boss,dummies=isDummyStyle(scenario.style); // fight_style=Patchwerk clears every raid event, including the tank healer; the unset style is the same fight. // The dummies are their own enemies in that unset (Patchwerk) fight. - const style=dummies||boss&&scenario.style==='Patchwerk'?'':`fight_style=${scenario.style}\n`; - return `${boss?bossLines(boss).join('\n')+'\n':''}${dummies?dummyLines(scenario.style,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=${scenario.duration||settings.duration}\nvary_combat_length=0.2\nthreads=${settings.threads}\n${style}desired_targets=${scenario.targets}\ncalculate_scale_factors=0\n${environmentLines(settings.environment,variant.text).join('\n')}\n${scenarioLines(scenario)}${boss?survivalLines(boss).join('\n')+'\n':''}${paths.apl?`save_actions="${paths.apl.replaceAll('\\','/')}"\n`:''}json2="${paths.json.replaceAll('\\','/')}"\nhtml="${paths.html.replaceAll('\\','/')}"\n`; + // 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`; + 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`; } export function resultFrom(report) { const p=report.sim?.players?.[0]; const dps=p?.collected_data?.dps; @@ -586,7 +594,7 @@ export class Jobs { // 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){ - const dir=path.join(runsDir,job.id),scenario=plan.scenarios[s],duration=scenario.duration||plan.settings.duration; + const dir=path.join(runsDir,job.id),{bleedTail,...scenario}=plan.scenarios[s],duration=(scenario.duration||plan.settings.duration)+(bleedTail||0); const settings={...plan.settings,iterations:Math.min(plan.settings.iterations,1000),targetError:Math.max(plan.settings.targetError,0.5)}; const reports=[]; for(const band of dummyBands){ diff --git a/package-lock.json b/package-lock.json index 9e15d76..071f21e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.32.0", + "version": "1.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.32.0", + "version": "1.33.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 6c63ebd..e608a55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.32.0", + "version": "1.33.0", "private": true, "type": "module", "scripts": { diff --git a/public/app.js b/public/app.js index 820fbb7..a57ae38 100644 --- a/public/app.js +++ b/public/app.js @@ -59,7 +59,7 @@ function renderVariants(){$('#variants').innerHTML=variants.map((v,i)=>`
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).
`:''}`; + html+=`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+=`| Variant | DPS / 95 % | vs baseline | ${tanky?'Survival | Score | ':''}Report |
|---|