From 409cf4d1ad33539d7726e50da798da8e46141823 Mon Sep 17 00:00:00 2001 From: Roburmaster <25195137+Roburmaster@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:17:00 +0200 Subject: [PATCH] 1.31.0: Silvermoon dummies can fall from 100% at your own pace; buffs as check boxes Dummy health is now a choice: held at 1% (busy city dummies) or from 100%. From 100%, four short calibration runs hold the dummies in each health band (above 80%, above 35%, above 20%, execute) and read the damage each dummy takes per second (role=tank makes SimC report it); every dummy then gets its own health timeline from 100% to 1%, where it stays. The result shows when each one reached 1%. Simulation settings get the raid buffs and target debuffs as check boxes, kept in step with the environment panel. Co-Authored-By: Claude Opus 5.5 --- desktop/package.json | 2 +- lib/dummies.mjs | 53 ++++++++++++++++++++++++++++++++++++++---- lib/engine.mjs | 32 ++++++++++++++++++++----- package-lock.json | 4 ++-- package.json | 2 +- public/app.js | 8 +++---- public/environment.js | 12 +++++++--- public/index.html | 2 +- public/style.css | 1 + tests/dummies.test.mjs | 34 +++++++++++++++++++++++---- 10 files changed, 122 insertions(+), 28 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 4c84532..7e30c73 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.30.0", + "version": "1.31.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 index a940113..6f66578 100644 --- a/lib/dummies.mjs +++ b/lib/dummies.mjs @@ -12,16 +12,59 @@ // 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. +// +// Health has two modes: +// - 'held': at 1% the whole fight, as the busy city dummies were; +// - 'falling': each dummy starts at 100% and the player's own damage takes it down to 1%, where it stays because it +// cannot die. SimC cannot do that directly (real health ends the fight when the first target dies, and the other +// four would die at 0), so it is measured: short calibration runs hold the dummies in each health band that +// changes a rotation (above 80%, above 35%, above 20%, execute) and read how much damage each dummy takes per +// second there (SimC reports damage taken only for tanks, hence role=tank, which changes nothing else). From those +// rates every dummy gets its own health timeline, in shares of the fight as SimC counts it. 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 dummyStyles={SilvermoonDummies:{label:'Silvermoon dummies'}}; export const isDummyStyle=style=>Object.hasOwn(dummyStyles,style); +export const dummyHealthModes=['held','falling']; +// The bands a falling dummy passes through, and the health it is held at to measure each one. +export const bands=[{from:100,to:80,at:90},{from:80,to:35,at:50},{from:35,to:20,at:30},{from:20,to:silvermoon.heldAt,at:silvermoon.heldAt}]; -export function dummyLines(style){ +export function normalizeDummyHealth(input){ + const mode=input?.mode??'held'; + if(!dummyHealthModes.includes(mode))throw new Error('Choose how the dummies’ health behaves.'); + return {mode}; +} +export const needsCalibration=scenario=>isDummyStyle(scenario?.style)&&scenario.dummyHealth?.mode==='falling'&&!scenario.dummyHealth.timelines; + +// One dummy's timeline from its damage taken per second in each band (`rates`, in bands order) over `duration`. +// Returns the SimC option value and the second it reaches 1% (null when the fight ends first). +export function fallTimeline(rates,duration){ + const points=[[100,0]];let t=0,reached=null; + for(const [i,band] of bands.entries()){ + const rate=Math.max(1,rates[i]);const span=(band.from-band.to)/100*silvermoon.health/rate; + if(t+span>=duration){points.push([band.from-(band.from-band.to)*(duration-t)/span,1]);t=duration;break;} + t+=span;points.push([band.to,t/duration]); + if(band.to===silvermoon.heldAt)reached=t; + } + // The last point must be at 1.0, or SimC adds one at 0% health. + if(points.at(-1)[1]<1)points.push([silvermoon.heldAt,1]); + return {timeline:points.map(([h,s])=>`${Number(h.toFixed(2))}:${Number(s.toFixed(4))}`).join('/'),reached:reached===null?null:Math.round(reached*10)/10}; +} +// From the calibration reports (one per band, in bands order): every dummy's timeline. +export function calibrate(reports,duration){ + const perBand=reports.map(r=>r.sim.targets.slice(0,silvermoon.count).map(t=>(t.collected_data?.dtps?.mean||0)/Math.max(1,t.collected_data?.fight_length?.mean||duration))); + const fits=Array.from({length:silvermoon.count},(_,i)=>fallTimeline(perBand.map(b=>b[i]),duration)); + return {mode:'falling',timelines:fits.map(f=>f.timeline),reached:fits.map(f=>f.reached),rates:perBand.map(b=>b.map(Math.round))}; +} + +function healthLines(health,i){ + if(health?.calibrate!==undefined)return [`enemy_fixed_health_percentage=${health.calibrate}`,'role=tank']; + if(health?.mode==='falling'&&health.timelines)return [`enemy_custom_health_timeline=${health.timelines[i]}`]; + return [`enemy_fixed_health_percentage=${silvermoon.heldAt}`]; +} +export function dummyLines(style,health=null){ 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'); + for(let i=0;i8) 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); + scenarios=scenarios.filter((s,i)=>!isDummyStyle(s?.style)||scenarios.findIndex(o=>o?.style===s.style&&(o.duration??null)===(s.duration??null)&&(o.dummyHealth?.mode??'held')===(s.dummyHealth?.mode??'held'))===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); + Object.assign(s,dummyScenario,{dummyHealth:normalizeDummyHealth(s.dummyHealth)}); + // Falling health is measured on the imported character; the labs simulate SimC's reference profiles instead. + if(s.dummyHealth.mode==='falling'&&weapons)throw new Error('Falling dummy health is measured on your own character. Use held health in Weapon Lab and Trinket Lab.'); } s.targets=integer(s.targets,1,1,20,'Targets'); 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); + const key=`${s.style}-${s.targets}-${s.duration||''}-${s.dummyHealth?.mode||''}`; if(unique.has(key)) throw new Error('The same scenario was selected more than once.'); unique.add(key); } if(request.mode==='trinkets'){ if(!season)throw new Error('Season loot data is not loaded.'); @@ -165,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).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':''}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; @@ -197,7 +199,7 @@ export class Jobs { async add(plan,request){ if([...this.jobs.values()].filter(j=>['queued','running'].includes(j.status)).length>=5)throw new Error('The queue is full (maximum 5 jobs).'); const steps=plan.trinkets?trinketSteps(plan.trinkets,plan.scenarios.length):plan.weapons?weaponSteps(plan.weapons,plan.scenarios.length):plan.crests||plan.vault?plan.scenarios.length:(plan.upgrade?upgradeSteps(plan.upgrade,plan.scenarios.length):plan.variants.length*plan.scenarios.length); - const job={id:randomUUID(),name:plan.profile.info.name,mode:request.mode || 'quick',created:new Date().toISOString(),status:'queued',done:0,total:steps+(plan.settings.tank?1:0),engine:plan.engine,settings:plan.settings,scenarios:plan.scenarios,search:plan.search,results:[],log:'',warnings:plan.profile.warnings,armory:isArmoryProfile(request.profile)}; + const job={id:randomUUID(),name:plan.profile.info.name,mode:request.mode || 'quick',created:new Date().toISOString(),status:'queued',done:0,total:steps+(plan.settings.tank?1:0)+plan.scenarios.filter(needsCalibration).length,engine:plan.engine,settings:plan.settings,scenarios:plan.scenarios,search:plan.search,results:[],log:'',warnings:plan.profile.warnings,armory:isArmoryProfile(request.profile)}; if(plan.upgrade)Object.assign(job,{upgrade:{season:plan.upgrade.season,finalists:plan.upgrade.finalists,embellished:plan.upgrade.embellished,limitsUsed:plan.upgrade.limitsUsed,blocked:plan.upgrade.blocked,candidates:plan.upgrade.candidates.map(({line,...c})=>c),screen:screenSettings(plan.settings)},stages:[]}); if(plan.crests){const {candidates,...rest}=plan.crests;Object.assign(job,{crests:{...rest,candidates:candidates.map(({line,...c})=>c)},stages:[]});} if(plan.vault){const {candidates,...rest}=plan.vault;Object.assign(job,{vault:{...rest,candidates:candidates.map(({line,...c})=>c)},stages:[]});} @@ -228,6 +230,7 @@ export class Jobs { const plan=job._plan;const current=await engineStatus(); if(!current.ready || (current.installed && !current.compatible) || current.commit!==plan.engine.commit)throw new Error('SimC or WoW changed while the job was queued. Submit a new job.'); if(plan.settings.tank)plan.settings.tank.boss=await this.calibrateTank(job,plan.profile.text,plan.settings,'Calibrating the tank boss','tank'); + for(const [s,scenario] of plan.scenarios.entries())if(needsCalibration(scenario))scenario.dummyHealth=await this.calibrateDummyHealth(job,plan,s); if(plan.trinkets)await this.runTrinkets(job,plan); else if(plan.weapons)await this.runWeapons(job,plan); else if(plan.upgrade)await this.runUpgrades(job,plan); @@ -580,6 +583,23 @@ export class Jobs { } // The tank boss is calibrated once against the gear it will judge and then shared by every variant and scenario. // Weapon Lab calibrates one boss per tank specialization, because each reference profile has its own health pool. + // Falling dummy health: one short run per health band with the dummies held there, measuring how much damage each + // dummy takes per second; every dummy then gets its own timeline from 100% to 1% (see dummies.mjs). + async calibrateDummyHealth(job,plan,s){ + const dir=path.join(runsDir,job.id),scenario=plan.scenarios[s],duration=scenario.duration||plan.settings.duration; + const settings={...plan.settings,iterations:Math.min(plan.settings.iterations,1000),targetError:Math.max(plan.settings.targetError,0.5)}; + const reports=[]; + for(const band of dummyBands){ + if(job.status==='cancelled')throw new Error('Cancelled.'); + job.current={name:`Measuring how fast the dummies fall · ${band.from}–${band.to}%`,scenario,index:job.results.length};await this.save(this.public(job)); + const stem=`dummies-${s}-${band.at}`;const paths={json:path.join(dir,stem+'.json'),html:path.join(dir,stem+'.html')};const input=path.join(dir,stem+'.simc'); + await fs.writeFile(input,inputFor({text:plan.profile.text},settings,{...scenario,dummyHealth:{calibrate:band.at}},paths).replace(/^html=.*\n/m,'')); + await this.execute(input,dir,job);if(job.status==='cancelled')throw new Error('Cancelled.'); + reports.push(JSON.parse(await fs.readFile(paths.json,'utf8'))); + } + job.done++;await this.save(this.public(job)); + return calibrateDummies(reports,duration); + } async calibrateTank(job,text,base,label,prefix){ const dir=path.join(runsDir,job.id);let n=0; job.current={name:label,scenario:{style:'Patchwerk',targets:1},index:job.stages?.length||0};await this.save(this.public(job)); diff --git a/package-lock.json b/package-lock.json index 1022b2a..d6d0c64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.30.0", + "version": "1.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 1e2ec38..77e3ce2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.30.0", + "version": "1.31.0", "private": true, "type": "module", "scripts": { diff --git a/public/app.js b/public/app.js index 05834c6..a8e9a27 100644 --- a/public/app.js +++ b/public/app.js @@ -12,7 +12,7 @@ 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.' + SilvermoonDummies:'Five Cleave Training Dummies stacked together, 3,537,050 health each and level 90, as measured in Silvermoon. They cannot die. Held: they sit at 1% the whole fight, as busy dummies in the city do, so execute is always up. From 100%: four short runs first measure how fast your damage takes each dummy down, then each one falls from 100% to 1% at that pace and stays there. Every raid buff, target debuff, Bloodlust and consumable starts off; turn on under Buffs what you had.' }; import {armoryUI} from '/armory.js'; const $=s=>document.querySelector(s); @@ -58,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=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 request(){if(mode==='trinkets')return trinkets.request();const targets=Number($('#targets').value);const scenarioTargets=dummyStyles[$('#fight-style').value]?[5]:$('#matrix').checked?[...new Set([targets,3,5])]:[targets];return {profile:$('#profile').value,mode,enchants:selections,combine:$('input[name=combine]:checked').value==='true',talentSearch:features.settings(),upgrades:upgrades.settings(),crests:crests.settings(),vault:vault.settings(),weapons:weapons.settings(),trinkets:trinkets.settings(),tank:tank.settings(),environment:environment.settings(),variants,iterations:Number($('#iterations').value),targetError:Number($('#target-error').value),duration:Number($('#duration').value),threads:Number($('#threads').value),scenarios:scenarioTargets.map(n=>({style:$('#fight-style').value,targets:n,...(dummyStyles[$('#fight-style').value]?{dummyHealth:{mode:$('#dummy-health').value}}:{})}))};} function 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`; @@ -76,7 +76,7 @@ function updateCount(){ const styleNames={SilvermoonDummies:'Silvermoon dummies'}; function syncFightStyle(){ const note=dummyStyles[$('#fight-style').value]; - $('#targets').disabled=!!note;$('#matrix').disabled=!!note;if(note)$('#targets').value=5; + $('#targets').disabled=!!note;$('#matrix').disabled=!!note;if(note)$('#targets').value=5;$('#dummy-health-label').hidden=!note; $('#fight-style-hint').hidden=!note;$('#fight-style-hint').textContent=note||''; } // Choosing the dummies starts from nothing; what the user turns on afterwards is kept. @@ -101,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(styleNames[scenario.style]||scenario.style)} / ${scenario.targets} targets / ${job.settings.duration} sec

`; + html+=`

${esc(styleNames[scenario.style]||scenario.style)} / ${scenario.targets} targets / ${job.settings.duration} sec${scenario.dummyHealth?.mode==='falling'?' / dummies from 100%':scenario.dummyHealth?' / dummies held at 1%':''}

${scenario.dummyHealth?.reached?`

Your damage took the dummies to 1% after ${scenario.dummyHealth.reached.map(r=>r===null?'—':r.toFixed(0)+' s').join(', ')} (the first is your main target).

`:''}`; 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/environment.js b/public/environment.js index 6b0c274..7807c1d 100644 --- a/public/environment.js +++ b/public/environment.js @@ -5,15 +5,21 @@ export function environmentUI({api,notice,addVariant}){ let options=null; 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';} + function preset(enabled){document.querySelectorAll('[data-buff]').forEach(el=>el.checked=enabled);$('#buff-preset-label').textContent=enabled?'Optimal raid buffs':'No external raid buffs';mirror();} + // The same buffs as check boxes in Simulation settings, beside the fight they belong to; both lists stay in step. + function mirror(){document.querySelectorAll('[data-buff-mirror]').forEach(el=>{const source=document.querySelector(`[data-buff="${el.dataset.buffMirror}"]`);if(source)el.checked=source.checked;});} // 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';} + function bare(){if(!options)return;preset(false);mirror();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.

`; $('#raid-preset').onclick=()=>preset(true);$('#solo-preset').onclick=()=>preset(false); + $('#sim-buffs')?.remove(); + $('#fight-style-hint').insertAdjacentHTML('afterend',`
Buffs
${['Raid buffs','Target debuffs'].map(group=>`
${esc(group)}
${options.buffs.filter(b=>b.group===group).map(b=>``).join('')}`).join('')}

Consumables are under Raid buffs & consumables below.

`); + $('#sim-buffs').addEventListener('change',e=>{const id=e.target.dataset.buffMirror;if(!id)return;const source=document.querySelector(`[data-buff="${id}"]`);source.checked=e.target.checked;$('#buff-preset-label').textContent='Custom raid buffs';source.dispatchEvent(new Event('change',{bubbles:true}));}); + $('#sim-buffs').addEventListener('click',e=>{const all=e.target.closest('[data-mirror-all]');if(all){preset(all.dataset.mirrorAll==='1');$('#buff-preset-label').textContent=all.dataset.mirrorAll==='1'?'Optimal raid buffs':'No external raid buffs';}}); $('#lust-mode').onchange=()=>{const mode=$('#lust-mode').value;$('#lust-value-label').hidden=mode==='pull';$('#lust-value').value=mode==='health'?30:mode==='remaining'?40:0;}; - $('#environment-content').addEventListener('change',e=>{if(e.target.matches('[data-buff]'))$('#buff-preset-label').textContent='Custom raid buffs';if(e.target.matches('[data-consumable]'))highlight();}); + $('#environment-content').addEventListener('change',e=>{if(e.target.matches('[data-buff]')){$('#buff-preset-label').textContent='Custom raid buffs';mirror();}if(e.target.matches('[data-consumable]'))highlight();}); function highlight(){document.querySelectorAll('[data-pick-value]').forEach(el=>el.classList.toggle('selected',document.querySelector(`[data-consumable="${el.dataset.pickCategory}"]`).value===el.dataset.pickValue));} $('#environment-content').addEventListener('click',e=>{ const pick=e.target.closest('[data-pick-value]');if(pick){document.querySelector(`[data-consumable="${pick.dataset.pickCategory}"]`).value=pick.dataset.pickValue;highlight();} diff --git a/public/index.html b/public/index.html index 57f4c97..b917d30 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 2985d10..9cf9786 100644 --- a/public/style.css +++ b/public/style.css @@ -130,3 +130,4 @@ @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}} +.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} diff --git a/tests/dummies.test.mjs b/tests/dummies.test.mjs index 5a1264a..e57da21 100644 --- a/tests/dummies.test.mjs +++ b/tests/dummies.test.mjs @@ -1,19 +1,19 @@ 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'; +import {silvermoon,dummyLines,dummyScenario,isDummyStyle,bands,fallTimeline,calibrate,needsCalibration,normalizeDummyHealth} 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',()=>{ +test('the Silvermoon dummies are the five measured in the city: level 90, never dying, passive',()=>{ 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.equal(lines.filter(l=>l==='enemy_fixed_health_percentage=1').length,5,'held at 1% unless told otherwise'); 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'),[]); @@ -22,12 +22,10 @@ test('the Silvermoon dummies are the five measured in the city: level 90, never 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'){ + assert.deepEqual(normalizeDummyHealth(undefined),{mode:'held'}); + assert.throws(()=>normalizeDummyHealth({mode:'sometimes'}),/health/); + assert.equal(needsCalibration({style:'SilvermoonDummies',dummyHealth:{mode:'falling'}}),true); + assert.equal(needsCalibration({style:'SilvermoonDummies',dummyHealth:{mode:'held'}}),false); + // 35,370.5 damage per second takes 1% of a dummy per second, in every band. + const pace=silvermoon.health/100; + const even=fallTimeline([pace,pace,pace,pace],300); + assert.equal(even.timeline,'100:0/80:0.0667/35:0.2167/20:0.2667/1:0.33/1:1');assert.equal(even.reached,99); + // Faster in execute: the last band takes half as long. + assert.equal(fallTimeline([pace,pace,pace,2*pace],300).reached,89.5); + // Too slow for the fight: it ends partway, with no point after 1.0. + const slow=fallTimeline([pace/4,pace/4,pace/4,pace/4],300); + assert.equal(slow.reached,null);assert.equal(slow.timeline,'100:0/80:0.2667/35:0.8667/25:1'); + // Calibration reports: damage taken per iteration and fight length per dummy, one report per band. + const report=rate=>({sim:{targets:Array.from({length:5},(_,i)=>({collected_data:{dtps:{mean:rate*(i?1:2)*100},fight_length:{mean:100}}}))}}); + const result=calibrate(bands.map(()=>report(pace)),300); + assert.equal(result.mode,'falling');assert.equal(result.timelines.length,5); + assert.deepEqual(result.reached,[49.5,99,99,99,99],'the main target takes twice the damage and gets there first'); + const input=inputFor({text:profile},settings,{style:'SilvermoonDummies',...dummyScenario,dummyHealth:result},paths); + assert.equal(input.split('enemy_custom_health_timeline=').length-1,5);assert.doesNotMatch(input,/enemy_fixed_health_percentage/); + // A calibration run holds the dummies in one band and reports their damage taken. + const probe=inputFor({text:profile},settings,{style:'SilvermoonDummies',...dummyScenario,dummyHealth:{calibrate:50}},paths); + assert.equal(probe.split('enemy_fixed_health_percentage=50\nrole=tank').length-1,5); +});
VariantDPS / 95 %vs baselineSurvivalScoreReport