diff --git a/desktop/package.json b/desktop/package.json index 7e30c73..4ec7488 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.31.0", + "version": "1.32.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 new file mode 100644 index 0000000..6f1f937 --- /dev/null +++ b/lib/analysis/fury.mjs @@ -0,0 +1,139 @@ +// Fury Warrior: what a combat log says about the rotation, and what to change. Spell IDs, not names, so any client +// language works. Every finding names the number it is based on, so the advice can be checked against the log. +export const spec={id:72,key:'fury',label:'Fury Warrior'}; +const ids={ + rampage:[184367],bloodthirst:[23881],bloodbath:[335096],ragingBlow:[85288],crushingBlow:[335097],execute:[5308,280735], + whirlwind:[190411],recklessness:[1719],odynsFury:[385059],bladestorm:[446035,227847],avatar:[107574],thunderousRoar:[384318], + ravager:[228920],championsSpear:[376079] +}; +const buffs={enrage:[184362],recklessness:[1719],suddenDeath:[52437,280776],whirlwind:[85739],bladestorm:[446035,227847]}; +const has=(list,id)=>list.includes(id); +const followUps=[385060,385061,385062]; +// Casts that hit one target only; with the Whirlwind buff they cleave, so on several targets they want it up. +const singleTarget=[...ids.rampage,...ids.bloodthirst,...ids.bloodbath,...ids.ragingBlow,...ids.crushingBlow,...ids.execute]; +const majorCooldowns=[...ids.bladestorm,...ids.odynsFury,...ids.avatar,...ids.thunderousRoar,...ids.ravager,...ids.championsSpear]; + +export function isFury(data){ + if(data.spec===spec.id)return true; + const cast=new Set(data.casts.map(c=>c.id)); + return [...ids.rampage,...ids.bloodthirst,...ids.ragingBlow,...ids.bloodbath].filter(id=>cast.has(id)).length>=2; +} + +// When a buff was up, from its applied/removed events, clipped to the fight. +export function intervals(auras,list,start,end){ + const out=[];let since=null; + for(const a of auras){ + if(!has(list,a.id))continue; + if((a.event==='APPLIED'||a.event==='REFRESH'||a.event==='APPLIED_DOSE')&&since===null)since=Math.max(a.time,start); + if(a.event==='REMOVED'){out.push([since??start,Math.min(a.time,end)]);since=null;} + } + if(since!==null)out.push([since,end]); + return out.filter(([a,b])=>b>a); +} +const total=list=>list.reduce((n,[a,b])=>n+b-a,0); +const upAt=(list,time)=>list.some(([a,b])=>time>=a-0.05&&time<=b+0.05); +const pct=(part,whole)=>whole>0?Math.round(1000*part/whole)/10:0; +const round=(n,d=1)=>Math.round(n*10**d)/10**d; + +// `sim`: casts per minute by SimC action name from a simulation of the same character, when one is chosen. +// `potions`: the current expansion's potion names, to recognise one in the log. +export function analyse(data,{sim=null,potions=[]}={}){ + const start=data.start,end=data.end,length=Math.max(1,end-start),minutes=length/60; + const findings=[];const add=(severity,title,detail,value=null)=>findings.push({severity,title,detail,value}); + const damage=[...data.damage.values()].reduce((n,d)=>n+d.amount,0); + // A few seconds before the first hit: the opener (Recklessness, Charge) belongs to the fight. + // One press of Odyn's Fury also logs its follow-up hits as casts (385060-385062); only the press counts. + const casts=data.casts.filter(c=>c.time>=start-5&&c.time<=end+1&&!followUps.includes(c.id)).sort((a,b)=>a.time-b.time); + const castsOf=list=>casts.filter(c=>has(list,c.id)); + + // Enrage drives Fury's mastery and haste; nearly all of a fight should be spent enraged. + const enrage=intervals(data.auras,buffs.enrage,start,end),enrageUp=pct(total(enrage),length); + if(enrageUp>=90)add('good','Enrage uptime',`Enraged ${enrageUp}% of the fight.`,enrageUp); + else add(enrageUp>=80?'tip':'warning','Enrage uptime',`Enraged ${enrageUp}% of the fight; aim for 90% or more. Rampage enrages you, so use it before Enrage runs out, and Bloodthirst or Bloodbath can refresh it too.`,enrageUp); + + // Rage spent is damage; rage over the cap is lost. + const rage=data.energize.filter(e=>e.type===1);const gained=rage.reduce((n,e)=>n+e.amount,0),wasted=rage.reduce((n,e)=>n+e.over,0); + const wastedPct=pct(wasted,gained+wasted); + if(gained>0){ + if(wastedPct<=3)add('good','Rage over the cap',`${Math.round(wasted)} rage (${wastedPct}%) lost to the cap.`,wastedPct); + else add(wastedPct<=8?'tip':'warning','Rage over the cap',`${Math.round(wasted)} rage (${wastedPct}%) was lost to the cap. Spend on Rampage before you reach it, especially before Bloodthirst, Raging Blow or Recklessness add more.`,wastedPct); + } + + // Sudden Death: a free Execute; a proc that expires unused is lost damage. + const sd=data.auras.filter(a=>has(buffs.suddenDeath,a.id)); + const executes=castsOf(ids.execute).map(c=>c.time); + const procs=sd.filter(a=>a.event==='APPLIED'||a.event==='APPLIED_DOSE').length; + const expired=sd.filter(a=>(a.event==='REMOVED'||a.event==='REMOVED_DOSE')&&!executes.some(t=>Math.abs(t-a.time)<=0.4)).length; + if(procs){ + if(expired===0)add('good','Sudden Death',`All ${procs} Sudden Death procs were used.`,0); + else add(expired/procs>0.15?'warning':'tip','Sudden Death',`${expired} of ${procs} Sudden Death procs ran out unused. Execute is free with the proc; use it before it expires.`,expired); + } + + // On several targets, single-target attacks without the Whirlwind buff hit only one of them. + const ww=intervals(data.auras,buffs.whirlwind,start,end); + const targetsAt=time=>[...data.targets.values()].filter(t=>t.times.some(x=>Math.abs(x-time)<=3)).length; + const spenders=casts.filter(c=>has(singleTarget,c.id)); + const multi=spenders.filter(c=>targetsAt(c.time)>=2); + if(multi.length>=10){ + const without=multi.filter(c=>!upAt(ww,c.time)).length,share=pct(without,multi.length); + if(share<=5)add('good','Whirlwind on several targets',`${100-share}% of your single-target attacks on several targets had the Whirlwind buff.`,share); + else add(share<=15?'tip':'warning','Whirlwind on several targets',`${without} of ${multi.length} single-target attacks (${share}%) were used on several targets without the Whirlwind buff, so they hit only one. Cast Whirlwind when the buff is down and there is more than one target.`,share); + } + + // Time without a cast, outside Bladestorm's channel. + const storm=intervals(data.auras,buffs.bladestorm,start,end);let idle=0;const gaps=[]; + for(let i=1;i2&&!storm.some(([s,e])=>sa)){idle+=gap-1.5;if(gaps.length<5)gaps.push({at:round(a-start),seconds:round(gap)});} + } + const idlePct=pct(idle,length); + if(idlePct<=3)add('good','Always casting',`Only ${round(idle)} s without a cast.`,idlePct); + else add(idlePct<=8?'tip':'warning','Time without a cast',`${round(idle)} s (${idlePct}%) passed without a cast, longest at ${gaps.map(g=>`${g.at} s (${g.seconds} s)`).join(', ')}. Keep the global cooldown rolling; when nothing else is ready, Whirlwind or Raging Blow.`,idlePct); + + // Recklessness and what went with it. + const reck=castsOf(ids.recklessness);const reckUp=intervals(data.auras,buffs.recklessness,start,end); + const cooldowns=reck.map(r=>({time:round(r.time-start),with:casts.filter(c=>has(majorCooldowns,c.id)&&c.time>=r.time-3&&c.time<=r.time+10).map(c=>c.name)})); + if(reck.length){ + const first=reck[0].time-start; + if(first>6)add('tip','First Recklessness',`Recklessness was first used ${round(first)} s in. Opening with it, after a Rampage or Bloodthirst to enrage, gives its full value while everything else is ready too.`,round(first)); + const alone=cooldowns.filter(c=>!c.with.length).length; + if(alone)add('tip','Cooldowns together',`${alone} of ${reck.length} Recklessness casts had no Bladestorm, Odyn's Fury or other major cooldown within 10 s. They are stronger together.`,alone); + else add('good','Cooldowns together','Every Recklessness went with a major cooldown.',0); + }else if(length>60)add('warning','Recklessness',`Recklessness was not used in ${Math.round(length)} s.`,0); + + // Potions: recognised by name, from the auras on the player or the casts. + const potionName=name=>/potion/i.test(name)||potions.some(p=>name.toLowerCase().startsWith(p.toLowerCase())); + const potionUses=[...data.auras.filter(a=>a.event==='APPLIED'&&potionName(a.name)),...casts.filter(c=>potionName(c.name))] + .sort((a,b)=>a.time-b.time).filter((x,i,list)=>!i||x.time-list[i-1].time>5) + .map(x=>({time:round(x.time-start),name:x.name,withRecklessness:upAt(reckUp,x.time)})); + if(potionUses.length){ + const off=potionUses.filter(p=>!p.withRecklessness).length; + if(off)add('tip','Potion timing',`${off} of ${potionUses.length} potions were used outside Recklessness. Drink it with Recklessness (and Bloodlust) so the burst lines up.`,off); + else add('good','Potion timing','Every potion went with Recklessness.',0); + }else if(length>=60)add('tip','No potion',`No potion was used in this ${Math.round(length)} s fight. On a boss, drink one at the pull with Recklessness and again near the end or in execute.`,0); + + // Casts per ability, beside SimC's when a simulation is chosen. + const byName=new Map(); + for(const c of casts){const r=byName.get(c.name)||{name:c.name,id:c.id,casts:0};r.casts++;byName.set(c.name,r);} + const abilities=[...byName.values()].map(r=>{ + const key=simKey(r.name),simCpm=sim?.[key]??null; + const dmg=[...data.damage.values()].filter(d=>d.name===r.name).reduce((n,d)=>n+d.amount,0); + return {...r,cpm:round(r.casts/minutes,2),simCpm:simCpm===null?null:round(simCpm,2),damage:dmg,share:pct(dmg,damage)}; + }).sort((a,b)=>b.casts-a.casts); + if(sim){ + for(const a of abilities){ + if(a.simCpm===null)continue; + const diff=a.cpm-a.simCpm; + if(Math.abs(diff)>=1&&Math.abs(diff)/Math.max(a.simCpm,0.5)>=0.25)add('tip',`${a.name}: ${diff<0?'fewer':'more'} than SimC`,`You cast ${a.name} ${a.cpm} times a minute; SimC casts it ${a.simCpm}. ${diff<0?'It ranks higher in SimC’s priority than in yours.':'SimC spends those global cooldowns on something stronger.'}`,round(diff,2)); + } + 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); + } + 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}))}; +} +// 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,''); +const title=key=>key.split('_').map(w=>w[0].toUpperCase()+w.slice(1)).join(' '); diff --git a/lib/analysis/index.mjs b/lib/analysis/index.mjs new file mode 100644 index 0000000..2d0a4c0 --- /dev/null +++ b/lib/analysis/index.mjs @@ -0,0 +1,30 @@ +// Rotation analysis from combat logs, one module per specialization. Fury Warrior is the first; a spec without a +// module is named so the page can say it is not analysed yet. +import * as fury from './fury.mjs'; +export const analysers=[fury]; +export const supported=analysers.map(a=>a.spec.label); +export function analyserFor(data){return analysers.find(a=>a.spec.id===data.spec)||analysers.find(a=>a.isFury?.(data))||null;} + +// SimC's casts per minute by action from a finished simulation report, for the side-by-side comparison. +export function simCastsPerMinute(report){ + const player=report?.sim?.players?.[0];if(!player)return null; + const minutes=(player.collected_data?.fight_length?.mean||0)/60;if(!minutes)return null; + // What the action list pressed in the sample iteration; procs, ticks and heals have executes too but are not casts. + const pressed=new Set([...(player.collected_data?.action_sequence||[]),...(player.collected_data?.action_sequence_precombat||[])].map(e=>e.name).filter(Boolean)); + const out={}; + for(const s of player.stats||[]){ + const executes=s.num_executes?.mean||0; + // Only what the player presses: auto attacks and procs are not casts. + if(executes>0&&pressed.has(s.name)&&!/^auto_attack/.test(s.name))out[s.name]=(out[s.name]||0)+executes/minutes; + } + return out; +} +// SimC's sample sequence of one iteration, and the action list it followed, for the rotation view. +export function simRotation(report,aplText=''){ + const player=report?.sim?.players?.[0];if(!player)return null; + const cd=player.collected_data||{}; + const sequence=(cd.action_sequence||[]).filter(e=>e.name).map(e=>({t:Math.round(e.time*100)/100,name:e.name,spell:e.spell_name||e.name,target:e.target,rage:e.resources?.rage??null,buffs:(e.buffs||[]).map(b=>b.name)})); + const precombat=(cd.action_sequence_precombat||[]).filter(e=>e.name).map(e=>e.name); + const apl=String(aplText).split(/\r?\n/).filter(l=>/^actions/.test(l)); + return {player:player.name,specialization:player.specialization,precombat,sequence,apl,castsPerMinute:simCastsPerMinute(report)}; +} diff --git a/lib/combatlog.mjs b/lib/combatlog.mjs new file mode 100644 index 0000000..6913b67 --- /dev/null +++ b/lib/combatlog.mjs @@ -0,0 +1,126 @@ +// WoW combat logs (Logs/WoWCombatLog-*.txt, advanced logging): finding the fights in a file, and reading one player's +// part of one fight for the rotation analysis (see analysis/). +// +// A file can be gigabytes, so it is read as a stream. A scan finds the fights once (boss encounters, and for training +// dummies and open-world play, stretches where a player keeps dealing damage) and remembers where each one starts +// and ends in bytes, so reading a fight later touches only its own part of the file. +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import readline from 'node:readline'; + +// A pause longer than this ends an activity stretch: the player stopped attacking. +const pause=10; +// Shorter stretches are not fights worth analysing. +const minimumLength=20; +// A stretch is read from this many seconds before its first hit: the opener (Recklessness, Charge, buffs) comes first. +const lead=6; + +export async function logsDir(wowDir){return wowDir?path.join(wowDir,'_retail_','Logs'):null;} +export async function listLogs(dir){ + if(!dir)return []; + const files=[]; + for(const name of await fsp.readdir(dir).catch(()=>[])){ + if(!/^WoWCombatLog.*\.txt$/i.test(name))continue; + const stat=await fsp.stat(path.join(dir,name)).catch(()=>null);if(stat?.isFile())files.push({name,size:stat.size,modified:stat.mtime.toISOString()}); + } + return files.sort((a,b)=>b.modified.localeCompare(a.modified)); +} +// Only a file in the logs folder, by its plain name. +export function logPath(dir,name){ + if(!dir||typeof name!=='string'||!/^WoWCombatLog[\w.-]*\.txt$/i.test(name))throw new Error('Choose a combat log from the list.'); + return path.join(dir,name); +} + +// One log line: "10/2/2026 08:52:57.6472 EVENT,field,field,...". Quoted fields keep their commas; brackets (talent +// and gear lists) stay as raw text. +export function parseLine(line){ + const gap=line.indexOf(' ');if(gap<0)return null; + const stamp=line.slice(0,gap).match(/(\d+)\/(\d+)\/(\d+) (\d+):(\d+):(\d+(?:\.\d+)?)/);if(!stamp)return null; + const time=Date.UTC(+stamp[3],+stamp[1]-1,+stamp[2],+stamp[4],+stamp[5])/1000+Number(stamp[6]); + const fields=[];let current='',quoted=false,depth=0;const text=line.slice(gap+2); + for(let i=0;ievent.startsWith('SWING')?9:12; +// The amount of a damage event; the advanced block is 19 fields long in this log version (12.x). +export function damageOf(p){const at=advancedStart(p.event)+19;return Number(p.fields[at])||0;} +const isPlayer=guid=>typeof guid==='string'&&guid.startsWith('Player-'); +const damageEvent=e=>e==='SWING_DAMAGE'||e==='SPELL_DAMAGE'||e==='SPELL_PERIODIC_DAMAGE'||e==='RANGE_DAMAGE'; + +async function* lines(file,start=0,end=Infinity){ + const stream=fs.createReadStream(file,{start,...(Number.isFinite(end)?{end:end-1}:{}),encoding:'utf8'}); + const reader=readline.createInterface({input:stream,crlfDelay:Infinity}); + let offset=start; + // Byte length of each line plus its CRLF, so a stretch can be read back from its offsets. + for await(const line of reader){const next=offset+Buffer.byteLength(line)+2;yield {line,offset,next};offset=next;} +} + +// Finds every fight: boss encounters (ENCOUNTER_START/END) and stretches of continuous damage per player. +// `progress(fraction)` is told how far the scan has come. +export async function scanLog(file,progress=()=>{}){ + const size=(await fsp.stat(file)).size; + const fights=[],stretches=new Map(),names=new Map(),specs=new Map();let encounter=null,lastReport=0,first=null,last=null; + // Where each recent second of the file starts, to begin a stretch `lead` seconds early. + const marks=[];const earlier=time=>{for(const [t,o] of marks)if(t>=time-lead)return o;return marks.at(-1)?.[1]??0;}; + const closeStretch=(guid,s)=>{if(s.end-s.start>=minimumLength)fights.push({kind:'activity',player:guid,start:s.start,end:s.end,startOffset:s.startOffset,endOffset:s.endOffset,damage:s.damage,targets:[...s.targets].slice(0,8)});}; + for await(const {line,offset,next} of lines(file)){ + if(offset-lastReport>8*1048576){lastReport=offset;progress(offset/size);} + const p=parseLine(line);if(!p)continue;first??=p.time;last=p.time; + if(!marks.length||p.time-marks.at(-1)[0]>=1){marks.push([p.time,offset]);while(marks.length&&p.time-marks[0][0]>lead+1)marks.shift();} + const f=p.fields; + if(p.event==='ENCOUNTER_START'){encounter={kind:'encounter',name:f[2],difficulty:Number(f[3]),start:p.time,startOffset:offset,players:new Map()};continue;} + if(p.event==='ENCOUNTER_END'&&encounter){fights.push({...encounter,end:p.time,endOffset:next,success:f[5]==='1',players:[...encounter.players.entries()].map(([guid,damage])=>({guid,damage}))});encounter=null;continue;} + if(p.event==='COMBATANT_INFO'){const bracket=f.findIndex(x=>x.startsWith('['));if(bracket>1)specs.set(f[1],Number(f[bracket-1]));continue;} + if(!damageEvent(p.event)||!isPlayer(f[1]))continue; + names.set(f[1],f[2]); + // Pets and guardians count for their owner when the line says who that is; players are their own source. + const amount=damageOf(p); + if(encounter)encounter.players.set(f[1],(encounter.players.get(f[1])||0)+amount); + let s=stretches.get(f[1]); + if(s&&p.time-s.end>pause){closeStretch(f[1],s);s=null;} + if(!s){s={start:p.time,end:p.time,startOffset:earlier(p.time),endOffset:next,damage:0,targets:new Set()};stretches.set(f[1],s);} + s.end=p.time;s.endOffset=next;s.damage+=amount;if(s.targets.size<8)s.targets.add(f[6]); + } + for(const [guid,s] of stretches)closeStretch(guid,s); + progress(1); + const label=guid=>names.get(guid)||guid; + return {size,start:first,end:last,players:[...names].map(([guid,name])=>({guid,name,spec:specs.get(guid)||null})), + fights:fights.sort((a,b)=>a.start-b.start).map((fight,id)=>({id,...fight,length:Math.round((fight.end-fight.start)*10)/10,...(fight.player?{name:label(fight.player)}:{}),...(fight.players?{players:fight.players.map(x=>({...x,name:label(x.guid)})).sort((a,b)=>b.damage-a.damage)}:{})}))}; +} + +// 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}; + 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;} + const fromPlayer=f[1]===guid,toPlayer=f[5]===guid; + if(!fromPlayer&&!toPlayer)continue; + if(fromPlayer)out.name??=f[2]; + if(p.event==='SPELL_CAST_SUCCESS'&&fromPlayer){out.casts.push({time:p.time,id:Number(f[9]),name:f[10],target:f[6]});continue;} + if(p.event.startsWith('SPELL_AURA_')&&toPlayer&&f[12]==='BUFF'){out.auras.push({time:p.time,event:p.event.slice(11),id:Number(f[9]),name:f[10],stacks:Number(f[13])||1,source:f[2]});continue;} + if(p.event.endsWith('_ENERGIZE')&&toPlayer&&fromPlayer){ + // ...,amount,overEnergize,powerType,maxPower at the end of the line. + const n=f.length;out.energize.push({time:p.time,id:Number(f[9]),name:f[10],amount:Number(f[n-4])||0,over:Number(f[n-3])||0,type:Number(f[n-2])});continue; + } + if(damageEvent(p.event)&&fromPlayer){ + 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; + 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); + } + } + out.start??=fight.start;out.end??=fight.end; + return out; +} diff --git a/lib/engine.mjs b/lib/engine.mjs index b2dc11f..b6f5c76 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -167,7 +167,7 @@ export function inputFor(variant,settings,scenario,paths) { // 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':''}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=${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`; } export function resultFrom(report) { const p=report.sim?.players?.[0]; const dps=p?.collected_data?.dps; @@ -238,7 +238,7 @@ export class Jobs { else if(plan.vault)await this.runVault(job,plan); else for(let s=0;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={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.'],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'].includes(value);$('#history').hidden=value!=='history';$('#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';$('#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. @@ -108,15 +109,17 @@ function renderJob(job){ 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+=`${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':''}`}${tanky?`${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'?`HTMLJSON`:''}Input`; + html+=`${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':''}`}${tanky?`${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`; } - html+=''; + 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.

'; 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; } async function loadHistory(){const jobs=await api('/api/jobs');$('#history-list').innerHTML=jobs.length?jobs.map(j=>``).join(''):'

No simulations yet. Run Quick Sim, Enchant Lab or Talent Search to get started.

';} +// 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'});}}); async function init(){ @@ -147,6 +150,7 @@ const trinkets=trinketUI({api,notice,updateCount,run:()=>runJob(),cancel:()=>$(' const engineView=engineUI({api,notice}); const activity=activityUI({api,openJob:async id=>{if(mode==='history')await setMode('quick');await watchJob(id);$('#results').scrollIntoView({behavior:'smooth',block:'start'});},onChange:()=>{if(mode==='history')loadHistory().catch(()=>{});}}); const wow=wowUI({api,notice,importText:async text=>{$('#profile').value=text;await importProfile();}}); +const logs=logsUI({api,notice}); armoryUI({api,notice,importText:async text=>{$('#profile').value=text;await importProfile();}}); const tank=tankUI({updateCount}); const environment=environmentUI({api,notice,addVariant:v=>{if(variants.length===1&&!variants[0].text)variants=[];variants.push(v);renderVariants();updateCount();}}); diff --git a/public/index.html b/public/index.html index b917d30..355ec66 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/logs.js b/public/logs.js new file mode 100644 index 0000000..23aae57 --- /dev/null +++ b/public/logs.js @@ -0,0 +1,85 @@ +const $=s=>document.querySelector(s); +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const number=n=>Math.round(n||0).toLocaleString('en-US'); +const size=b=>b>1073741824?`${(b/1073741824).toFixed(1)} GB`:b>1048576?`${(b/1048576).toFixed(0)} MB`:`${Math.ceil(b/1024)} KB`; +const when=iso=>new Date(iso).toLocaleString('en-GB',{dateStyle:'medium',timeStyle:'short'}); +const clock=s=>`${Math.floor(s/60)}:${String(Math.round(s%60)).padStart(2,'0')}`; +const short=name=>String(name||'').replace(/-[^-]+-(EU|US|KR|TW|CN)$/,''); +// Abilities drawn on the cast timeline, in this order; the rest of the casts are listed in the table. +const lanes=['Recklessness','Avatar','Bladestorm',"Odyn's Fury",'Thunderous Roar','Rampage','Bloodthirst','Bloodbath','Raging Blow','Crushing Blow','Execute','Whirlwind']; + +// 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; + $('#history').insertAdjacentHTML('afterend',``); + + async function refresh(){ + 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(''):''; + if(!r.dir)$('#log-scan-state').textContent='The WoW folder was not found, so there are no logs to read.'; + }catch(e){notice(e.message);} + } + async function startScan(){ + const file=$('#log-file').value;if(!file)return;clearTimeout(polling);$('#log-report').innerHTML=''; + try{ + const state=await api('/api/logs/scan',{file}); + if(state.status==='scanning'){$('#log-scan-state').innerHTML=`Reading the log … ${Math.round(100*state.progress)}%
`;polling=setTimeout(startScan,700);return;} + if(state.status==='failed')throw new Error(state.error); + 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. + 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 rows=all.slice(0,60); + $('#log-fights').innerHTML=rows.length?`${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.

'; + } + 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]); + // 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 …
`; + const run=async()=>{try{render(await api('/api/logs/analyse',{file:scan.file,fight:id,player,job:$('#log-compare').value||null}));}catch(e){$('#log-result').innerHTML=`

${esc(e.message)}

`;}}; + $('#log-compare').onchange=run;if(sims[0]){$('#log-compare').value=sims[0].id;}await run(); + } + function render(r){ + const icon={warning:'!',tip:'→',good:'✓'}; + const timeline=lanes.filter(l=>r.casts.some(c=>c.name===l)).map(l=>`
${esc(l)}
${r.casts.filter(c=>c.name===l).map(c=>``).join('')}
`).join(''); + $('#log-result').innerHTML=`
${number(r.dps)}DPS · ${esc(short(r.name))} · ${esc(r.spec)}
${clock(r.length)}${r.targets} target${r.targets===1?'':'s'}
${r.uptime.enrage}%Enrage
${r.uptime.recklessness}%Recklessness
${r.compare?`
${number(r.compare.dps)}SimC · ${esc(r.compare.scenario?.style||'')}
`:''}
+

What to change

${r.findings.map(f=>`
${icon[f.severity]}
${esc(f.title)}

${esc(f.detail)}

`).join('')}
+

Casts over the fight

${timeline}
+

Casts per minute${r.compare?' · you and SimC':''}

${r.compare?'':''}${r.abilities.map(a=>`${r.compare?``:''}`).join('')}
AbilityCastsPer minuteSimC per minuteDamage
${esc(a.name)}${a.casts}${a.cpm}${a.simCpm??'—'}${a.share?a.share+'%':'—'}
+ ${r.cooldowns.length?`

Recklessness

${r.cooldowns.map(c=>``).join('')}
AtWith
${clock(c.time)}${c.with.length?esc([...new Set(c.with)].join(', ')):'Nothing else'}
`:''} + ${r.potions.length?`

Potions

${r.potions.map(p=>``).join('')}
AtPotionWith Recklessness
${clock(p.time)}${esc(p.name)}${p.withRecklessness?'Yes':'No'}
`:''} +
Damage by spell${r.spells.map(s=>``).join('')}
SpellDamageShareHitsCrit
${esc(s.name)}${number(s.amount)}${s.share}%${s.hits}${s.crit}%
+

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();}; + document.addEventListener('click',e=>{const b=e.target.closest('[data-analyse]');if(b)analyse(Number(b.dataset.analyse));}); + return {show:refresh}; +} + +// SimC's rotation for one finished run: the action list it followed and what it pressed in a sample iteration. +export async function rotationView(api,job,stem){ + const r=await api(`/api/jobs/${job}/rotation/${stem}`); + const cpm=Object.entries(r.castsPerMinute||{}).sort((a,b)=>b[1]-a[1]); + return `

SimC rotation · ${esc(r.variant)}

+
Casts per minute
${cpm.map(([k,v])=>``).join('')}
${esc(k)}${v.toFixed(2)}
+
Sample sequence · first 60 s
${r.sequence.filter(e=>e.t<=60).map(e=>``).join('')}
TimeActionRageBuffs
${e.t.toFixed(2)}${esc(e.name)}${e.rage===null?'—':Math.round(e.rage)}${esc(e.buffs.filter(b=>/enrage|reckless|whirlwind|sudden|bladestorm|avatar|bloodlust|potion/.test(b)).join(', '))}
+ ${r.apl.length?`
Action priority list (${r.apl.length} lines)
${esc(r.apl.join('\n'))}
`:'

The action list is saved for runs made from SimC Lab 1.32.0 on.

'}
`; +} diff --git a/public/style.css b/public/style.css index 9cf9786..08622fd 100644 --- a/public/style.css +++ b/public/style.css @@ -131,3 +131,5 @@ .vault-pick{padding:12px 14px;border:1px solid var(--line);border-left:3px solid var(--green);border-radius:6px;background:#141a25;margin:10px 0 14px}.vault-pick strong{color:var(--green)}.result-table tr.best td:first-child{box-shadow:inset 3px 0 0 var(--green)} .update-steps{list-style:none;margin:12px 0 8px;padding:0;display:grid;gap:5px;font-size:12px;color:var(--muted)}.update-steps li{display:flex;align-items:center;gap:8px}.update-steps span{width:18px;height:18px;border-radius:50%;display:inline-grid;place-content:center;font-size:10px;border:1px solid var(--line);flex-shrink:0}.update-steps .done{color:#b9c4d4}.update-steps .done span{background:#1d3329;border-color:#305442;color:var(--green)}.update-steps .current{color:var(--amber);font-weight:600}.update-steps .current span{background:#302b23;border-color:#57432b;animation:pulse 1.6s ease-in-out infinite}.update-steps .failed{color:#f4b5be}.update-steps .failed span{background:#412b32;border-color:#805060}.update-now{font-size:12px;line-height:1.7;color:var(--muted);overflow-wrap:anywhere}.update-now strong{color:#d6deea}.update-log summary{font-size:12px;margin-top:8px}.update-banner .engine-actions{display:flex;gap:8px;flex-wrap:wrap}.update-banner .engine-actions .button{width:auto}.sidebar-update.has-update{border-color:#57432b;color:var(--amber)}@keyframes pulse{50%{opacity:.45}}@media(prefers-reduced-motion:reduce){.update-steps .current span{animation:none}} .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} diff --git a/server.mjs b/server.mjs index 00f7a5d..e06b93d 100644 --- a/server.mjs +++ b/server.mjs @@ -25,6 +25,8 @@ import {loadSettings,saveSettings} from './lib/settings.mjs'; import {WowAddon} from './lib/wowaddon.mjs'; import {identity,simEntry,trackTable,sendableModes} from './lib/wowdata.mjs'; 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'; 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'); @@ -54,6 +56,26 @@ jobs.onFinished=async job=>{ }; wow.autoUpdate().catch(e=>console.error('SimCLab addon update failed:',e.message)); const ready=()=>{if(!catalog)throw new Error(loadError||'SimC is not installed yet. Use Update SimC.');}; +// Combat log scans, kept per file while it is unchanged (name, size and time); a big file takes a while, so a scan +// runs in the background and the page asks how far it has come. +const scans=new Map(); +async function startScan(name){ + const dir=await logsDir(await engine.wowInstallDir());const file=logPath(dir,name);const stat=await fs.stat(file); + const key=`${name}|${stat.size}|${stat.mtimeMs}`;let scan=scans.get(name); + if(!scan||scan.key!==key){ + scan={key,status:'scanning',progress:0};scans.set(name,scan); + scanLog(file,p=>{scan.progress=p;}).then(result=>Object.assign(scan,{status:'done',result}),e=>Object.assign(scan,{status:'failed',error:e.message})); + } + return {status:scan.status,progress:scan.progress,error:scan.error,...(scan.status==='done'?{scan:scan.result}:{})}; +} +// A finished simulation to compare with: its first completed result's report. +async function jobReport(id,stem){ + const job=jobs.jobs.get(String(id));if(!job)throw new Error('Simulation not found.'); + const row=stem?job.results.find(r=>r.stem===stem):job.results.find(r=>r.status==='complete'&&r.baseline)||job.results.find(r=>r.status==='complete'); + if(!row||row.status!=='complete')throw new Error('That simulation has no finished result.'); + const dir=path.join(runsDir,job.id); + return {job,row,report:JSON.parse(await fs.readFile(path.join(dir,row.stem+'.json'),'utf8')),apl:await fs.readFile(path.join(dir,row.stem+'.apl.simc'),'utf8').catch(()=>'')}; +} const json=(res,status,data)=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store'});res.end(JSON.stringify(data));}; async function body(req){let text='';for await(const chunk of req){text+=chunk;if(text.length>600000)throw new Error('The request is too large.');}return JSON.parse(text);} const server=http.createServer(async(req,res)=>{ @@ -82,6 +104,17 @@ 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()); + 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));} + if(req.method==='POST'&&route==='/api/logs/analyse'){ + const {file,fight:id,player,job}=await body(req);const state=await startScan(file);if(state.status!=='done')throw new Error('Scan the log first.'); + const fight=state.scan.fights.find(f=>f.id===Number(id));if(!fight)throw new Error('Choose a fight.'); + const data=await readPlayer(logPath(await logsDir(await engine.wowInstallDir()),file),fight,String(player)); + const analyser=analyserFor(data);if(!analyser)throw new Error(`Rotation analysis is not available for this specialization yet. Analysed so far: ${analysedSpecs.join(', ')}.`); + const compare=job?await jobReport(job):null; + const potions=[...new Set((catalog?.consumables.potion||[]).map(p=>p.name.replace(/\s*\(.*\)$/,'')))]; + return json(res,200,{...analyser.analyse(data,{sim:compare?simCastsPerMinute(compare.report):null,potions}),fight:{id:fight.id,kind:fight.kind,name:fight.name,length:fight.length},...(compare?{compare:{job:compare.job.id,name:compare.job.name,dps:compare.row.dps,scenario:compare.job.scenarios[compare.row.scenario]}}:{})}); + } if(req.method==='POST'&&route==='/api/wow/send'){const {job:id}=await body(req);const job=jobs.jobs.get(String(id));if(!job)return json(res,404,{error:'Job not found.'});return json(res,200,await sendToWow(job,{auto:false}));} if(route!=='/api/jobs'&&route.startsWith('/api/')&&!route.startsWith('/api/jobs/')&&route!=='/api/status')ready(); if(req.method==='GET'&&route==='/api/options')return json(res,200,{buffs,consumables:catalog.consumables,expansion:catalog.expansion,tankPresets}); @@ -128,6 +161,8 @@ const server=http.createServer(async(req,res)=>{ if(req.method==='POST'&&route==='/api/jobs'){ready();if(updater.switching)throw new Error('SimC is switching to the new engine. Try again in a moment.');const request=await body(req);const plan=await prepare(request,catalog,talentData,season);return json(res,201,jobs.public(await jobs.add(plan,request)));} if(req.method==='GET'&&route==='/api/jobs/active')return json(res,200,jobs.activeJobs()); if(req.method==='GET'&&route==='/api/jobs')return json(res,200,[...jobs.jobs.values()].reverse().map(j=>({id:j.id,name:j.name,mode:j.mode,status:j.status,created:j.created,done:j.done,total:j.total,fraction:jobFraction(j)}))); + const rotation=route.match(/^\/api\/jobs\/([\da-f-]{36})\/rotation(?:\/(\d{3}))?$/); + if(req.method==='GET'&&rotation){const {report,apl,row}=await jobReport(rotation[1],rotation[2]);return json(res,200,{...simRotation(report,apl),stem:row.stem,variant:row.name,dps:row.dps});} const jobRoute=route.match(/^\/api\/jobs\/([\da-f-]{36})(\/cancel)?$/); if(jobRoute){const job=jobs.jobs.get(jobRoute[1]);if(!job)return json(res,404,{error:'Job not found.'});if(req.method==='POST'&&jobRoute[2])return json(res,200,await jobs.cancel(job.id));if(req.method==='GET'&&!jobRoute[2])return json(res,200,{...jobs.public(job),queue:jobs.queueFor(job),fraction:jobFraction(job)});} // The tier list is written here rather than by SimC, and carries no script, so it can be read in place. @@ -176,7 +211,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','/armory.js':'armory.js','/style.css':'style.css'}; + 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'}; 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/combatlog.test.mjs b/tests/combatlog.test.mjs new file mode 100644 index 0000000..7529669 --- /dev/null +++ b/tests/combatlog.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {parseLine,scanLog,readPlayer,logPath,listLogs} from '../lib/combatlog.mjs'; +import {analyserFor,simCastsPerMinute} from '../lib/analysis/index.mjs'; +import {intervals,simKey} from '../lib/analysis/fury.mjs'; + +const me='Player-1329-0408D637',name='"Roburevolved-Ravencrest-EU"',flags='0x511,0x80000000'; +const dummy=i=>`Creature-0-3891-0-88154-243208-0000${i}F3A3A`; +const stamp=s=>{const t=new Date(Date.UTC(2026,9,2,8,53,0)+s*1000);return `10/2/2026 ${String(t.getUTCHours()).padStart(2,'0')}:${String(t.getUTCMinutes()).padStart(2,'0')}:${String(t.getUTCSeconds()).padStart(2,'0')}.${String(t.getUTCMilliseconds()).padStart(3,'0')}0`;}; +// The advanced block (17 fields of the unit, then the position) as 12.x writes it. +const block=guid=>`${guid},0000000000000000,1,3537050,0,0,1470,0,0,0,1,0,0,0,8172.79,-4354.56,2393,0.2616,90`; +const hit=(s,spell,label,target,amount,crit=0)=>`${stamp(s)} SPELL_DAMAGE,${me},${name},${flags},${dummy(target)},"Cleave Training Dummy",0x10a28,0x80000000,${spell},"${label}",0x1,${block(dummy(target))},${amount},${amount},-1,1,0,0,0,${crit},nil,nil,nil,ST`; +const cast=(s,spell,label)=>`${stamp(s)} SPELL_CAST_SUCCESS,${me},${name},${flags},${dummy(1)},"Cleave Training Dummy",0x10a28,0x80000000,${spell},"${label}",0x1,${block(me)}`; +const aura=(s,event,spell,label)=>`${stamp(s)} SPELL_AURA_${event},${me},${name},${flags},${me},${name},${flags},${spell},"${label}",0x1,BUFF`; +const rage=(s,amount,over)=>`${stamp(s)} SPELL_ENERGIZE,${me},${name},${flags},${me},${name},${flags},23881,"Bloodthirst",0x1,${block(me)},${amount},${over},1,1300`; + +async function sampleLog(){ + const lines=['10/2/2026 08:52:57.6472 COMBAT_LOG_VERSION,22,ADVANCED_LOG_ENABLED,1,BUILD_VERSION,12.1.0,PROJECT_ID,1']; + lines.push(aura(0,'APPLIED',184362,'Enrage'),aura(0,'APPLIED',85739,'Whirlwind'),cast(0.5,1719,'Recklessness'),aura(0.5,'APPLIED',1719,'Recklessness')); + for(let s=1;s<=60;s+=1.5){ + lines.push(cast(s,s%3<1.5?184367:23881,s%3<1.5?'Rampage':'Bloodthirst')); + for(let t=1;t<=5;t++)lines.push(hit(s,184367,'Rampage',t,20000,t===1?1:0)); + } + lines.push(cast(2,446035,'Bladestorm'),aura(30,'REMOVED',184362,'Enrage'),aura(36,'APPLIED',184362,'Enrage')); + lines.push(aura(10,'APPLIED',52437,'Sudden Death'),aura(18,'REMOVED',52437,'Sudden Death'),rage(5,20,0),rage(6,20,10)); + lines.push(aura(3,'APPLIED',1236994,'Liquid Luster')); + const dir=await fs.mkdtemp(path.join(os.tmpdir(),'simclab-log-'));const file=path.join(dir,'WoWCombatLog-100226_085257.txt'); + // A real log is in time order. + lines.sort((a,b)=>a.slice(0,24).localeCompare(b.slice(0,24))); + await fs.writeFile(file,lines.join('\r\n')+'\r\n');return {dir,file}; +} + +test('a log line keeps quoted commas and bracketed lists whole',()=>{ + const p=parseLine('10/2/2026 08:53:15.0802 SPELL_CAST_SUCCESS,Player-1,"Name, Odd-Realm",0x511,0x0,0000,nil,0x0,0x0,385059,"Odyn\'s Fury",0x1'); + assert.equal(p.event,'SPELL_CAST_SUCCESS');assert.equal(p.fields[2],'Name, Odd-Realm');assert.equal(p.fields[10],"Odyn's Fury"); + assert.equal(parseLine('9/25/2026 19:35:41.1272 COMBATANT_INFO,Player-1,0,[(1,2,1),(3,4,1)],266').fields[3],'[(1,2,1),(3,4,1)]'); + assert.equal(parseLine('no timestamp here'),null); + assert.throws(()=>logPath('C:/Logs','../secret.txt'),/combat log/); +}); + +test('the scan finds a player’s fight, and its slice of the file reads back the same events',async()=>{ + const {dir,file}=await sampleLog(); + assert.equal((await listLogs(dir))[0].name,'WoWCombatLog-100226_085257.txt'); + const scan=await scanLog(file); + assert.equal(scan.fights.length,1);const fight=scan.fights[0]; + assert.equal(fight.kind,'activity');assert.equal(fight.name,'Roburevolved-Ravencrest-EU');assert.equal(fight.length,58.5);assert.equal(fight.damage,40*5*20000); + const data=await readPlayer(file,fight,me); + assert.equal(data.damage.get(184367).hits,200);assert.equal(data.targets.size,5);assert.ok(data.casts.length>=40); +}); + +test('Fury analysis: uptimes, wasted rage, expired procs and potions, with SimC beside it',async()=>{ + const {file}=await sampleLog();const scan=await scanLog(file);const data=await readPlayer(file,scan.fights[0],me); + const fury=analyserFor(data);assert.equal(fury?.spec.label,'Fury Warrior'); + const r=fury.analyse(data,{potions:['Liquid Luster'],sim:{rampage:30,bloodthirst:5,raging_blow:8}}); + assert.equal(r.dps,Math.round(40*5*20000/58.5)); + assert.equal(r.uptime.enrage,Math.round(1000*(58.5-6)/58.5)/10,'down from 30 s to 36 s'); + assert.deepEqual(r.rage,{gained:40,wasted:10}); + const title=t=>r.findings.find(f=>f.title===t); + assert.equal(title('Enrage uptime').severity,'tip','89.7% is just under the 90% aim'); + assert.match(title('Sudden Death').detail,/1 of 1 Sudden Death procs ran out unused/); + assert.equal(title('Rage over the cap').severity,'warning'); + assert.equal(title('Potion timing').severity,'good','drunk inside Recklessness'); + assert.ok(title('Raging Blow not used'),'SimC presses something the player never did'); + assert.equal(r.abilities.find(a=>a.name==='Rampage').simCpm,30); + assert.equal(r.cooldowns[0].with[0],'Bladestorm'); +}); + +test('buff windows and SimC names',()=>{ + const auras=[{time:1,event:'APPLIED',id:7},{time:5,event:'REMOVED',id:7},{time:8,event:'APPLIED',id:7}]; + assert.deepEqual(intervals(auras,[7],0,10),[[1,5],[8,10]]); + assert.equal(simKey("Odyn's Fury"),'odyns_fury');assert.equal(simKey('Raging Blow'),'raging_blow'); + // Only what the action list pressed counts as a cast, not ticks and procs. + const report={sim:{players:[{collected_data:{fight_length:{mean:120},action_sequence:[{name:'rampage'},{name:'auto_attack'}]},stats:[{name:'rampage',num_executes:{mean:50}},{name:'rend_dot',num_executes:{mean:100}},{name:'auto_attack',num_executes:{mean:1}}]}]}}; + assert.deepEqual(simCastsPerMinute(report),{rampage:25}); +});