diff --git a/desktop/package.json b/desktop/package.json index b138963..4c84532 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.29.1", + "version": "1.30.0", "description": "Local SimulationCraft workbench with upgrade finder and tank simulation.", "author": "Roburmaster", "private": true, diff --git a/lib/dummies.mjs b/lib/dummies.mjs new file mode 100644 index 0000000..a940113 --- /dev/null +++ b/lib/dummies.mjs @@ -0,0 +1,30 @@ +// The five Cleave Training Dummies in Silvermoon City (NPC 243208), measured from a combat log taken beside them on +// WoW 12.1.0 (2026-10-02): +// - five dummies stacked within 4 yards, 3,537,050 health each, level 90 like the player; +// - armor 1,470: every physical hit lost exactly 30%, which SimC already gives (armor 1,470 against the attacker's +// level-90 constant 3,430), so armor needs no option; +// - they cannot die. Busy as they always are, each sat at 1 health 78% of the time and never rose above 8%, so +// every execute effect is up the whole fight; +// - nothing but the player's own effects raised the damage they took: no Mystic Touch, Chaos Brand or Hunter's +// Mark; the player had no Fortitude either (maximum health exactly 5% under SimC's), and no Bloodlust or potion; +// - they never attack (SimC's default enemy hits the player, which feeds damage-taken procs, so they only wait). +// So the page starts this fight with every buff, debuff and consumable off (environment.js, bare()); the user turns +// on what they had, and nothing here forces any of it. +// SimC creates extra targets with its defaults, so every dummy is declared on its own, before the player: options +// that follow belong to the player again, and the player becomes actor `count` for profilesets. +// A fresh pull of five at full health was tried and left out: SimC ends a fixed-health fight when the first target +// dies, and its DungeonRoute pulls drop raid buffs and target debuffs, so neither measures the city's dummies. +export const silvermoon={npc:243208,name:'Cleave_Training_Dummy',count:5,health:3537050,heldAt:1}; +export const dummyStyles={SilvermoonDummies:{label:'Silvermoon dummies · 5 in execute'}}; +export const isDummyStyle=style=>Object.hasOwn(dummyStyles,style); + +export function dummyLines(style){ + if(!isDummyStyle(style))return []; + const lines=['target_level+=0']; + for(let i=1;i<=silvermoon.count;i++)lines.push(`enemy=${silvermoon.name}_${i}`,`enemy_fixed_health_percentage=${silvermoon.heldAt}`,'actions=wait,sec=10'); + return lines; +} +// How many enemies are declared before the player in a fight of this style. +export const dummyCount=style=>isDummyStyle(style)?silvermoon.count:0; +// The dummies are always five; buffs and consumables are the environment's, as the user sets them. +export const dummyScenario={targets:silvermoon.count}; diff --git a/lib/engine.mjs b/lib/engine.mjs index 979cccb..2477bc9 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -14,6 +14,7 @@ import {promisify} from 'node:util'; import {randomUUID} from 'node:crypto'; import {parseProfile,createVariants} from './profile.mjs'; import {isArmoryProfile} from './armory.mjs'; +import {isDummyStyle,dummyLines,dummyCount,dummyScenario} from './dummies.mjs'; import {normalizeTank,bossLines,survivalLines,actorTank,calibrate,tankComparison,tankMetricList} from './tank.mjs'; import {appRoot,home,runsDir,readEngineMetadata,enginePaths} from './paths.mjs'; export {runsDir}; @@ -65,8 +66,9 @@ export async function installedWowVersion(){ return row?.[headers.indexOf('Version')]||null; }catch{return null;} } -// CastingPatchwerk is Patchwerk with a boss that casts, for effects that react to enemy casts. -export const fightStyles=['Patchwerk','CastingPatchwerk','HecticAddCleave','DungeonSlice','HeavyMovement','LightMovement']; +// CastingPatchwerk is Patchwerk with a boss that casts, for effects that react to enemy casts. The Silvermoon styles +// are the city's five training dummies, measured from a combat log (see dummies.mjs). +export const fightStyles=['Patchwerk','CastingPatchwerk','HecticAddCleave','DungeonSlice','HeavyMovement','LightMovement','SilvermoonDummies']; function integer(value, fallback, min, max, label) { const n=Number(value ?? fallback); if(!Number.isInteger(n)||nmax) throw new Error(`${label} must be ${min}–${max}.`); return n; } @@ -89,11 +91,17 @@ export async function prepare(request,catalog,talentData,season) { const presets=request.mode==='trinkets'&&request.trinkets?.scenarioSet?.length; // Bloodmallet parity fixes the fight and the precision to Bloodmallet's own (see parity in trinkets.mjs). const parity=request.mode==='trinkets'&&!!request.trinkets?.parity; - const scenarios=parity?[{style:parityRun.fightStyle,targets:parityRun.targets,duration:parityRun.duration}]:presets?presetScenarios(request.trinkets.scenarioSet):request.scenarios || [{style:'Patchwerk',targets:1}]; + let scenarios=parity?[{style:parityRun.fightStyle,targets:parityRun.targets,duration:parityRun.duration}]:presets?presetScenarios(request.trinkets.scenarioSet):request.scenarios || [{style:'Patchwerk',targets:1}]; if(!Array.isArray(scenarios)||!scenarios.length||scenarios.length>8) throw new Error('Choose 1–8 scenarios.'); + // The Silvermoon dummies are always five, so a target matrix collapses to one fight there. + scenarios=scenarios.filter((s,i)=>!isDummyStyle(s?.style)||scenarios.findIndex(o=>o?.style===s.style&&(o.duration??null)===(s.duration??null))===i); const unique=new Set(); for(const s of scenarios) { if(!fightStyles.includes(s.style)) throw new Error('Unknown fight style.'); + if(isDummyStyle(s.style)){ + if(settings.tank)throw new Error('The Silvermoon dummies never attack, so there is nothing to tank. Turn tank simulation off for this fight style.'); + Object.assign(s,dummyScenario); + } s.targets=integer(s.targets,1,1,20,'Targets'); if(s.duration!==undefined)s.duration=integer(s.duration,300,10,1200,'Scenario duration'); const key=`${s.style}-${s.targets}-${s.duration||''}`; if(unique.has(key)) throw new Error('The same scenario was selected more than once.'); unique.add(key); @@ -150,11 +158,14 @@ 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(''); +// 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; + 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. - const style=boss&&scenario.style==='Patchwerk'?'':`fight_style=${scenario.style}\n`; - return `${boss?bossLines(boss).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`; + // 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).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`; } export function resultFrom(report) { const p=report.sim?.players?.[0]; const dps=p?.collected_data?.dps; @@ -548,7 +559,8 @@ export class Jobs { const title=job.crests?'Upgrades':job.vault?'Great Vault choices':{1:'Screening',3:'Embellishment pairs',[pairStages.screen]:'Pair screening',[pairStages.final]:'Pair final',[pairStages.resolve]:'Resolving tied pairs'}[stage]||'Final round'; job.current={name:`${spec?spec.label+' · ':''}${title} · ${list.length} ${isPairStage(stage)?'pairs':'candidates'}`,scenario,index:job.stages.length}; // Profilesets go last so each one inherits every option of the base profile and environment. - await fs.writeFile(input,inputFor({text:profile.text},settings,scenario,paths)+(settings.tank?`profileset_metric=${tankMetricList}\nprofileset_main_actor_index=1\n`:'')+(spec?.support?'profileset_metric=raid_dps,dps\n':'')+profilesetLines(list,profile,this.catalog).join('\n')+'\n'); + const actor=playerActorIndex(settings,scenario); + await fs.writeFile(input,inputFor({text:profile.text},settings,scenario,paths)+(settings.tank?`profileset_metric=${tankMetricList}\n`:'')+(actor?`profileset_main_actor_index=${actor}\n`:'')+(spec?.support?'profileset_metric=raid_dps,dps\n':'')+profilesetLines(list,profile,this.catalog).join('\n')+'\n'); await this.save(this.public(job)); try{ await this.execute(input,dir,job); diff --git a/lib/settings.mjs b/lib/settings.mjs index 72f847e..c486256 100644 --- a/lib/settings.mjs +++ b/lib/settings.mjs @@ -4,12 +4,13 @@ import path from 'node:path'; import {dataDir} from './paths.mjs'; const file=path.join(dataDir,'settings.json'); -// Checking for a newer official SimC build at start, and installing it, is on unless the user turns it off. // Following the latest commit builds SimC from source (Windows otherwise takes the official nightly, a day behind at most). -export const defaults={autoUpdateSimc:true,followLatestCommit:false}; +// SimC is never installed by itself, so there is no setting for that: the app only says when a newer build is out. +export const defaults={followLatestCommit:false}; export async function loadSettings(){ - try{return {...defaults,...JSON.parse(await fs.readFile(file,'utf8'))};}catch{return {...defaults};} + // Settings that no longer exist (autoUpdateSimc before 1.30.0) are dropped. + try{const saved=JSON.parse(await fs.readFile(file,'utf8'));return {...defaults,...Object.fromEntries(Object.entries(saved).filter(([key])=>key in defaults))};}catch{return {...defaults};} } export async function saveSettings(changes){ const current=await loadSettings(),next={...current}; diff --git a/lib/updater.mjs b/lib/updater.mjs index 237d3e8..30ffadd 100644 --- a/lib/updater.mjs +++ b/lib/updater.mjs @@ -76,14 +76,24 @@ export function plan({installedWow,local,head,nightly,live,mode='auto',sourceOnl return {action:upToDate?'data':engine.kind,engine:upToDate?null:engine,target,reason:upToDate?'New game data is available.':engine.kind==='nightly'?'A newer SimC build is available.':newerHead?'GitHub has newer SimC commits; SimC is built from source.':'SimC must be built from source for this WoW build.'}; } +// The steps of each kind of update, in order, as the engine panel shows them (labels live in the page). +// 'switch' downloads the matching game data and swaps engine and data in; only it keeps new simulations waiting. +export const phasePlans={nightly:['check','download','verify','wait','switch'],source:['check','tools','fetch','configure','compile','verify','wait','switch'],data:['check','wait','switch']}; + export class Updater{ // `followSource()` says whether this install follows the latest SimC commit (built from source) rather than the // official nightly; Linux has no nightly and always does. // `onSettled(state)` is told when an update ends, whether it installed something or failed. - constructor({busy,onInstalled,followSource=async()=>false,onSettled=()=>{},idleWait=30000}){this.busy=busy;this.onInstalled=onInstalled;this.followSource=followSource;this.onSettled=onSettled;this.idleWait=idleWait;this.state={status:'idle',log:[]};} + // `checkEvery` is how often `watch()` looks for a newer SimC build. + constructor({busy,onInstalled,followSource=async()=>false,onSettled=()=>{},idleWait=30000,checkEvery=3*3600*1000}){this.busy=busy;this.onInstalled=onInstalled;this.followSource=followSource;this.onSettled=onSettled;this.idleWait=idleWait;this.checkEvery=checkEvery;this.state={status:'idle',log:[]};} get running(){return this.state.status==='running';} + // Simulations run on the old engine while a new one downloads or compiles; only the switch itself shuts them out. + get switching(){return this.running&&this.state.phase==='switch';} async sourceOnly(){return !windows||!!(await this.followSource().catch(()=>false));} log(line){this.state.log.push(`${new Date().toLocaleTimeString('en-GB')} ${line}`);this.state.log=this.state.log.slice(-200);this.state.step=line;} + // Where a running update is: its steps in order, the one running, and how far that one has come (`progress`). + plan(kind){this.state.phases=phasePlans[kind]||phasePlans.data;} + phase(id){Object.assign(this.state,{phase:id,phaseStarted:new Date().toISOString(),progress:null});} async check(){ const [local,installedWow]=await Promise.all([readEngineMetadata(),import('./engine.mjs').then(m=>m.installedWowVersion())]); @@ -111,37 +121,41 @@ export class Updater{ const complete=dataFiles.every(name=>localData?.hashes?.[name]); const sourceOnly=await this.sourceOnly(); const decision=plan({installedWow,local:{...local,dataHash:complete?localData?.contentHash:null},head,nightly,live,sourceOnly}); + this.noteAvailable({checkedAt:new Date().toISOString(),decision,behind,live}); return this.state.check={checkedAt:new Date().toISOString(),sourceOnly,installedWow,local:{version:local.version,wowVersion:local.wowVersion,commit:local.commit,behind,source:['nightly','source'].includes(local.source)?local.source:'source',dataBuild:localData?.wowBuild},head,nightly:nightly&&{name:nightly.name,url:nightly.url,sha:nightly.sha,date:nightly.date,...nightly.build},live:live&&{wowBuild:live.wowBuild,contentHash:live.contentHash},decision,errors}; } // `sha` pins one SimC commit: the official build of that commit when there is one, else that commit built from // source. For comparing with a result made on a known commit (Bloodmallet parity); an ordinary update never pins. - // At start, when the user has it on: install a newer official build or new game data by itself, and when this - // install follows the latest commit, build it from source (the user chose that; only what changed is compiled). - // The check's reason is kept so the engine panel can say what happened. - async startup(){ - if(this.state.status==='running'||this.busy())return null; - const check=await this.check(); - const action=check.decision.action; - this.state.startup={checkedAt:check.checkedAt,action,reason:check.decision.reason,behind:check.local.behind??null}; - if(!['nightly','data',...(check.sourceOnly?['source']:[])].includes(action))return null; - this.log('Automatic update at start …'); - return this.start('auto'); + // SimC Lab never installs SimC by itself: an update the user did not ask for takes the engine away in the middle + // of their work. `watch()` only looks, at start and every `checkEvery`, and `state.available` tells the page that + // something newer is out; installing it is the user's click. + async watch(){ + const look=async()=>{if(!this.running)await this.check().catch(e=>{this.state.lastCheck={checkedAt:new Date().toISOString(),error:e.message};});}; + clearInterval(this.timer);this.timer=setInterval(look,this.checkEvery);this.timer.unref?.(); + await look(); + } + noteAvailable({checkedAt,decision,behind,live}){ + const newer=['nightly','source','data'].includes(decision.action); + this.state.lastCheck={checkedAt,action:decision.action,reason:decision.reason,behind:behind??null}; + // `key` names what is on offer, so the page asks once per new build rather than at every check. + this.state.available=newer?{action:decision.action,reason:decision.reason,checkedAt,behind:behind??null,version:decision.engine?.version||null,wowVersion:decision.engine?.wowVersion||live?.wowBuild||null,commit:decision.engine?.sha||null,date:decision.engine?.date||null,key:`${decision.action}:${decision.engine?.sha||live?.contentHash||''}`}:null; } start(mode='auto',sha=null){ if(sha!==null&&!/^[0-9a-f]{7,40}$/i.test(String(sha)))throw new Error('A SimC commit is 7 to 40 hexadecimal characters.'); if(this.state.status==='running')throw new Error('An update is already running.'); - if(this.busy())throw new Error('Wait for running simulations to finish before updating SimC.'); if(!['auto','nightly','source','rebuild','data'].includes(mode))throw new Error('Unknown update mode.'); if(mode==='nightly'&&!windows)throw new Error('Official SimC builds are Windows only. Use Build from source.'); - this.state={status:'running',mode,...(this.state.startup?{startup:this.state.startup}:{}),...(sha?{sha}:{}),log:[],started:new Date().toISOString()}; - this.run(mode,sha?.toLowerCase()||null).then(result=>Object.assign(this.state,{status:'complete',result,finished:new Date().toISOString()}),e=>{this.log(`Failed: ${e.message}`);Object.assign(this.state,{status:'failed',error:e.message,finished:new Date().toISOString()});}) - .then(()=>this.onSettled(this.state)).catch(()=>{}); + const {available,lastCheck}=this.state; + this.state={status:'running',mode,...(sha?{sha}:{}),available,lastCheck,phases:['check'],log:[],started:new Date().toISOString()}; + this.run(mode,sha?.toLowerCase()||null).then(result=>Object.assign(this.state,{status:'complete',result,finished:new Date().toISOString(),...(result.changed?{available:null}:{})}),e=>{this.log(`Failed: ${e.message}`);Object.assign(this.state,{status:'failed',failedPhase:this.state.phase,error:e.message,finished:new Date().toISOString()});}) + .then(()=>Object.assign(this.state,{phase:null,progress:null})).then(()=>this.onSettled(this.state)).catch(()=>{}); return this.state; } async run(mode,sha=null){ + this.phase('check'); this.log('Checking SimC, WoW and game data versions …'); const check=await this.check(); const local=await readEngineMetadata(); @@ -153,6 +167,7 @@ export class Updater{ if(decision.action==='wait')throw new Error(decision.reason); if(decision.action==='none')return {changed:false,reason:decision.reason}; const live=check.live;if(!live)throw new Error('Game data could not be reached.'); + this.plan(decision.engine?.kind||'data'); let installed=null; if(decision.engine){ installed=decision.engine.kind==='nightly'?await this.installNightly(decision.engine):await this.buildFromSource(decision.engine,{clean:mode==='rebuild'}); @@ -160,10 +175,13 @@ export class Updater{ } // A simulation that started while the engine was building keeps the engine it started on: nothing is switched // (or removed) until no job is running, so one job never mixes two SimC commits. + this.phase('wait'); if(this.busy()){ this.log('Waiting for running simulations to finish before switching to the new engine …'); while(this.busy())await new Promise(r=>setTimeout(r,this.idleWait)); } + // No await between the last idle check and this: from here new simulations wait until the switch is done. + this.phase('switch'); const staged=await this.downloadData(live,installed?.meta.wowVersion||local.wowVersion); // Engine and data switch together, so the app never pairs a new engine with old data. await swap(staged,upstreamDir); @@ -192,14 +210,14 @@ export class Updater{ // Compressed responses report the compressed length, so progress is shown only for large uncompressed files. const total=res.headers.get('content-encoding')?0:Number(res.headers.get('content-length'))||0;let done=0,last=0; if(total<20*1048576)this.log(`Downloading ${label} …`); - const counter=new Transform({transform:(chunk,_,cb)=>{done+=chunk.length;if(total>=20*1048576&&done-last>total/10){last=done;this.log(`${label}: ${Math.min(100,Math.round(100*done/total))}% of ${(total/1048576).toFixed(0)} MB`);}cb(null,chunk);}}); + const counter=new Transform({transform:(chunk,_,cb)=>{done+=chunk.length;if(total>=20*1048576)this.state.progress={label,done,total,unit:'bytes'};if(total>=20*1048576&&done-last>total/10){last=done;this.log(`${label}: ${Math.min(100,Math.round(100*done/total))}% of ${(total/1048576).toFixed(0)} MB`);}cb(null,chunk);}}); await pipeline(Readable.fromWeb(res.body),counter,createWriteStream(file)); } async installNightly(engine){ const work=await fs.mkdtemp(path.join(os.tmpdir(),'simc-lab-')); try{ - const archive=path.join(work,engine.name); + this.phase('download');const archive=path.join(work,engine.name); this.log(`Downloading ${engine.name} …`);await this.download(engine.url,archive,'SimC'); this.log('Unpacking …'); // Windows ships bsdtar, which reads 7z archives. @@ -226,7 +244,9 @@ export class Updater{ // The source checkout and the build folder are kept between updates: a new commit fetches and compiles only what // changed. `clean` empties the build folder first, for a build that starts from nothing. async buildFromSource(engine,{clean=false}={}){ + this.phase('tools'); const tools=windows?await this.toolchain():await this.linuxToolchain(); + this.phase('fetch'); const src=path.join(home,'source','simc'),build=path.join(home,'source','build'); if(clean){this.log('Clean build: removing the previous build folder …');await fs.rm(build,{recursive:true,force:true});} if(await exists(path.join(src,'.git'))){ @@ -247,11 +267,14 @@ export class Updater{ const sha=(await run(tools.git,['-C',src,'rev-parse','HEAD'],{windowsHide:true})).stdout.trim(); const info=parseBuildInfo(await fs.readFile(path.join(src,'SpellDataDump/build_info.txt'),'utf8')); if(!info)throw new Error('The SimC source is not a live build.'); - this.log('Configuring the build …'); + this.phase('configure');this.log('Configuring the build …'); // On Linux SimC is built with networking (libcurl), so the Armory import works as it does with a nightly. const networking=!windows&&tools.curl; await this.exec(tools.cmake,['-S',src,'-B',build,...(windows?['-A','x64']:['-DCMAKE_BUILD_TYPE=Release']),'-DBUILD_GUI=OFF','-DBUILD_TESTING=OFF',`-DSC_NO_NETWORKING=${networking?'OFF':'ON'}`]); - this.log('Compiling SimC. The first build takes 20–40 minutes …'); + // MSBuild names each file it compiles and make prints its share done: either tells how far the build has come. + // A build after an update compiles only what changed, so the file count is an upper bound. + this.phase('compile');this.compiled={done:0,total:await countSources(path.join(src,'engine'))}; + this.log('Compiling SimC. The first build takes 20–40 minutes; after an update only what changed is compiled …'); await this.exec(tools.cmake,['--build',build,'--config','Release','--parallel',String(buildJobs())]); const dir=path.join(enginesDir,`${info.version}-${sha.slice(0,7)}-source`); await fs.rm(dir,{recursive:true,force:true});await fs.mkdir(dir,{recursive:true}); @@ -292,13 +315,22 @@ export class Updater{ exec(command,args){return new Promise((resolve,reject)=>{ const child=spawn(command,args,{windowsHide:true,shell:false});let tail=''; - const capture=data=>{tail=(tail+data.toString()).slice(-6000);const line=data.toString().trim().split(/\r?\n/).pop();if(line)this.state.detail=line.slice(0,300);}; + const capture=data=>{tail=(tail+data.toString()).slice(-6000);const lines=data.toString().split(/\r?\n/);const line=lines.map(l=>l.trim()).filter(Boolean).pop();if(line)this.state.detail=line.slice(0,300);if(this.state.phase==='compile')this.compileProgress(lines);}; child.stdout.on('data',capture);child.stderr.on('data',capture); child.on('error',reject);child.on('close',code=>code===0?resolve():reject(new Error(`${path.basename(command)} failed (${code}). ${tail.slice(-1200)}`))); });} + compileProgress(lines){ + const c=this.compiled;if(!c)return; + for(const line of lines){ + const percent=line.match(/^\[\s*(\d+)%\]/); + if(percent){this.state.progress={label:'Compiling',percent:Number(percent[1]),unit:'percent'};continue;} + if(/^\s*[\w.+-]+\.(?:cpp|cc|c)\s*$/.test(line)){c.done++;this.state.progress={label:'Compiling',done:c.done,total:Math.max(c.total,c.done),unit:'files'};} + } + } + async verify(installed){ - this.log('Checking the new engine …'); + this.phase('verify');this.log('Checking the new engine …'); let output='';try{output=(await run(path.join(installed.dir,simcName),[],{windowsHide:true,timeout:120000,maxBuffer:1e7})).stdout;}catch(e){output=`${e.stdout||''}${e.stderr||''}`;} const info=parseBuildInfo(output); if(!info||info.version!==installed.meta.version||info.wowVersion!==installed.meta.wowVersion)throw new Error('The new SimC engine did not report the expected version.'); @@ -328,6 +360,12 @@ function metaFor(engine,dir){ return {version:engine.version,wowVersion:engine.wowVersion,hotfix:engine.hotfix,commit:engine.sha,commitDate:engine.date,branch:engine.branch||'midnight',builtAt:new Date().toISOString(),dir:path.relative(home,dir),repository:`https://github.com/${repo}`}; } const exists=p=>fs.access(p).then(()=>true,()=>false); +// The C++ files a full SimC build compiles. +async function countSources(dir){ + let n=0; + for(const entry of await fs.readdir(dir,{withFileTypes:true,recursive:true}).catch(()=>[]))if(entry.isFile()&&/\.(?:cpp|cc|c)$/.test(entry.name))n++; + return n; +} // SimC's compiler jobs take about 1.5 GB each, so a small machine is kept from running out of memory. function buildJobs(){ const set=Number(process.env.SIMC_LAB_BUILD_JOBS);if(Number.isInteger(set)&&set>0)return set; diff --git a/package-lock.json b/package-lock.json index 0d93d08..1022b2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.29.1", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.29.1", + "version": "1.30.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index a36c9c1..1e2ec38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.29.1", + "version": "1.30.0", "private": true, "type": "module", "scripts": { diff --git a/public/app.js b/public/app.js index 4baa70a..05834c6 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,10 @@ import {tankUI,signed} from '/tank.js'; import {engineUI} from '/engine.js'; import {activityUI,describeProgress,duration} from '/activity.js'; import {wowUI} from '/wow.js'; +// The Silvermoon dummies are always five, measured from a combat log beside them (lib/dummies.mjs). +const dummyStyles={ + SilvermoonDummies:'Five Cleave Training Dummies stacked together, 3,537,050 health each and level 90, as measured in Silvermoon. They cannot die and sit at about 1% health, so execute effects are up the whole fight. Every raid buff, target debuff, Bloodlust and consumable is turned off for this fight; turn on under Raid buffs & consumables what you had.' +}; import {armoryUI} from '/armory.js'; const $=s=>document.querySelector(s); const $$=s=>[...document.querySelectorAll(s)]; @@ -54,7 +58,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=$('#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}))};} +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}))};} 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`; @@ -69,6 +73,14 @@ function updateCount(){ if(mode==='weapons'){$('#run-summary').textContent='One tier list per specialization';$('#run-count').textContent='';weapons.count(request);} if(mode==='trinkets'){$('#run-summary').textContent='One trinket chart per specialization';$('#run-count').textContent='';trinkets.count(request);} } +const styleNames={SilvermoonDummies:'Silvermoon dummies'}; +function syncFightStyle(){ + const note=dummyStyles[$('#fight-style').value]; + $('#targets').disabled=!!note;$('#matrix').disabled=!!note;if(note)$('#targets').value=5; + $('#fight-style-hint').hidden=!note;$('#fight-style-hint').textContent=note||''; +} +// Choosing the dummies starts from nothing; what the user turns on afterwards is kept. +$('#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(){ try{notice('');$('#run').disabled=true;if(!['weapons','trinkets'].includes(mode)&&(!profile||importedText!==$('#profile').value))await importProfile();const data=request();const preview=await api('/api/preview',data);$('#run-summary').textContent=`Starting ${preview.total} runs …`;const job=await api('/api/jobs',data);activity.refresh();await watchJob(job.id);$('#results').scrollIntoView({behavior:'smooth',block:'start'});}catch(e){notice(e.message);}finally{$('#run').disabled=false;updateCount();} @@ -89,7 +101,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(scenario.style)} / ${scenario.targets} targets / ${job.settings.duration} sec

`; + html+=`

${esc(styleNames[scenario.style]||scenario.style)} / ${scenario.targets} targets / ${job.settings.duration} sec

`; 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/engine.js b/public/engine.js index f3bf5b6..61ad756 100644 --- a/public/engine.js +++ b/public/engine.js @@ -2,32 +2,36 @@ const $=s=>document.querySelector(s); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const when=iso=>iso?new Date(iso).toLocaleString('en-GB',{dateStyle:'medium',timeStyle:'short'}):'Unknown'; const kinds={nightly:'Official nightly build',source:'Built from source'}; +// The steps of an update (phasePlans in lib/updater.mjs), in the words the engine panel uses. +const phaseLabels={check:'Check versions',download:'Download SimC',tools:'Build tools',fetch:'Fetch SimC source',configure:'Configure build',compile:'Compile SimC',verify:'Test the new engine',wait:'Wait for running simulations',switch:'Game data and switch'}; +const clock=seconds=>{seconds=Math.max(0,Math.round(seconds));const h=Math.floor(seconds/3600),m=Math.floor(seconds%3600/60),s=seconds%60;return h?`${h}h ${String(m).padStart(2,'0')}m`:`${m}:${String(s).padStart(2,'0')}`;}; +const since=iso=>iso?(Date.now()-new Date(iso))/1000:0; +const store={get:k=>{try{return localStorage.getItem(k);}catch{return null;}},set:(k,v)=>{try{localStorage.setItem(k,v);}catch{}}}; -// Engine and app updates: one button installs the newest matching SimC and game data; the desktop app also -// updates itself through the preload bridge. +// Engine and app updates. SimC is installed only when the user clicks: the server looks for a newer build at start +// and every few hours, and this page says so in a banner (and, in the desktop app, a system notification). export function engineUI({api,notice}){ - let status=null,polling=null,settings={},startup=null; + let status=null,polling=null,settings={},state={}; api('/api/settings').then(s=>{settings=s;if(status)render(status);},()=>{}); - const startupNote=()=>startup?` Last start: ${esc(startup.reason)}`:''; const desktop=window.simcDesktop; - $('#workspace').insertAdjacentHTML('beforebegin',``); + $('#workspace').insertAdjacentHTML('beforebegin',` + `); $('.sidebar-bottom').insertAdjacentHTML('beforeend',''); + // Linux has no official builds, and a Windows install can choose to follow the latest commit: both build from source. + const follow=()=>(status?.app?.platform&&status.app.platform!=='win32')||!!settings.followLatestCommit; function render(s){ status=s;const e=s.engine; const linux=s.app?.platform&&s.app.platform!=='win32'; - // Linux has no official builds, and a Windows install can choose to follow the latest commit: both build from source. - const follow=linux||!!settings.followLatestCommit; - const side=document.querySelector('.sidebar-update');if(side)side.textContent=follow?'Update to latest commit':'Update SimC'; + const following=follow(); if(linux)$('#setup-panel p').textContent='SimC Lab needs the SimulationCraft engine and current game data. On Linux SimC is built from source (20–40 minutes the first time); Git, CMake, a C++ compiler and libcurl must be installed, and the en_US.UTF-8 locale (Ubuntu: sudo apt install git cmake build-essential libcurl4-openssl-dev && sudo locale-gen en_US.UTF-8).'; $('#engine-detail').innerHTML=`

Installed WoW
${esc(e.installed||'Not found')}

SimulationCraft
${esc(e.version||'Not installed')}${e.wowVersion?` · WoW ${esc(e.wowVersion)}`:''}
${esc(kinds[e.source]||'Built from source')}${e.commitDate?` · ${when(e.commitDate)}`:''}

Commit
${esc(e.commit||'—')}

-
${follow?'':''}
-

${follow?'Update to latest commit fetches the newest SimC commit on GitHub and compiles only what changed.':'Update SimC installs the newest official build (a day behind GitHub at most). Update to latest commit builds the newest GitHub commit from source instead.'}

- ${linux?'':`

Builds SimC from source whenever GitHub has a newer commit, instead of waiting for the next official build (a day at most). The first build takes 20–40 minutes; after that only what changed is compiled.

`} - ${s.app?.server?'':` -

${follow?'Checks GitHub and game data at every start, and builds a newer commit from source (only what changed is compiled).':'Checks the SimC nightly and game data at every start and installs them when they are newer.'}${startupNote()}

`} +
${following?'':''}
+

${following?'Update to latest commit fetches the newest SimC commit on GitHub and compiles only what changed.':'Update SimC installs the newest official build (a day behind GitHub at most). Update to latest commit builds the newest GitHub commit from source instead.'}

+

${autoNote()}

+ ${linux?'':`

Updates build the newest GitHub commit from source instead of waiting for the next official build (a day at most), and the notice comes for every new commit. The first build takes 20–40 minutes; after that only what changed is compiled.

`}
Advanced

${linux?'Build from source downloads the SimC source and compiles it with the system’s Git, CMake and C++ compiler. It needs about 8 GB and takes 20–40 minutes; SIMC_LAB_BUILD_JOBS limits the compiler jobs on a small machine.':'Build from source downloads the SimC source and compiles it. The first build installs Git, CMake and the Visual Studio C++ tools with winget, needs about 8 GB and takes 20–40 minutes.'}

Build from source (clean) empties the build folder and compiles all of SimC again: for when a build has gone wrong.

Install one exact SimC commit, to compare with a result made on it (Trinket Lab's Bloodmallet parity check). It must be for the WoW build you have installed; the next ordinary update moves on again.

@@ -35,6 +39,66 @@ export function engineUI({api,notice}){ const missing=!e.ready||!!s.loadError; $('#setup-panel').hidden=!missing; $('.engine-details').open=missing||$('.engine-details').open; + apply(state); + } + function autoNote(){ + const c=state.lastCheck; + return 'SimC is only updated when you click. SimC Lab looks for a newer build at start and every 3 hours and tells you at the top of the page. Simulations keep running on the current engine while an update downloads or compiles; it is switched in when they are done.' + +(c?` Last check ${esc(when(c.checkedAt))}: ${esc(c.error||c.reason)}`:''); + } + function sidebar(){ + const side=document.querySelector('.sidebar-update');if(!side)return; + side.textContent=state.status==='running'?'Updating SimC …':`${follow()?'Update to latest commit':'Update SimC'}${state.available?' •':''}`; + side.classList.toggle('has-update',!!state.available&&state.status!=='running'); + } + + // A newer build: say what it is and offer it, once per build unless the user brings it back with Check. + function banner(){ + const a=state.available,el=$('#update-banner'); + const show=!!a&&state.status!=='running'&&store.get('simc-lab-update-dismissed')!==a.key&&!!status?.engine?.ready; + el.hidden=!show;if(!show)return; + const what=a.action==='data'?`New game data for WoW ${a.wowVersion||''}`:a.action==='source'?`A newer SimC commit${a.commit?` (${a.commit.slice(0,7)})`:''}`:`A newer official SimC build${a.version?` (${a.version}${a.commit?` · ${a.commit.slice(0,7)}`:''})`:''}`; + const behind=Number.isFinite(a.behind)&&a.behind>0?` Your SimC is ${a.behind} commit${a.behind===1?'':'s'} behind GitHub.`:''; + const cost=a.action==='source'?' It is built from source; after the first build only what changed is compiled.':a.action==='nightly'?' A download of a few minutes.':' A short download.'; + el.innerHTML=`
⇪

${esc(what)} is out

${esc(a.reason)}${behind}${cost} Your simulations keep running on the current engine until it is switched in.${a.date?` Published ${esc(when(a.date))}.`:''}

`; + // The desktop app also tells the system, once per build. + if(desktop&&'Notification' in window&&store.get('simc-lab-update-notified')!==a.key){store.set('simc-lab-update-notified',a.key);try{new Notification('SimC Lab',{body:`${what} is out. Open SimC Lab to update.`});}catch{}} + } + + // Where a running update is: every step in order with the current one marked, how long it has run, and its own + // progress where SimC or the download tells it (bytes, compiled files or make's percentage). + function progress(){ + const s=state,phases=s.phases||[],current=phases.indexOf(s.phase); + let html=''; + if(s.status==='running'||((s.status==='complete'||s.status==='failed')&&s.log?.length)){ + const failedAt=s.status==='failed'?Math.max(0,phases.indexOf(s.failedPhase)):-1; + const steps=phases.map((id,i)=>{ + const done=s.status==='complete'||(s.status==='running'&&i${done?'✓':i===failedAt?'!':i+1}${esc(phaseLabels[id]||id)}`; + }).join(''); + const p=s.progress;let bar='',part=''; + if(s.status==='running'&&p){ + const fraction=p.unit==='percent'?p.percent/100:p.total?p.done/p.total:0; + part=p.unit==='bytes'?`${(p.done/1048576).toFixed(0)} of ${(p.total/1048576).toFixed(0)} MB`:p.unit==='files'?`${p.done} of up to ${p.total} files compiled`:`${p.percent}%`; + bar=`
`; + } + const head=s.status==='running'?`Step ${Math.max(1,current+1)} of ${phases.length}: ${esc(phaseLabels[s.phase]||'Starting')} · ${clock(since(s.phaseStarted))} in this step · ${clock(since(s.started))} in all${part?` · ${part}`:''}` + :s.status==='complete'?`${esc(s.result?.changed?'Update installed.':s.result?.reason||'Nothing to install.')}${s.started&&s.finished?` · took ${clock((new Date(s.finished)-new Date(s.started))/1000)}`:''}` + :'The update failed.'; + const hint=s.status!=='running'?'':s.phase==='wait'?'The new engine is ready. It is switched in when the simulations running now are done.':s.phase==='switch'?'Switching engines: new simulations can start again in a moment.':'You can keep simulating meanwhile; jobs use the current engine.'; + html=`
    ${steps}
${head}
${bar}${hint?`

${hint}

`:''}${s.status==='failed'?`

${esc(s.error)}

`:''}
Log
${esc((s.log||[]).slice(-14).join('\n'))}${s.detail&&s.status==='running'?'\n'+esc(s.detail):''}
`; + } + document.querySelectorAll('[data-progress]').forEach(el=>el.innerHTML=html); + } + + function apply(next){ + const was=state.status;state=next||{}; + document.querySelectorAll('[data-update]').forEach(b=>b.disabled=state.status==='running'); + sidebar();banner();progress(); + const note=$('#engine-auto-note');if(note)note.innerHTML=autoNote(); + // A new engine changes the item, talent and loot data the page was built from. + if(was==='running'&&state.status==='complete'&&state.result?.changed)setTimeout(()=>location.reload(),1500); } async function check(){ @@ -48,32 +112,32 @@ export function engineUI({api,notice}){ if(c.live)lines.push(`Game data: WoW ${c.live.wowBuild}`); for(const err of c.errors)lines.push(err); target.innerHTML=lines.map(esc).join('
'); + // Checking by hand brings back a banner that was put off. + store.set('simc-lab-update-dismissed',''); + apply(await api('/api/engine/update')); }catch(err){target.textContent=err.message;} } async function start(mode,sha){ try{await api('/api/engine/update',{mode,...(sha?{sha}:{})});}catch(err){notice(err.message);return;} - document.querySelectorAll('[data-update]').forEach(b=>b.disabled=true); poll(); } async function poll(){ clearTimeout(polling); - let state;try{state=await api('/api/engine/update');}catch{polling=setTimeout(poll,2000);return;} - const html=`
${esc((state.log||[]).slice(-12).join('\n'))}${state.detail&&state.status==='running'?'\n'+esc(state.detail):''}
${state.status==='failed'?`

${esc(state.error)}

`:''}`; - document.querySelectorAll('[data-progress]').forEach(el=>el.innerHTML=html); - if(state.status==='running'){polling=setTimeout(poll,1000);return;} - document.querySelectorAll('[data-update]').forEach(b=>b.disabled=false); - // A new engine changes the item, talent and loot data the page was built from. - if(state.status==='complete'&&state.result?.changed)setTimeout(()=>location.reload(),1200); + let next;try{next=await api('/api/engine/update');}catch{polling=setTimeout(poll,2000);return;} + apply(next); + // Every second while an update runs; otherwise once a minute, to show what the server's own check found (and + // sooner until its first check at start is done). + polling=setTimeout(poll,state.status==='running'?1000:state.lastCheck?60000:5000); } document.addEventListener('change',async event=>{ - const key={'auto-update-simc':'autoUpdateSimc','follow-latest-commit':'followLatestCommit'}[event.target.id]; - if(!key)return; - try{settings=await api('/api/settings',{[key]:event.target.checked});if(key==='followLatestCommit'&&status)render(status);}catch(err){notice(err.message);event.target.checked=!event.target.checked;} + if(event.target.id!=='follow-latest-commit')return; + try{settings=await api('/api/settings',{followLatestCommit:event.target.checked});if(status)render(status);}catch(err){notice(err.message);event.target.checked=!event.target.checked;} }); document.addEventListener('click',event=>{ const update=event.target.closest('[data-update]');if(update){start(update.dataset.update);return;} + if(event.target.closest('[data-dismiss-update]')){if(state.available)store.set('simc-lab-update-dismissed',state.available.key);banner();return;} if(event.target.closest('[data-check]'))check(); if(event.target.closest('[data-pin]')){const sha=$('#engine-pin').value.trim();if(sha)start('source',sha);} if(event.target.closest('[data-app-check]'))desktop.checkForUpdates(); @@ -84,7 +148,8 @@ export function engineUI({api,notice}){ target.textContent=update.message; $('[data-app-install]').hidden=update.state!=='downloaded'; }); - // Resume the view of an update that was running when the page loaded. - api('/api/engine/update').then(state=>{startup=state.startup||null;if(status)render(status);if(state.status==='running'){document.querySelectorAll('[data-update]').forEach(b=>b.disabled=true);poll();}},()=>{}); + // The step clock keeps moving between polls. + setInterval(()=>{if(state.status==='running')progress();},1000); + poll(); return {render,missing:()=>!status?.engine.ready||!!status?.loadError}; } diff --git a/public/environment.js b/public/environment.js index 1c1f1c2..6b0c274 100644 --- a/public/environment.js +++ b/public/environment.js @@ -6,6 +6,8 @@ export function environmentUI({api,notice,addVariant}){ const categories=[['food','Food'],['flask','Flask'],['potion','Potion'],['augmentation','Augment rune'],['main_hand_oil','Main-hand oil / stone'],['off_hand_oil','Off-hand oil / stone']]; $('#results').insertAdjacentHTML('beforebegin',`

Raid buffs & consumables

All simulation modes

Choose the conditions your character will fight in. These settings apply to the baseline and every alternative.

Loading live options…

`); function preset(enabled){document.querySelectorAll('[data-buff]').forEach(el=>el.checked=enabled);$('#buff-preset-label').textContent=enabled?'Optimal raid buffs':'No external raid buffs';} + // Nothing at all: no raid buffs, target debuffs or Bloodlust, and no consumables. The Silvermoon dummies start here. + function bare(){if(!options)return;preset(false);document.querySelectorAll('[data-consumable]').forEach(el=>el.value='none');document.querySelectorAll('[data-pick-value]').forEach(el=>el.classList.remove('selected'));$('#buff-preset-label').textContent='No buffs or consumables';} async function init(){try{ options=await api('/api/options'); $('#environment-content').innerHTML=`
Optimal raid buffs
Customize raid buffs and target debuffs

Disabling an override removes the assumed external buff. Your character can still provide its own buffs through its action list.

${['Raid buffs','Target debuffs'].map(group=>`

${group}

${options.buffs.filter(b=>b.group===group).map(b=>``).join('')}
`).join('')}

“From profile / SimC” preserves imported consumables or uses the class defaults. “None” disables that consumable. Explicit choices override every build.

${categories.map(([key,label])=>{const items=options.consumables[key.endsWith('_oil')?'temporary_enchant':key]||[];return `

${label}

Browse choices
${items.map(c=>`
${c.itemId?itemLink(c.itemId,c.name||c.shortName):esc(c.name||c.shortName)}
`).join('')}
`;}).join('')}
Advanced encounter settings

0% gives a fixed duration; 20% uses SimC’s usual ±20% variation. Detailed HTML, JSON and exact SimC input are included with every completed run.

`; @@ -19,5 +21,5 @@ export function environmentUI({api,notice,addVariant}){ }); $('#reset-environment').onclick=()=>{preset(true);$('#lust-mode').value='pull';$('#lust-mode').onchange();$('#fight-variation').value=20;document.querySelectorAll('[data-consumable]').forEach(el=>el.value='profile');highlight();}; }catch(e){$('#environment-content').textContent=e.message;notice(e.message);}} - return {options:()=>options,init,settings(){return options?{buffs:Object.fromEntries([...document.querySelectorAll('[data-buff]')].map(el=>[el.dataset.buff,el.checked])),consumables:Object.fromEntries([...document.querySelectorAll('[data-consumable]')].map(el=>[el.dataset.consumable,el.value])),bloodlust:{mode:$('#lust-mode').value,value:Number($('#lust-value').value)},variation:Number($('#fight-variation').value)}:{};}}; + return {options:()=>options,init,bare,settings(){return options?{buffs:Object.fromEntries([...document.querySelectorAll('[data-buff]')].map(el=>[el.dataset.buff,el.checked])),consumables:Object.fromEntries([...document.querySelectorAll('[data-consumable]')].map(el=>[el.dataset.consumable,el.value])),bloodlust:{mode:$('#lust-mode').value,value:Number($('#lust-value').value)},variation:Number($('#fight-variation').value)}:{};}}; } diff --git a/public/index.html b/public/index.html index a1db576..57f4c97 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/style.css b/public/style.css index 6aa1aff..2985d10 100644 --- a/public/style.css +++ b/public/style.css @@ -129,3 +129,4 @@ .trinket-run .three-col{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px} @media(max-width:900px){.trinket-run .three-col{grid-template-columns:1fr 1fr}} .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}} diff --git a/server.mjs b/server.mjs index d6044d4..00f7a5d 100644 --- a/server.mjs +++ b/server.mjs @@ -100,7 +100,7 @@ const server=http.createServer(async(req,res)=>{ const slot=url.searchParams.get('slot'),info={class:url.searchParams.get('class'),spec:url.searchParams.get('spec'),level:url.searchParams.get('level')??90};if(!slotTypes[slot])return json(res,400,{error:'Select an equipment slot.'}); const q=(url.searchParams.get('q')||'').toLowerCase();const result=catalog.currentItems.filter(i=>fitsSlot(i,slot,info)&&(i.name.toLowerCase().includes(q)||String(i.id)===q)).slice(0,100).map(i=>({id:i.id,name:i.name,itemLevel:i.itemLevel,expansion:i.expansion}));return json(res,200,result); } - if(req.method==='POST'&&route==='/api/armory'){if(updater.state.status==='running')throw new Error('Wait for the SimC update to finish.');const {region,realm,name,url:link}=await body(req);return json(res,200,await importArmory({region,realm,name,url:link},{executable:engine.executable}));} + if(req.method==='POST'&&route==='/api/armory'){if(updater.switching)throw new Error('SimC is switching to the new engine. Try again in a moment.');const {region,realm,name,url:link}=await body(req);return json(res,200,await importArmory({region,realm,name,url:link},{executable:engine.executable}));} if(req.method==='POST'&&route==='/api/import'){ const text=(await body(req)).profile;const p=parseProfile(text);p.upgradeState=readUpgradeState(text); for(const item of Object.values(p.gear)){item.item=catalog.items.get(item.id)||null;item.enchants=catalog.forItem(item.id,p.info.class);} @@ -125,7 +125,7 @@ const server=http.createServer(async(req,res)=>{ const trinkets=plan.trinkets&&{specs:plan.trinkets.specs.length,trinkets:plan.trinkets.trinkets,candidates:plan.trinkets.candidates,skipped:plan.trinkets.skipped,tanks:plan.trinkets.specs.filter(s=>s.tank).length,sources:plan.trinkets.sources,levels:plan.trinkets.levels,steps:trinketSteps(plan.trinkets,plan.scenarios.length),model:plan.trinkets.model,estimate:plan.trinkets.estimate,screened:plan.trinkets.specs.filter(s=>new Set(s.candidates.map(c=>c.itemId)).size>plan.trinkets.finalists).length}; return json(res,200,{trinkets,variants:plan.variants.map(v=>({name:v.name,baseline:!!v.baseline})),total:trinkets?trinkets.steps:weapons?weapons.steps:crests||vault?plan.scenarios.length:upgrade?upgrade.steps:plan.variants.length*plan.scenarios.length,warnings:plan.profile.warnings,search:plan.search,upgrade,crests,vault,weapons}); } - if(req.method==='POST'&&route==='/api/jobs'){ready();if(updater.state.status==='running')throw new Error('Wait for the SimC update to finish.');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==='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 jobRoute=route.match(/^\/api\/jobs\/([\da-f-]{36})(\/cancel)?$/); @@ -182,6 +182,6 @@ const server=http.createServer(async(req,res)=>{ }catch(e){json(res,e.code==='ENOENT'?404:400,{error:e.message});} }); server.listen(port,'127.0.0.1',()=>console.log(`SimC Lab: http://127.0.0.1:${port}`)); -// Keep SimC on the newest official build when the user wants it: checked once at every start. -loadSettings().then(s=>s.autoUpdateSimc?updater.startup():null).catch(e=>console.error('Automatic SimC update failed:',e.message)); +// Look for a newer SimC build at start and every few hours. Nothing is installed without the user's click. +updater.watch().catch(e=>console.error('SimC update check failed:',e.message)); for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>{jobs.child?.kill();server.close();process.exit(0);}); diff --git a/tests/dummies.test.mjs b/tests/dummies.test.mjs new file mode 100644 index 0000000..5a1264a --- /dev/null +++ b/tests/dummies.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inputFor,fightStyles,playerActorIndex,scenarioLines} from '../lib/engine.mjs'; +import {silvermoon,dummyLines,dummyScenario,isDummyStyle} from '../lib/dummies.mjs'; + +const settings={iterations:10000,targetError:0.1,duration:300,threads:4,environment:null,tank:null}; +const paths={json:'C:\\runs\\000.json',html:'C:\\runs\\000.html'}; +const profile='warrior="Me"\nspec=fury\nlevel=90'; + +test('the Silvermoon dummies are the five measured in the city: level 90, never dying, held in execute range',()=>{ + assert.ok(fightStyles.includes('SilvermoonDummies'));assert.ok(!isDummyStyle('Patchwerk')); + assert.equal(silvermoon.count,5);assert.equal(silvermoon.health,3537050); + const lines=dummyLines('SilvermoonDummies'); + assert.equal(lines[0],'target_level+=0','same level as the player, not a +3 boss'); + assert.equal(lines.filter(l=>l.startsWith('enemy=')).length,5,'every dummy declared, so each gets the options'); + assert.equal(lines.filter(l=>l==='enemy_fixed_health_percentage=1').length,5,'held at about 1%'); + assert.ok(!lines.some(l=>l.startsWith('enemy_health')),'a fixed health would end the fight when the first dummy reached 0'); + assert.equal(lines.filter(l=>l==='actions=wait,sec=10').length,5,'city dummies never attack'); + assert.deepEqual(dummyLines('Patchwerk'),[]); +}); + +test('dummy input: enemies before the player, no fight style, five targets, buffs left to the environment',()=>{ + const scenario={style:'SilvermoonDummies',...dummyScenario}; + const input=inputFor({text:profile},settings,scenario,paths); + // The page starts the dummies with everything off; whatever the user turns back on must reach SimC. + assert.equal(scenarioLines(scenario),'','the scenario forces nothing off'); + const env={buffs:{arcane_intellect:false,battle_shout:false,power_word_fortitude:false,mark_of_the_wild:false,skyfury:false,blessing_of_the_bronze:false,chaos_brand:false,mystic_touch:false,hunters_mark:true,bleeding:false,mortal_wounds:false,bloodlust:true},variation:20,bloodlust:{mode:'pull',value:0},consumables:{potion:'none'}}; + const chosen=inputFor({text:profile},{...settings,environment:env},scenario,paths); + assert.match(chosen,/override\.hunters_mark=1\n/);assert.match(chosen,/override\.bloodlust=1\n/);assert.match(chosen,/override\.mystic_touch=0\n/);assert.match(chosen,/potion=disabled\n/); + assert.doesNotMatch(chosen,/override\.bloodlust=0/); + assert.ok(input.startsWith('target_level+=0\nenemy=Cleave_Training_Dummy_1\n')); + assert.ok(input.indexOf('enemy=Cleave_Training_Dummy_5'){ +test('app settings: the nightly is followed by default, a choice is kept, and the old auto-update setting is gone',async()=>{ process.env.SIMC_LAB_HOME=await fs.mkdtemp(path.join(os.tmpdir(),'simclab-settings-')); const {loadSettings,saveSettings}=await import('../lib/settings.mjs?home='+Date.now()); - assert.deepEqual(await loadSettings(),{autoUpdateSimc:true,followLatestCommit:false},'follow the nightly unless chosen'); - assert.deepEqual(await saveSettings({autoUpdateSimc:false}),{autoUpdateSimc:false,followLatestCommit:false}); - assert.deepEqual(await loadSettings(),{autoUpdateSimc:false,followLatestCommit:false}); + assert.deepEqual(await loadSettings(),{followLatestCommit:false},'follow the nightly unless chosen'); assert.equal((await saveSettings({followLatestCommit:true})).followLatestCommit,true); - await assert.rejects(saveSettings({autoUpdateSimc:'yes'}),/Invalid value/); - await assert.rejects(saveSettings({other:true}),/Unknown setting/); + // A settings file from before 1.30.0 still says autoUpdateSimc; it no longer means anything. + await fs.writeFile(path.join(process.env.SIMC_LAB_HOME,'data','settings.json'),JSON.stringify({autoUpdateSimc:true,followLatestCommit:true})); + assert.deepEqual(await loadSettings(),{followLatestCommit:true}); + assert.deepEqual(await saveSettings({followLatestCommit:false}),{followLatestCommit:false}); + await assert.rejects(saveSettings({autoUpdateSimc:false}),/Unknown setting/); + await assert.rejects(saveSettings({followLatestCommit:'yes'}),/Invalid value/); }); diff --git a/tests/updater.test.mjs b/tests/updater.test.mjs index b3e4bf4..8ab7dba 100644 --- a/tests/updater.test.mjs +++ b/tests/updater.test.mjs @@ -63,20 +63,44 @@ test('an installed engine newer than the nightly is kept for the current WoW bui assert.equal(plan({...args,local:{...local,wowVersion:'12.1.0.69814'},live:live('12.1.0.69875')}).action,'nightly','a WoW patch replaces the engine'); }); -test('at start, only an official build or new game data is installed by itself',async()=>{ +test('SimC is never installed by itself: the watch only checks, and says what newer build is out',async()=>{ const {Updater}=await import('../lib/updater.mjs'); - for(const [action,started] of [['nightly',true],['data',true],['source',false],['none',false],['wait',false]]){ - const u=new Updater({busy:()=>false,onInstalled:async()=>{}}); - let mode=null; - u.check=async()=>({checkedAt:'t',local:{behind:3},decision:{action,reason:`r-${action}`}}); - u.start=m=>{mode=m;return {status:'running'};}; - await u.startup(); - assert.equal(mode!==null,started,action); - assert.equal(u.state.startup.reason,`r-${action}`); + const u=new Updater({busy:()=>false,onInstalled:async()=>{},checkEvery:5}); + let checks=0,started=false; + u.check=async()=>{checks++;return {};};u.start=()=>{started=true;};u.run=async()=>{started=true;}; + assert.equal(u.startup,undefined,'no install at start'); + await u.watch();await new Promise(r=>setTimeout(r,30));clearInterval(u.timer); + assert.ok(checks>=2,'checked at start and again later');assert.equal(started,false); + for(const [action,offered] of [['nightly',true],['source',true],['data',true],['none',false],['wait',false]]){ + u.noteAvailable({checkedAt:'t',behind:3,live:{wowBuild:'12.1.0.1',contentHash:'h9'},decision:{action,reason:`r-${action}`,engine:action==='nightly'||action==='source'?{sha:'abc1234',version:'1210-02',wowVersion:'12.1.0.1',date:'d'}:null}}); + assert.equal(!!u.state.available,offered,action);assert.equal(u.state.lastCheck.reason,`r-${action}`); } - const busy=new Updater({busy:()=>true,onInstalled:async()=>{}}); - busy.check=async()=>{throw new Error('must not check while simulations run');}; - assert.equal(await busy.startup(),null); + u.noteAvailable({checkedAt:'t',behind:3,decision:{action:'nightly',reason:'r',engine:{sha:'abc1234',version:'1210-02'}}}); + assert.deepEqual([u.state.available.key,u.state.available.version,u.state.available.behind],['nightly:abc1234','1210-02',3],'one key per build, so the page asks once'); + u.noteAvailable({checkedAt:'t',live:{contentHash:'h9'},decision:{action:'data',reason:'r',engine:null}}); + assert.equal(u.state.available.key,'data:h9'); +}); + +test('simulations may start during an update, except while the engine is switched',async()=>{ + const {Updater}=await import('../lib/updater.mjs'); + const u=new Updater({busy:()=>true,onInstalled:async()=>{}}); + u.run=()=>new Promise(()=>{}); + u.state.available={key:'nightly:x'}; + u.start('auto'); + assert.equal(u.running,true,'an update starts while a simulation runs'); + assert.equal(u.state.available.key,'nightly:x','the notice survives the start'); + for(const [phase,switching] of [['check',false],['compile',false],['wait',false],['switch',true]]){u.phase(phase);assert.equal(u.switching,switching,phase);} + u.plan('source');assert.deepEqual(u.state.phases,['check','tools','fetch','configure','compile','verify','wait','switch']); +}); + +test('compile progress comes from MSBuild file names or make percentages',async()=>{ + const {Updater}=await import('../lib/updater.mjs'); + const u=new Updater({busy:()=>false,onInstalled:async()=>{}}); + u.compiled={done:0,total:400}; + u.compileProgress([' sc_warrior.cpp',' sc_enemy.cpp','Generating Code...','simc.vcxproj -> C:\\build\\simc.exe']); + assert.deepEqual(u.state.progress,{label:'Compiling',done:2,total:400,unit:'files'}); + u.compileProgress(['[ 45%] Building CXX object engine/CMakeFiles/engine.dir/sim/sim.cpp.o']); + assert.deepEqual(u.state.progress,{label:'Compiling',percent:45,unit:'percent'}); }); test('where there are no official builds (Linux), a newer commit on GitHub is built from source',()=>{ @@ -98,12 +122,6 @@ test('update modes: latest commit skips what is installed, a clean build does no assert.equal(await new Updater({busy:()=>false,onInstalled:async()=>{},followSource:async()=>true}).sourceOnly(),true); const plain=await new Updater({busy:()=>false,onInstalled:async()=>{}}).sourceOnly(); assert.equal(plain,process.platform!=='win32'); - // At start, following installs compile a newer commit; others never do. - for(const [sourceOnly,started] of [[true,true],[false,false]]){ - const s=new Updater({busy:()=>false,onInstalled:async()=>{}});let mode=null; - s.check=async()=>({checkedAt:'t',sourceOnly,local:{behind:3},decision:{action:'source',reason:'r'}});s.start=m=>{mode=m;return {};}; - await s.startup();assert.equal(mode!==null,started); - } }); test('following the latest commit on Windows builds GitHub head even when a newer nightly exists',()=>{
VariantDPS / 95 %vs baselineSurvivalScoreReport