diff --git a/README.md b/README.md index f8f6ad1..d0bc288 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Known limits: boss mechanics, movement, deaths and crowd control are not simulat Protection Warrior, Protection Paladin, Blood Death Knight, Guardian Druid, Brewmaster Monk and Vengeance Demon Hunter are detected on import. Every mode (Quick Sim, Enchant Lab, Gear Compare, Upgrade Finder, Crest Planner, Great Vault and Talent Search) then ranks results on DPS and survival. -**Tank Sim** has its own place in the menu: the imported tank against a boss and its adds, one simulation per fight you tick (the boss alone, the boss with 2 adds, the boss with 4 adds), with the numbers a tank cares about: damage taken per second and as a share of your health, healing and absorbs, how much of the fight you survive, how often you die, and your DPS. It needs a tank specialization. Tank Sim is a page of its own and is kept apart from the other simulations: its fights (always Patchwerk: the fights you tick are the whole description of the encounter), duration, iterations, target error, CPU threads, raid buffs and consumables, boss and gear sources are all on the page, and the settings column the other modes share is not used. It can also **find gear** from three sources you switch on or off: the gear from your bags (read from the addon export, with the equipped gear always the baseline), upgrades with crests (each equipped item at every higher level of its track, within the crests you have), and loot from instances (Upgrade Finder's raid, Mythic+, Great Vault, delve and crafted sources, kept as a separate copy of its settings). Every candidate from all three is simulated in its slot in each fight you ticked and the best ones again at full precision, ranked on the survival and damage score. Each fight in the result shows what your current gear takes per second, how much of the fight it survives and how often it dies, then the gear that does better for that fight. With all three off it runs the fights alone. +**Tank Sim** has its own place in the menu: the imported tank against a boss and its adds, one simulation per fight you tick (the boss alone, the boss with 2 adds, the boss with 4 adds), with the numbers a tank cares about: damage taken per second and as a share of your health, healing and absorbs, how much of the fight you survive, how often you die, and your DPS. It needs a tank specialization. Tank Sim is a page of its own and is kept apart from the other simulations: its fights (always Patchwerk: the fights you tick are the whole description of the encounter), duration, iterations, target error, CPU threads, raid buffs and consumables, boss and gear sources are all on the page, and the settings column the other modes share is not used. It can also **find gear** from three sources you switch on or off: the gear from your bags (read from the addon export, with the equipped gear always the baseline), upgrades with crests (each equipped item at every higher level of its track, within the crests you have), and loot from instances (Upgrade Finder's raid, Mythic+, Great Vault, delve and crafted sources, kept as a separate copy of its settings). Every candidate from all three is simulated in its slot in each fight you ticked and the best ones again at full precision, ranked on the survival and damage score. Each fight in the result shows what your current gear takes per second, how much of the fight it survives and how often it dies, then the gear that does better for that fight. The best trinkets and rings then go on to a round of **pairs**: every pair of them, together with the two you wear, is simulated as a pair, because one trinket measured alone cannot say whether to wear 1 and 2 or 1 and 4. The result opens with **Gear to wear**: for each fight, the best measured item in every slot (with the upgrade level and crest cost for crest upgrades, or the source and item level for drops and bag gear) and the best trinket and ring pair, at a ranking weight you move with a slider. Each result keeps its DPS and survival gains, so moving the slider ranks again at once without a new simulation. Each slot is measured on its own against your worn gear, so the list is not one combined set; only the pairs are measured together. The final round was chosen with the weight you ran with, and the page says so when the slider is far from it. With all three sources off it runs the fights alone. SimC's built-in tank dummy uses level-70 damage values and never threatens a Midnight tank, and SimC gives players infinite health by default. SimC Lab replaces it with a calibrated boss, tuned once per job against the imported gear and then shared by every variant and scenario: diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 11da78a..b39335a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.44.1", + "version": "1.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.44.1", + "version": "1.45.0", "license": "GPL-3.0-or-later", "dependencies": { "electron-updater": "^6.8.9" diff --git a/desktop/package.json b/desktop/package.json index baa49c0..c3ce7e1 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.44.1", + "version": "1.45.0", "description": "Local SimulationCraft workbench with upgrade finder and tank simulation.", "author": "Roburmaster", "private": true, diff --git a/lib/engine.mjs b/lib/engine.mjs index 0210f72..c588636 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -2,7 +2,7 @@ import {normalizeEnvironment,environmentLines} from './environment.mjs'; import {prepareTalents} from './optimizer.mjs'; import {buildCandidates,profilesetLines,profilesetResults,screenSettings,selectFinalists,embellishmentPairs,upgradeSteps} from './upgrades.mjs'; import {buildCrestCandidates} from './crests.mjs'; -import {buildTankGear} from './tankgear.mjs'; +import {buildTankGear,jewelryPairs} from './tankgear.mjs'; import {buildVaultCandidates} from './vault.mjs'; import {prepareWeapons,publicSpec,weaponSteps,specSteps,selectTop,rankWeaponRows,actorGear,pairCount,withPair,bestPair,profilePair,scaleStats,scaleOnly} from './weapons.mjs'; import {prepareTrinkets,trinketSteps,specSteps as trinketSpecSteps,topRows,selectTopTrinkets,trinketCount,gainOf,pairPool,trinketPairs,selectPairs,tiedPairs,bestPartners,pairScreenSettings,resolveSettings,pairStages,isPairStage,presetScenarios,groupOf,parity as parityRun} from './trinkets.mjs'; @@ -252,7 +252,7 @@ export class Jobs { 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)+plan.scenarios.filter(calibrates).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,counts:plan.upgrade.counts,candidates:plan.upgrade.candidates.map(({line,...c})=>c),screen:screenSettings(plan.settings)},stages:[]}); + 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,counts:plan.upgrade.counts,worn:plan.upgrade.worn,jewelry:!!plan.upgrade.jewelry,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:[]});} if(plan.trinkets)Object.assign(job,{trinkets:{...plan.trinkets,screen:screenSettings(plan.settings),pairScreen:pairScreenSettings(plan.settings),resolve:resolveSettings(plan.settings),repro:reproduction(plan), @@ -322,9 +322,20 @@ export class Jobs { if(job.status==='cancelled')return; const finalists=screen?selectFinalists(all,screen,plan.upgrade.finalists,plan.settings.tank):[]; const skip=async(stage,reason)=>{job.stages.push({scenario:s,stage,status:'skipped',count:0,reason});job.done++;await this.save(this.public(job));}; - if(!finalists.length){await skip(2,screen?'No candidate could beat the current gear within screening uncertainty.':'Screening failed, so there was no final round.');if(plan.upgrade.embellished)await skip(3,'There was no final round to pair embellishments from.');continue;} + if(!finalists.length){await skip(2,screen?'No candidate could beat the current gear within screening uncertainty.':'Screening failed, so there was no final round.');if(plan.upgrade.embellished)await skip(3,'There was no final round to pair embellishments from.');if(plan.upgrade.jewelry)await skip(4,'There was no final round to pair trinkets and rings from.');continue;} const final=await this.stage(job,plan,s,2,finalists,plan.settings); - if(!plan.upgrade.embellished||job.status==='cancelled')continue; + if(job.status==='cancelled')continue; + // Tank Sim: the best trinkets and rings, each pair simulated together with the two worn, which one item at a time cannot say. + if(plan.upgrade.jewelry){ + const pairs=final?jewelryPairs(finalists,final.rows,plan.profile,{itemLimits:plan.upgrade.itemLimits},this.catalog):[]; + if(!pairs.length)await skip(4,final?'No trinket or ring pair could be built from the final round.':'The final round failed, so there were no pairs.'); + else{ + job.upgrade.jewelryPairs=[...(job.upgrade.jewelryPairs||[]),...pairs.map(p=>({scenario:s,key:p.key,family:p.family,parts:p.parts.map(({line,sources,...part})=>part)}))]; + await this.stage(job,plan,s,4,pairs,plan.settings); + if(job.status==='cancelled')continue; + } + } + if(!plan.upgrade.embellished)continue; // Third round: the best embellished upgrades worn two at a time, within the equip limit. const pairs=final?embellishmentPairs(finalists,final.rows,final.baseline,plan.profile,{itemLimits:plan.upgrade.itemLimits},this.catalog,{tank:plan.settings.tank}):[]; if(!pairs.length){const worn=(plan.upgrade.limitsUsed||[]).map(h=>h.name).join(', ');await skip(3,!final?'The final round failed, so there were no embellishments to pair.':worn?`No two of the best embellished upgrades can be worn together next to ${worn}, so the best single one is the answer.`:'Fewer than two embellished upgrades beat your gear, so there was nothing to pair.');continue;} @@ -616,7 +627,7 @@ export class Jobs { const scenario=plan.scenarios[s]; // Enough to run the same stage again: the fight, its length and variation, and the precision it asked for. const record={...(spec?{spec:spec.key}:{}),scenario:s,stage,stem,count:list.length,iterations:settings.iterations,targetError:settings.targetError,fightStyle:scenario.style,targets:scenario.targets,duration:scenario.duration||settings.duration,variation:settings.environment?.variation??20}; - 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'; + const title=job.crests?'Upgrades':job.vault?'Great Vault choices':{1:'Screening',3:'Embellishment pairs',4:'Trinket and ring 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. const actor=playerActorIndex(settings,scenario); diff --git a/lib/tank.mjs b/lib/tank.mjs index b909eba..bc3f009 100644 --- a/lib/tank.mjs +++ b/lib/tank.mjs @@ -145,5 +145,6 @@ export function tankComparison(row,base,boss,weight){ // SimC gives no usable error for survived time; a death-or-not bound (at most the binomial spread) stands in. const aliveError=(m,n)=>m.aliveError||(n?z*Math.sqrt(Math.max(m.alive*(1-m.alive),1/n)/n):0); const survivalError=Math.hypot(100*Math.hypot(row.tank.netError||0,base.tank.netError||0)/pressure,100*Math.hypot(aliveError(row.tank,row.iterations),aliveError(base.tank,base.iterations))); - return {dpsGain,survival,score,scoreError:Math.hypot((1-weight/100)*dpsError,weight/100*survivalError)}; + // The two errors are kept so a result can be weighed again with another weight without a new simulation. + return {dpsGain,survival,score,scoreError:Math.hypot((1-weight/100)*dpsError,weight/100*survivalError),dpsError,survivalError}; } diff --git a/lib/tankgear.mjs b/lib/tankgear.mjs index 803b7f2..598bb03 100644 --- a/lib/tankgear.mjs +++ b/lib/tankgear.mjs @@ -2,7 +2,7 @@ // one of. Every source ends up as the same kind of candidate, so one screening round and one final round can rank all of // them together, with the tank's own score (survival and DPS) in each fight. import {slots as allSlots} from './profile.mjs'; -import {buildCandidates,equippedLimits,overLimit,itemLimitsOf,bonusIdsOf,limits as upgradeLimits} from './upgrades.mjs'; +import {buildCandidates,equippedLimits,overLimit,itemLimitsOf,bonusIdsOf,variantOf,limits as upgradeLimits} from './upgrades.mjs'; import {buildCrestCandidates} from './crests.mjs'; // Items the addon export lists beside the equipped gear: bags, the Great Vault, anything saved. Equipped gear is the @@ -71,5 +71,44 @@ export function buildTankGear(profile,text,request,season,catalog,specId){ list.forEach((c,i)=>c.key='c'+String(i+1).padStart(3,'0')); const equipped=equippedLimits(profile,season.itemLimits); const holders=Object.entries(equipped).map(([slot,used])=>({slot,name:catalog.items.get(profile.gear[slot].id)?.name||`Item ${profile.gear[slot].id}`,limits:[...used]})); - return {candidates:list,finalists,embellished:list.some(c=>c.limits),limitsUsed:holders,blocked,freed,counts,warnings}; + // What is worn now, so the result can name what each recommendation replaces. + const worn=Object.fromEntries(Object.entries(profile.gear||{}).filter(([,g])=>g?.id).map(([slot,g])=>[slot,{itemId:g.id,name:catalog.items.get(g.id)?.name||`Item ${g.id}`}])); + return {candidates:list,finalists,embellished:list.some(c=>c.limits),limitsUsed:holders,blocked,freed,counts,warnings,worn,jewelry:true}; +} + +// A single item measured on its own says nothing about which two trinkets or rings to wear together: the answer is a pair. +// The best candidates of each pair of slots go together with the two worn pieces, and every pair that can be worn at once +// becomes one simulation. A pair of the two worn pieces is the baseline, so it is not run. +const twin={finger1:'finger2',finger2:'finger1',trinket1:'trinket2',trinket2:'trinket1'}; +export function jewelryPairs(candidates,rows,profile,data,catalog,{pick=5}={}){ + const byKey=new Map(candidates.map(c=>[c.key,c])); + const equipped=equippedLimits(profile,data.itemLimits),pairs=[]; + for(const family of ['trinket','finger']){ + const worn=[family+'1',family+'2'].filter(slot=>profile.gear[slot]?.id).map(slot=>{ + const g=profile.gear[slot]; + return {slot,itemId:g.id,name:catalog.items.get(g.id)?.name||`Item ${g.id}`,value:g.value,line:`${slot}=${g.value}`,worn:true,limits:[...itemLimitsOf(g.id,bonusIdsOf(g.value),data.itemLimits)]}; + }); + const best=[],seen=new Set(); + const ranked=rows.filter(r=>Number.isFinite(r.score)&&byKey.get(r.key)?.slot.startsWith(family)&&!byKey.get(r.key).freed).sort((a,b)=>b.score-a.score); + for(const r of ranked){ + const c=byKey.get(r.key),id=variantOf(c);if(seen.has(id))continue;seen.add(id); + best.push({slot:c.slot,itemId:c.itemId,name:c.name,itemLevel:c.itemLevel,value:c.value,line:c.line,limits:c.limits||[],sources:c.sources,...(c.upgrade?{upgrade:c.upgrade}:{})}); + if(best.length>=pick)break; + } + const pool=[...worn,...best]; + for(let i=0;ipart.slot===slot?part:{...part,slot,line:part.line.replace(/^[a-z_0-9]+=/,slot+'=')}; + let first=a,second=b; + if(a.worn)second=place(b,twin[a.slot]); + else if(b.worn)first=place(a,twin[b.slot]); + else if(a.slot===b.slot)second=place(b,twin[b.slot]); + if(first.slot===second.slot)continue; + if(overLimit(equipped,{[first.slot]:new Set(first.limits),[second.slot]:new Set(second.limits)},[],data.itemLimits).length)continue; + pairs.push({key:'j'+String(pairs.length+1).padStart(3,'0'),family,parts:[first,second]}); + } + } + return pairs; } diff --git a/lib/upgrades.mjs b/lib/upgrades.mjs index 7e8849f..5333bb0 100644 --- a/lib/upgrades.mjs +++ b/lib/upgrades.mjs @@ -403,4 +403,4 @@ export function selectFinalists(candidates,screen,size,tank){ } // Screening and the final round per scenario, and the embellishment pairs when anything embellished is searched. -export const upgradeSteps=(upgrade,scenarios)=>(upgrade.embellished?3:2)*scenarios; +export const upgradeSteps=(upgrade,scenarios)=>((upgrade.embellished?3:2)+(upgrade.jewelry?1:0))*scenarios; diff --git a/package-lock.json b/package-lock.json index 85fc243..8fc7167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.44.1", + "version": "1.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.44.1", + "version": "1.45.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 622d369..fd96fe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.44.1", + "version": "1.45.0", "private": true, "type": "module", "scripts": { diff --git a/public/recommend.js b/public/recommend.js new file mode 100644 index 0000000..159d48a --- /dev/null +++ b/public/recommend.js @@ -0,0 +1,82 @@ +import {itemLink,slotNames} from '/items.js'; +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const $=s=>document.querySelector(s); + +// Slots in the order a player reads them. Rings and trinkets are answered by pairs, the rest one slot at a time. +const single=['head','neck','shoulder','back','chest','wrist','hands','waist','legs','feet']; +const weapons=['main_hand','off_hand']; +const families={finger:'Rings',trinket:'Trinkets'}; +const signed=(n,digits=2)=>`${n>=0?'+':''}${n.toFixed(digits)}`; + +// The weighted score of a measured row. Each row keeps its gain in DPS and in survival, so another weight is a sum, not a +// new simulation. Rows from before the errors were kept fall back on the error they were weighed with. +export const weighted=(row,w)=>({ + score:(1-w)*row.dpsGain+w*row.survival, + error:Number.isFinite(row.dpsError)&&Number.isFinite(row.survivalError)?Math.hypot((1-w)*row.dpsError,w*row.survivalError):row.scoreError||0 +}); + +// What to wear in every slot of one fight, at a weight (0 to 1): the best measured item per slot, and the best pair of +// trinkets and of rings. A slot where nothing beats the current gear says to keep it. +export function recommend(job,s,w){ + const up=job.upgrade;if(!up)return null; + const candidates=new Map(up.candidates.map(c=>[c.key,c])); + const rows=job.results.filter(r=>r.scenario===s&&r.stage===2&&r.status==='complete'&&candidates.has(r.key)&&Number.isFinite(r.dpsGain)).map(r=>({r,c:candidates.get(r.key),...weighted(r,w)})); + const pairs=(up.jewelryPairs||[]).filter(p=>p.scenario===s).map(p=>{const r=job.results.find(x=>x.scenario===s&&x.stage===4&&x.key===p.key&&x.status==='complete'&&Number.isFinite(x.dpsGain));return r?{r,p,...weighted(r,w)}:null;}).filter(Boolean); + const best=list=>[...list].sort((a,b)=>b.score-a.score)[0]; + // A slot counts as searched when anything was offered for it, even if screening sent nothing on: it was measured and lost. + const offered=slot=>up.candidates.some(c=>c.slot===slot); + const slots=[...single,...weapons].map(slot=>{const pick=best(rows.filter(x=>x.c.slot===slot));return {slot,pick:pick&&pick.score>0?pick:null,tried:offered(slot)};}); + const sets=Object.keys(families).map(family=>{const own=pairs.filter(x=>x.p.family===family).sort((a,b)=>b.score-a.score);return {family,pick:own[0]&&own[0].score>0?own[0]:null,top:own.slice(0,5),tried:own.length>0||offered(family+'1')||offered(family+'2')};}); + return {slots,sets,rows:rows.length,hasPairs:pairs.length>0}; +} + +const gain=x=>`${signed(x.score)}DPS ${signed(x.r.dpsGain)} % · survival ${signed(x.r.survival)} %${Math.abs(x.score)<=x.error?' · uncertain':''}`; +function piece(c,up){ + // The same item you wear, at a higher level: say so, since the name alone looks like "keep". + if(!c.upgrade&&Object.entries(up.worn||{}).some(([slot,w])=>w.itemId===c.itemId&&(slot===c.slot||slot.replace(/\d$/,'')===c.slot.replace(/\d$/,''))))return `${itemLink(c.itemId,c.name,c.value)}The item you wear, at item level ${c.itemLevel} · ${esc(label(c))}`; + if(c.upgrade)return `${itemLink(c.itemId,c.name,c.value)}Upgrade to ${c.upgrade.to}/${c.upgrade.max} · item level ${c.itemLevel} · ${c.upgrade.crests} crest${c.upgrade.crests===1?'':'s'} from ${c.upgrade.from}/${c.upgrade.max}`; + return `${itemLink(c.itemId,c.name,c.value)}Item level ${c.itemLevel} · ${esc(label(c))}`; +} +// The source label often carries the item level already; it is shown once. +const label=c=>String(c.sources?.[0]?.label||'').replace(/\s*·?\s*item level \d+/i,''); +const wornName=(up,slot)=>esc(up.worn?.[slot]?.name||'—'); +const part=p=>p.worn?`${esc(p.name)}worn now · keep`:`${itemLink(p.itemId,p.name,p.value)}new · item level ${p.itemLevel}${p.upgrade?` · upgrade to ${p.upgrade.to}/${p.upgrade.max}`:''}`; + +export function recommendBody(job,s,w){ + const up=job.upgrade,rec=recommend(job,s,w); + if(!rec||!rec.rows)return '

The gear search has not finished for this fight yet.

'; + const slotRow=({slot,pick,tried})=>`${esc(slotNames[slot]||slot)}${wornName(up,slot)}${pick?piece(pick.c,up):`${tried?'Keep what you wear':'Nothing offered for this slot'}`}${pick?gain(pick):'—'}`; + const setRow=({family,pick,tried,top})=>{ + const label=families[family],now=['1','2'].map(n=>wornName(up,family+n)).join(' + '); + const others=top.length>1?`
Best ${label.toLowerCase().replace(/s$/,'')} pairs measured${top.map(x=>``).join('')}
FirstSecondScore
${part(x.p.parts[0])}${part(x.p.parts[1])}${gain(x)}
`:''; + return `${label}as a pair${now}${pick?`${part(pick.p.parts[0])}
${part(pick.p.parts[1])}`:`${tried?'Keep both':'Not searched'}`}${others}${pick?gain(pick):'—'}`; + }; + const head=slots=>slots.map(slotRow).join(''); + const [first,last]=[rec.slots.filter(x=>single.includes(x.slot)),rec.slots.filter(x=>weapons.includes(x.slot))]; + return `${head(first)}${rec.sets.filter(x=>x.tried||up.jewelry).map(setRow).join('')}${head(last)}
SlotYou wearWear thisScore
`; +} + +export function recommendUI(){ + let lastJob=null,fight=0,weight=null,jobId=null; + const jobWeight=job=>(job.settings?.tank?.weight??50); + const note=job=>{const w=weight??jobWeight(job);return Math.abs(w-jobWeight(job))>=15?`

The final round was chosen with ${jobWeight(job)}% weight on survival. At ${w}% something better may be missing from it: run again with the weight you want to be sure.

`:'';}; + function redraw(){ + const box=$('#tank-reco-body');if(!box||!lastJob)return; + const w=weight??jobWeight(lastJob); + box.innerHTML=recommendBody(lastJob,fight,w/100); + const label=$('#tank-reco-label');if(label)label.textContent=`${w}% survival · ${100-w}% DPS`; + const warn=$('#tank-reco-note');if(warn)warn.innerHTML=note(lastJob); + } + document.querySelector('#result-content').addEventListener('input',e=>{if(e.target.id!=='tank-reco-weight')return;weight=Number(e.target.value);redraw();}); + document.querySelector('#result-content').addEventListener('click',e=>{const chip=e.target.closest('[data-reco-fight]');if(!chip||!lastJob)return;fight=Number(chip.dataset.recoFight);document.querySelectorAll('[data-reco-fight]').forEach(c=>c.classList.toggle('active',c===chip));redraw();}); + return { + html(job,fights){ + if(!job.upgrade||!job.results.some(r=>r.stage===2&&r.status==='complete'&&Number.isFinite(r.dpsGain)))return ''; + if(jobId!==job.id){jobId=job.id;weight=null;fight=0;} + lastJob=job; + const w=weight??jobWeight(job); + const chips=job.scenarios.length>1?`
${job.scenarios.map((sc,i)=>``).join('')}
`:''; + return `

Gear to wear

What scored best in every slot for the fight below, at the weight you set. Each slot was measured on its own against the gear you wear now, so these are not one combined set: two upgrades can be worth less together than apart. Trinkets and rings are the exception, because they are simulated as pairs.

${w}% survival · ${100-w}% DPS
${note(job)}
${chips}
${recommendBody(job,fight,w/100)}
`; + } + }; +} diff --git a/public/style.css b/public/style.css index ddb4fa5..8cabcfc 100644 --- a/public/style.css +++ b/public/style.css @@ -158,3 +158,12 @@ #tank-panel.embedded{background:none;border:0;padding:0;margin:0} #tank-panel.embedded .panel-heading{margin-bottom:6px} #tank-panel.embedded .panel-heading h2{margin:0;font-size:11px;letter-spacing:1.6px;text-transform:uppercase;color:var(--amber);font-weight:700} +.tank-reco{margin:22px 0 8px;padding:16px 18px;border:1px solid var(--line);border-left:3px solid var(--amber);border-radius:8px;background:#141a25} +.tank-reco>h3{margin:0 0 6px;font-size:13px;letter-spacing:1.4px;text-transform:uppercase;color:var(--amber)} +.reco-weight{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:10px 0} +.reco-weight label{margin:0;min-width:260px} +.reco-weight input[type=range]{width:100%} +.reco-table td,.reco-table th{text-align:left;vertical-align:top;white-space:normal} +.reco-table td:last-child,.reco-table th:last-child{white-space:nowrap} +.reco-table td small{display:block} +.tank-reco{overflow-x:auto} diff --git a/public/tanksim.js b/public/tanksim.js index 074217e..8ec071b 100644 --- a/public/tanksim.js +++ b/public/tanksim.js @@ -1,5 +1,6 @@ import {upgradeUI} from '/upgrades.js'; import {crestUI} from '/crests.js'; +import {recommendUI} from '/recommend.js'; const $=s=>document.querySelector(s); const $$=s=>[...document.querySelectorAll(s)]; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); @@ -45,6 +46,7 @@ export function tankSimUI({api,notice,updateCount,run,cancel}){
Waiting for character import
`); const loot=upgradeUI({api,notice,updateCount,host:$('#tank-loot-host'),sfx:'-tank',owner:'tank'}); const crests=crestUI({api,notice,updateCount,host:$('#tank-crests-host'),sfx:'-tank'}); + const reco=recommendUI(); $('#tanksim-panel').addEventListener('change',event=>{ if(event.target.matches('#tank-loot,#tank-crests'))$(`#${event.target.id}-host`).closest('.tank-source').classList.toggle('off',!event.target.checked); updateCount(); @@ -123,7 +125,7 @@ export function tankSimUI({api,notice,updateCount,run,cancel}){ const x=r.tank,health=x.health||t?.boss?.health,fight=fights.find(f=>f.targets===sc.targets); const pct=n=>health?`${(100*n/health).toFixed(1)}% of your health`:''; return `${esc(fight?.label||`${sc.targets} targets`)}${esc(sc.style)} · ${sc.targets-1} add${sc.targets===2?'':'s'}${number(x.dtps)} / sec${pct(x.dtps)} per second${number(x.hps)} healed + ${number(x.aps)} absorbed / secnet after healing ${number(x.dtps-x.hps)} / sec${(100*x.alive).toFixed(0)}%of the fight, on average${x.deaths===undefined?'—':`${(100*x.deaths).toFixed(0)}%`}of ${number(r.iterations||job.settings.iterations)} fights${number(r.dps)}±${number(r.error95||0)}`;}).join('')}

Damage taken is after your armor, avoidance and absorbs, as SimC counts it. More adds means more damage and more deaths, and that is the point: the same gear holds up differently against a pack than against a single hit. Deaths are against a boss whose tank-busters were sized so your current gear dies in about ${t?.deathTarget??25}% of boss-alone fights.

`; - return html; + return html+reco.html(job,fights); }, loot }; diff --git a/public/upgrades.js b/public/upgrades.js index 217ca74..f3f8057 100644 --- a/public/upgrades.js +++ b/public/upgrades.js @@ -116,7 +116,7 @@ export function upgradeUI({api,notice,updateCount,host=null,sfx='',owner='upgrad const base=baselines[2]||baselines[1];if(base)html+=`

Current gear: ${number(base.dps)} DPS${base.error95!==null?` ± ${number(base.error95)}`:''}${base.tank?` · takes ${number(base.tank.dtps)} damage per second${base.tank.health?` (${(100*base.tank.dtps/base.tank.health).toFixed(1)}% of your health)`:''}, survives ${(100*base.tank.alive).toFixed(0)}% of the fight${base.tank.deaths!==undefined?`, dies in ${(100*base.tank.deaths).toFixed(0)}% of fights`:''}`:''}

`; html+=slotFilterBar(measured); if(slotFilter!=='all')html+=`

${esc(familyNames[slotFilter]||slotFilter)}: every item measured for this slot, best first.${rows.length?'':' Nothing was measured for it in this job.'}

`; - const stageName=n=>n===1?'Screening':n===3?'Embellishment pairs':'Final round'; + const stageName=n=>n===1?'Screening':n===3?'Embellishment pairs':n===4?'Trinket and ring pairs':'Final round'; for(const st of stages){if(st.status==='failed')html+=`

${stageName(st.stage)} failed: ${esc(st.error)}

`;if(st.status==='skipped')html+=`

${esc(st.reason)}

`;} if(upgrades.length){ const groups=new Map(); diff --git a/server.mjs b/server.mjs index 8419c7e..6f49002 100644 --- a/server.mjs +++ b/server.mjs @@ -246,7 +246,7 @@ const server=http.createServer(async(req,res)=>{ // Reports are generated by SimC. Serve downloads so their scripts never share this app's origin. res.writeHead(200,{'Content-Type':ext==='.json'?'application/json':'application/octet-stream','Content-Disposition':`attachment; filename="${report[2]}"`});return res.end(data); } - const assets={'/':'index.html','/app.js':'app.js','/mplus.js':'mplus.js','/items.js':'items.js','/wowhead.js':'wowhead.js','/features.js':'features.js','/upgrades.js':'upgrades.js','/tanksim.js':'tanksim.js','/crests.js':'crests.js','/crestplan.js':'crestplan.js','/vault.js':'vault.js','/weapons.js':'weapons.js','/trinkets.js':'trinkets.js','/tank.js':'tank.js','/engine.js':'engine.js','/activity.js':'activity.js','/environment.js':'environment.js','/wow.js':'wow.js','/logs.js':'logs.js','/armory.js':'armory.js','/style.css':'style.css'}; + const assets={'/':'index.html','/app.js':'app.js','/mplus.js':'mplus.js','/items.js':'items.js','/wowhead.js':'wowhead.js','/features.js':'features.js','/upgrades.js':'upgrades.js','/tanksim.js':'tanksim.js','/recommend.js':'recommend.js','/crests.js':'crests.js','/crestplan.js':'crestplan.js','/vault.js':'vault.js','/weapons.js':'weapons.js','/trinkets.js':'trinkets.js','/tank.js':'tank.js','/engine.js':'engine.js','/activity.js':'activity.js','/environment.js':'environment.js','/wow.js':'wow.js','/logs.js':'logs.js','/armory.js':'armory.js','/style.css':'style.css'}; if(req.method==='GET'&&assets[route]){const file=assets[route];res.writeHead(200,{'Content-Type':file.endsWith('.js')?'text/javascript; charset=utf-8':file.endsWith('.css')?'text/css; charset=utf-8':'text/html; charset=utf-8'});return res.end(await fs.readFile(path.join(root,'public',file)));} json(res,404,{error:'Not found.'}); }catch(e){json(res,e.code==='ENOENT'?404:400,{error:e.message});} diff --git a/tests/tank-gear-integration.mjs b/tests/tank-gear-integration.mjs index d60abbc..3c0f6b6 100644 --- a/tests/tank-gear-integration.mjs +++ b/tests/tank-gear-integration.mjs @@ -25,7 +25,7 @@ const bags=await post('/api/preview',{...common,tankGear:{finalists:24,bags:{}}} assert.equal(bags.body.upgrade.candidates,1);assert.deepEqual(bags.body.upgrade.counts,{loot:0,bags:1,crests:0}); // All three sources at once, run for real. -const gear={finalists:24,bags:{},crests:{levels:'max',affordable:false},loot:{slots:['waist','head'],finalists:24,raid:{enabled:true,difficulty:sources.difficulties.at(-1).track,upgrade:0,encounters:raid.encounters.slice(0,2).map(e=>e.id)}}}; +const gear={finalists:24,bags:{},crests:{levels:'max',affordable:false},loot:{slots:['waist','head','trinket1','trinket2','finger1','finger2'],finalists:24,raid:{enabled:true,difficulty:sources.difficulties.at(-1).track,upgrade:0,encounters:raid.encounters.map(e=>e.id)}}}; const preview=await post('/api/preview',{...common,tankGear:gear}); const counts=preview.body.upgrade.counts;assert.ok(counts.bags===1&&counts.loot>0,JSON.stringify(counts)); const job=await wait((await post('/api/jobs',{...common,tankGear:gear})).body.id); @@ -39,3 +39,13 @@ for(const s of [0,1]){ assert.ok(job.results.some(r=>r.scenario===s&&Number.isFinite(r.score)),`Fight ${s} ranks gear on the tank score.`); } console.log('tank gear ok',JSON.stringify({counts,candidates:job.upgrade.candidates.length,origins:[...origins]})); +// Trinkets and rings are answered as pairs: stage 4 either ran pairs or says why not, in every fight. +for(const s of [0,1]){ + const st=job.stages.find(x=>x.scenario===s&&x.stage===4);assert.ok(st,`Fight ${s} has a pair stage.`); + if(st.status==='complete'){ + const pairs=job.results.filter(r=>r.scenario===s&&r.stage===4&&r.status==='complete'); + assert.ok(pairs.length&&pairs.every(r=>Number.isFinite(r.score)&&Number.isFinite(r.dpsError)&&Number.isFinite(r.survivalError)),'Pairs carry the score and both errors.'); + assert.ok(job.upgrade.jewelryPairs.some(p=>p.scenario===s&&p.parts.every(x=>x.name&&x.slot))); + console.log('fight',s,'pairs',pairs.length); + }else console.log('fight',s,'pair stage',st.status,st.reason||st.error); +} diff --git a/tests/tankgear.test.mjs b/tests/tankgear.test.mjs index febe08c..b32eb25 100644 --- a/tests/tankgear.test.mjs +++ b/tests/tankgear.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {bagCandidates,buildTankGear} from '../lib/tankgear.mjs'; +import {bagCandidates,buildTankGear,jewelryPairs} from '../lib/tankgear.mjs'; const HERO=3445; const ladder=(id,name,bonus,first)=>({id,name,levels:[0,1,2,3,4,5].map(i=>({level:i+1,max:6,bonusId:bonus+i,itemLevel:first+[0,3,6,10,13,16][i],discounts:[{currencyId:HERO,scaling:0,accountWide:false}],...(i?{cost:{currencyId:HERO,amount:20}}:{})}))}); @@ -59,3 +59,29 @@ test('an empty bag source says why, and a finalist size outside the list is refu assert.throws(()=>buildTankGear(none,text,{bags:{}},season,catalog,null),/lists no bag gear/); assert.throws(()=>buildTankGear(profile,text,{bags:{},finalists:7},season,catalog,null),/final round size/); }); + +test('trinkets are paired with each other and with the two worn, never the worn pair itself or one item twice',()=>{ + const worn={info:{class:'druid',spec:'guardian'},gear:{trinket1:{id:11,value:',id=11,bonus_id=1'},trinket2:{id:12,value:',id=12,bonus_id=1'},finger1:{id:21,value:',id=21'},finger2:{id:22,value:',id=22'}},alternatives:[]}; + const names=new Map([[11,{name:'Worn A'}],[12,{name:'Worn B'}],[21,{name:'Ring A'}],[22,{name:'Ring B'}],[31,{name:'New X'}],[32,{name:'New Y'}],[41,{name:'New Ring'}]]); + const cat={items:names}; + const cands=[{key:'c001',slot:'trinket1',itemId:31,name:'New X',itemLevel:300,value:',id=31',line:'trinket1=,id=31',sources:[]}, + {key:'c002',slot:'trinket2',itemId:32,name:'New Y',itemLevel:300,value:',id=32',line:'trinket2=,id=32',sources:[]}, + {key:'c003',slot:'finger1',itemId:41,name:'New Ring',itemLevel:300,value:',id=41',line:'finger1=,id=41',sources:[]}]; + const rows=[{key:'c001',score:3},{key:'c002',score:2},{key:'c003',score:1}]; + const pairs=jewelryPairs(cands,rows,worn,{itemLimits:season.itemLimits},cat); + const trinkets=pairs.filter(p=>p.family==='trinket'),rings=pairs.filter(p=>p.family==='finger'); + // Four pieces in the pool: X, Y and the two worn. Six pairs, less the worn pair itself. + assert.equal(trinkets.length,5); + assert.ok(trinkets.every(p=>p.parts[0].slot!==p.parts[1].slot&&p.parts[0].itemId!==p.parts[1].itemId)); + assert.ok(!trinkets.some(p=>p.parts.every(x=>x.worn))); + // A new trinket beside a worn one takes the other slot, and the line says so. + const keep=trinkets.find(p=>p.parts.some(x=>x.itemId===11)&&p.parts.some(x=>x.itemId===31)); + assert.deepEqual(keep.parts.map(x=>x.line),['trinket1=,id=11,bonus_id=1','trinket2=,id=31']); + const keepB=trinkets.find(p=>p.parts.some(x=>x.itemId===12)&&p.parts.some(x=>x.itemId===32)); + assert.deepEqual(keepB.parts.map(x=>x.line),['trinket2=,id=12,bonus_id=1','trinket1=,id=32']); + // Two new ones that both prefer slot 1 share the two slots. + const both=trinkets.find(p=>p.parts.every(x=>!x.worn));assert.deepEqual(both.parts.map(x=>x.slot).sort(),['trinket1','trinket2']); + // Rings: the one new ring pairs with each worn ring. + assert.equal(rings.length,2); + assert.deepEqual(pairs.map(p=>p.key),pairs.map((p,i)=>'j'+String(i+1).padStart(3,'0'))); +});