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)=>`
{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}}:{})}))};} +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 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`; @@ -81,6 +81,7 @@ function syncFightStyle(){ $('#fight-style-hint').hidden=!note;$('#fight-style-hint').textContent=note||''; } // Choosing the dummies starts from nothing; what the user turns on afterwards is kept. +$('#bleed-tail-on').addEventListener('change',()=>{$('#bleed-tail-hint').hidden=!$('#bleed-tail-on').checked;}); $('#fight-style').addEventListener('change',()=>{syncFightStyle();if(dummyStyles[$('#fight-style').value])environment.bare();});syncFightStyle(); $$('.settings input,.settings select,input[name=combine]').forEach(el=>el.addEventListener('change',updateCount)); async function runJob(){ @@ -102,7 +103,7 @@ 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.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).

`:''}`; + 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()){ diff --git a/public/index.html b/public/index.html index 355ec66..6f559e5 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/logs.js b/public/logs.js index 23aae57..0ef6068 100644 --- a/public/logs.js +++ b/public/logs.js @@ -11,17 +11,36 @@ const lanes=['Recklessness','Avatar','Bladestorm',"Odyn's Fury",'Thunderous Roar // Log Analysis: pick a combat log, a fight and a player, and see what the rotation did and what to change, beside a // finished simulation of the same character when one is chosen. export function logsUI({api,notice}){ - let files=[],scan=null,polling=null; + let files=[],scan=null,polling=null,characters=[]; + const store={get:k=>{try{return localStorage.getItem(k);}catch{return null;}},set:(k,v)=>{try{localStorage.setItem(k,v);}catch{}}}; $('#history').insertAdjacentHTML('afterend',``); + // The analysis is about one character: yours, as the SimCLab addon saved it (Name and realm), or a name typed in. + async function loadCharacters(){ + try{characters=((await api('/api/wow/captures')).characters||[]).map(c=>({key:c.key,name:c.name,realm:c.realm})).filter(c=>c.name);}catch{characters=[];} + const saved=store.get('simc-lab-log-character'); + $('#log-character').innerHTML=characters.map(c=>``).join('')+''; + if(saved&&[...$('#log-character').options].some(o=>o.value===saved))$('#log-character').value=saved; + $('#log-other-label').hidden=$('#log-character').value!=='other'; + } + const flat=s=>String(s||'').toLowerCase().replace(/[\s'’-]/g,''); + // A log name is Name-Realm-Region; the addon saves the name and the realm. + function mine(logName){ + const key=$('#log-character').value; + if(key==='other'){const typed=flat($('#log-other').value);return !!typed&&flat(logName).startsWith(typed);} + const c=characters.find(x=>x.key===key);if(!c)return false; + const [name,realm]=String(logName).split('-');return flat(name)===flat(c.name)&&(!c.realm||flat(realm)===flat(c.realm)); + } async function refresh(){ + await loadCharacters(); try{ const r=await api('/api/logs');files=r.files;$('#logs-specs').textContent=`Analysed: ${r.specs.join(', ')}`; $('#log-file').innerHTML=files.length?files.map(f=>``).join(''):''; @@ -37,19 +56,17 @@ export function logsUI({api,notice}){ scan={file,...state.scan};$('#log-scan-state').textContent=`${scan.fights.length} fights in ${file}.`;renderFights(); }catch(e){$('#log-scan-state').textContent=e.message;} } - // A raid log has a stretch of activity for every player and pull, so the list is filtered and capped. + // Only the chosen character's fights: their own activity, and the boss encounters they took part in. function renderFights(){ $('#log-filters').hidden=false; - const q=$('#log-player').value.trim().toLowerCase(),bosses=$('#log-bosses').checked; - const all=scan.fights.slice().reverse().filter(f=>(!bosses||f.kind==='encounter')&&(!q||(f.kind==='encounter'?f.players.some(p=>p.name.toLowerCase().includes(q)):String(f.name).toLowerCase().includes(q)))); + const bosses=$('#log-bosses').checked; + const all=scan.fights.slice().reverse().map(f=>({f,me:f.kind==='encounter'?f.players.find(p=>mine(p.name)):mine(f.name)?{guid:f.player,name:f.name,damage:f.damage}:null})).filter(x=>x.me&&(!bosses||x.f.kind==='encounter')); const rows=all.slice(0,60); - $('#log-fights').innerHTML=rows.length?`
VariantDPS / 95 %vs baselineSurvivalScoreReport
${rows.map(f=>{ - const players=f.kind==='encounter'?(q?[...f.players].sort((a,b)=>Number(b.name.toLowerCase().includes(q))-Number(a.name.toLowerCase().includes(q))):f.players):[{guid:f.player,name:f.name,damage:f.damage}]; - return ``;}).join('')}
FightLengthPlayerDamage
${f.kind==='encounter'?`${esc(f.name)}${f.success?'Kill':'Wipe'}`:`Activity${esc(f.targets.slice(0,3).join(', '))}${f.targets.length>3?' …':''}`}${clock(f.length)}${number(players.reduce((n,p)=>n+p.damage,0))}
${all.length>rows.length?`

Showing the newest 60 of ${all.length}. Filter by player to narrow it.

`:''}`:'

No fights found. A fight is a boss encounter, or at least 20 seconds of continuous damage by one player.

'; + $('#log-fights').innerHTML=rows.length?`${rows.map(({f,me})=>{ + return ``;}).join('')}
FightLengthDPSDamage
${f.kind==='encounter'?`${esc(f.name)}${f.success?'Kill':'Wipe'}`:`Activity${esc(f.targets.slice(0,3).join(', '))}${f.targets.length>3?' …':''}`}${clock(f.length)}${number(me.damage/Math.max(1,f.length))}${number(me.damage)}
${all.length>rows.length?`

Showing the newest 60 of ${all.length}.

`:''}`:`

No fights for this character in the log. A fight is a boss encounter they took part in, or at least 20 seconds of their continuous damage.

`; } async function analyse(id){ - const player=document.querySelector(`[data-fight-player="${id}"]`).value; - const name=short(document.querySelector(`[data-fight-player="${id}"]`).selectedOptions[0].textContent.split(' · ')[0]); + const row=document.querySelector(`[data-analyse="${id}"]`).closest('tr');const player=row.dataset.player,name=row.dataset.playerName; // Finished simulations of the same character, newest first, to set the rotation beside. let sims=[];try{sims=(await api('/api/jobs')).filter(j=>['complete','partial'].includes(j.status)&&['quick','compare','enchants','talents'].includes(j.mode)&&String(j.name).toLowerCase()===name.toLowerCase()).sort((a,b)=>String(b.created).localeCompare(String(a.created)));}catch{} $('#log-report').innerHTML=`

${sims.length?'SimC’s casts per minute from that simulation are shown beside yours.':`No finished simulation of ${esc(name)} yet. Run a Quick Sim of the same character (for dummies, the Silvermoon dummies fight style) to compare.`}

Analysing …
`; @@ -69,7 +86,8 @@ export function logsUI({api,notice}){

Rage: ${number(r.rage.gained)} gained, ${number(r.rage.wasted)} lost to the cap. Buffs you already had when the log started (flask, food, rune) do not show in a log.

`; } $('#log-refresh').onclick=refresh;$('#log-scan').onclick=startScan; - $('#log-player').oninput=()=>{if(scan)renderFights();};$('#log-bosses').onchange=()=>{if(scan)renderFights();}; + $('#log-character').onchange=()=>{store.set('simc-lab-log-character',$('#log-character').value);$('#log-other-label').hidden=$('#log-character').value!=='other';$('#log-report').innerHTML='';if(scan)renderFights();}; + $('#log-other').oninput=()=>{if(scan)renderFights();};$('#log-bosses').onchange=()=>{if(scan)renderFights();}; document.addEventListener('click',e=>{const b=e.target.closest('[data-analyse]');if(b)analyse(Number(b.dataset.analyse));}); return {show:refresh}; } diff --git a/public/style.css b/public/style.css index 08622fd..188ffe2 100644 --- a/public/style.css +++ b/public/style.css @@ -133,3 +133,4 @@ .sim-buffs{border:1px solid var(--line);border-radius:7px;padding:12px;margin:0 0 17px;display:grid;gap:6px}.sim-buffs-head{display:flex;justify-content:space-between;align-items:center;font-size:13px;color:#c5cedd;font-weight:600}.sim-buff-group{font-size:11px;letter-spacing:1px;color:var(--muted);text-transform:uppercase;margin-top:6px}.sim-buffs .check{font-size:12px;margin:0}.sim-buffs .hint{margin:4px 0 0} .logs-pick{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.logs-pick label{flex:1;min-width:240px}.logs-pick .button{width:auto}.log-fights select{min-width:180px;font-size:12px;padding:6px}.log-fights td{vertical-align:middle}.log-compare{margin-top:22px;padding-top:18px;border-top:1px solid var(--line)}.log-summary{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.log-summary>div{background:#131a24;border:1px solid #3b475b;border-radius:7px;padding:14px 18px;min-width:120px}.log-summary strong{display:block;font-size:24px;color:var(--amber);font-weight:600}.log-summary span{font-size:12px;color:var(--muted)}.log-findings{display:grid;gap:8px}.finding{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:7px;background:#141a25}.finding>span{width:22px;height:22px;border-radius:50%;display:grid;place-content:center;font-size:12px;flex-shrink:0}.finding strong{font-size:13px}.finding p{margin:4px 0 0;font-size:13px;color:var(--muted);line-height:1.6}.finding.warning{border-color:#805060}.finding.warning>span{background:#412b32;color:#f4b5be}.finding.tip>span{background:#302b23;color:var(--amber)}.finding.good>span{background:#1d3329;color:var(--green)}.cast-timeline{display:grid;gap:4px}.lane{display:grid;grid-template-columns:130px 1fr;gap:10px;align-items:center;font-size:11px;color:var(--muted)}.lane>div{position:relative;height:14px;background:#141a25;border:1px solid var(--line);border-radius:3px}.lane i{position:absolute;top:2px;width:2px;height:8px;background:var(--amber)}.rotation-view{margin-top:16px;padding:14px;border:1px solid var(--line);border-radius:7px;background:#141a25}.rotation-view h4{margin:0 0 10px;font-size:13px}.rotation-view h5{margin:0 0 8px;font-size:11px;letter-spacing:1px;color:var(--muted);text-transform:uppercase}.rotation-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr);gap:16px}.rotation-grid>div{max-height:420px;overflow:auto}.apl{background:#0f141d;padding:12px;max-height:360px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;color:#aabbd2}@media(max-width:720px){.rotation-grid{grid-template-columns:1fr}.lane{grid-template-columns:90px 1fr}} .rotation-grid{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.rotation-view .result-table td,.rotation-view .result-table th{text-align:left;white-space:normal}.rotation-view .result-table td:first-child{max-width:none} +.bleed-tail{display:flex;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap}.bleed-tail .inline{display:flex;align-items:center;gap:6px;font-size:12px}.bleed-tail .inline input{width:64px;padding:6px} diff --git a/tests/bleedtail.test.mjs b/tests/bleedtail.test.mjs new file mode 100644 index 0000000..af4f12b --- /dev/null +++ b/tests/bleedtail.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inputFor,bleedTailLines} from '../lib/engine.mjs'; + +const settings={iterations:1000,targetError:0.1,duration:150,threads:4,environment:{buffs:{},variation:20,bloodlust:{mode:'pull',value:0},consumables:{}},tank:null}; +const paths={json:'C:\\runs\\000.json',html:'C:\\runs\\000.html'}; + +test('stopping at the end: a players-only stun from the set duration, a longer fight, no length variation',()=>{ + assert.equal(bleedTailLines({},150),''); + const input=inputFor({text:'warrior="Me"'},settings,{style:'SilvermoonDummies',targets:5,bleedTail:20},paths); + assert.match(input,/max_time=170\n/,'the fight runs on while the bleeds tick'); + assert.match(input,/raid_events\+=\/stun,first=150,duration=50,cooldown=9999,players_only=1\n/); + // After the environment's variation, so the stop lands where it is set. + assert.ok(input.lastIndexOf('vary_combat_length=0')>input.indexOf('vary_combat_length=0.2')); + // Patchwerk would clear the raid event, so the style is left unset (the same fight). + const patchwerk=inputFor({text:'warrior="Me"'},settings,{style:'Patchwerk',targets:1,bleedTail:15},paths); + assert.doesNotMatch(patchwerk,/fight_style=/);assert.match(patchwerk,/max_time=165\n/); + assert.match(inputFor({text:'warrior="Me"'},settings,{style:'HecticAddCleave',targets:1,bleedTail:15},paths),/fight_style=HecticAddCleave\n/); +});