From fc5ee20029e259d8435f5e0420a8c915dec6f3b9 Mon Sep 17 00:00:00 2001 From: Roburmaster <25195137+Roburmaster@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:51:30 +0200 Subject: [PATCH] 1.44.0: Tank Sim is a page of its own, and tries bags, crests and instance loot Tank Sim no longer borrows the settings column the other modes share. Its page holds every setting: the fights, fight style, duration, iterations, target error, CPU threads, raid buffs and consumables, the boss, and the gear sources. The shared settings column and buffs panel are hidden in this mode. The gear it can try is now three sources the player switches on or off, merged into one search that ranks on the tank's survival and damage score in each fight: gear from the bags (read from the addon export, with item levels), upgrades with crests (every equipped item at each higher level of its track, within the crests owned), and loot from instances (Upgrade Finder's sources, mounted as a separate copy so Tank Sim's choices never change Upgrade Finder's). Equipped gear stays the baseline. With all three off it runs the fights alone. The job is now mode tank, with the gear search attached, instead of an upgrades job. Tested through the app on a Guardian Druid (bags and instance loot: 121 candidates, a full run with a bag item and raid loot ranked on the tank score) and with unit tests of the merge, the equip limit and the crest fallback. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- desktop/package-lock.json | 4 +- desktop/package.json | 2 +- lib/engine.mjs | 11 ++- lib/tankgear.mjs | 75 ++++++++++++++++++++ lib/upgradepage.mjs | 2 +- package-lock.json | 4 +- package.json | 2 +- public/app.js | 27 ++++---- public/crests.js | 40 ++++++----- public/style.css | 22 ++++++ public/tank.js | 2 + public/tanksim.js | 118 ++++++++++++++++++++++++++++---- public/upgrades.js | 53 +++++++------- server.mjs | 2 +- tests/tank-gear-integration.mjs | 41 +++++++++++ tests/tankgear.test.mjs | 61 +++++++++++++++++ 17 files changed, 392 insertions(+), 76 deletions(-) create mode 100644 lib/tankgear.mjs create mode 100644 tests/tank-gear-integration.mjs create mode 100644 tests/tankgear.test.mjs diff --git a/README.md b/README.md index bf1e087..4cde09b 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 can also **find gear upgrades** (on by default): Upgrade Finder's sources appear in the same mode, and every item from them 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 upgrades for that fight. The same upgrades can be run from Upgrade Finder itself with the same number of targets. +**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, fight style, 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. 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 6c248fc..19d1fac 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.43.0", + "version": "1.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.43.0", + "version": "1.44.0", "license": "GPL-3.0-or-later", "dependencies": { "electron-updater": "^6.8.9" diff --git a/desktop/package.json b/desktop/package.json index ace1f02..5bd66c9 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.43.0", + "version": "1.44.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 eff7aaa..0210f72 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -2,6 +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 {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'; @@ -162,6 +163,14 @@ export async function prepare(request,catalog,talentData,season) { const name=`Weapon tier list · ${plan.specs.length} spec${plan.specs.length===1?'':'s'}`; return {engine,profile:{text:'',info:{name},warnings:[]},settings,scenarios,variants:[],weapons:plan}; } + // Tank Sim tries the gear a tank carries and could earn, from its own list of sources (see tankgear.mjs); with none chosen it is plain fights. + if(request.mode==='tank'){ + const upgrade=buildTankGear(profile,request.profile,request.tankGear,season,catalog,talentData.find(profile.info).specId); + if(upgrade){ + upgrade.season=season.season;upgrade.itemLimits=season.itemLimits; + return {engine,profile:{...profile,warnings:[...(profile.warnings||[]),...upgrade.warnings]},settings,scenarios,variants:[{name:'Current gear',text:profile.text,baseline:true}],upgrade}; + } + } if(request.mode==='upgrades'){ if(!season)throw new Error('Season loot data is not loaded.'); const upgrade=buildCandidates(profile,request.upgrades,season,catalog,talentData.find(profile.info).specId); @@ -243,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,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,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), diff --git a/lib/tankgear.mjs b/lib/tankgear.mjs new file mode 100644 index 0000000..803b7f2 --- /dev/null +++ b/lib/tankgear.mjs @@ -0,0 +1,75 @@ +// Tank Sim's gear: what to try on a tank, from three sources that Upgrade Finder, Gear Compare and Crest Planner each know +// 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 {buildCrestCandidates} from './crests.mjs'; + +// Items the addon export lists beside the equipped gear: bags, the Great Vault, anything saved. Equipped gear is the +// baseline and always runs, so it is never a candidate of its own. +export function bagCandidates(profile,season,catalog){ + const out=[];let blocked=0; + const limits=season?.itemLimits,equipped=equippedLimits(profile,limits); + for(const alt of profile.alternatives||[]){ + const slot=alt.slot;if(!slot||!allSlots.includes(slot))continue; + const value=String(alt.text).slice(slot.length+1); + const id=Number(value.match(/(?:^|,)id=(\d+)/)?.[1]);if(!id||profile.gear[slot]?.value===value)continue; + const item=catalog.items.get(id); + // A bag item that would break an equip limit next to what is worn is left out, as in Upgrade Finder. + const uses=itemLimitsOf(id,bonusIdsOf(value),limits); + if(uses.size&&overLimit(equipped,{[slot]:uses},[],limits).length){blocked++;continue;} + // The addon names each bag item with its item level, "Pledgebearer's Girdle (295)"; an override in the line wins. + const level=Number(value.match(/(?:^|,)ilevel=(\d+)/)?.[1])||Number(String(alt.name).match(/\((\d+)\)\s*$/)?.[1])||'?'; + const section=alt.section||'Bags'; + out.push({slot,itemId:id,name:item?.name||String(alt.name).replace(/\s*\(\d+\)\s*$/,'')||`Item ${id}`,itemLevel:level,value,line:`${slot}=${value}`,...(uses.size?{limits:[...uses]}:{}), + sources:[{origin:'bags',group:`bags:${section}`,groupName:section,label:`${section} · item level ${level}`}]}); + } + return {list:out,blocked}; +} + +// Each equipped item at every higher level of its upgrade track, within the crests that can be spent. +export function crestCandidates(profile,text,request,season,catalog){ + const crest=buildCrestCandidates(profile,text,request,season,catalog); + const list=crest.candidates.map(c=>({slot:c.slot,itemId:c.itemId,name:c.name,itemLevel:c.itemLevel,value:c.value,line:c.line, + upgrade:{track:c.track.name,from:c.from,to:c.to,max:c.max,fromItemLevel:c.fromItemLevel,crests:c.crests,fullCrests:c.fullCrests,currencyId:c.currencyId}, + sources:[{origin:'crests',group:`crests:${c.slot}`,groupName:`Upgrading ${c.name}`,label:`${c.track.name} ${c.from}/${c.max} to ${c.to}/${c.max} · ${c.crests} crest${c.crests===1?'':'s'}`}]})); + return {list,budget:crest.budget,currencies:crest.currencies}; +} + +// The three sources of a tank job, merged. `request` is {loot, bags, crests, finalists}: a source is on when its key is +// there. Returns null when none is asked for, which leaves the job as plain fights. +export function buildTankGear(profile,text,request,season,catalog,specId){ + const asked=request&&typeof request==='object'?request:null; + if(!asked||!(asked.loot||asked.bags||asked.crests))return null; + if(!season)throw new Error('Season loot data is not loaded.'); + const finalists=Number(asked.finalists??48);if(!upgradeLimits.finalists.includes(finalists))throw new Error('Choose a supported final round size.'); + const warnings=[],counts={loot:0,bags:0,crests:0};let blocked=0,freed=0; + const pool=[]; + if(asked.loot){ + const loot=buildCandidates(profile,{...asked.loot,finalists},season,catalog,specId); + pool.push(...loot.candidates);blocked+=loot.blocked;freed+=loot.freed;counts.loot=loot.candidates.length; + } + if(asked.bags){ + const bags=bagCandidates(profile,season,catalog); + pool.push(...bags.list);blocked+=bags.blocked;counts.bags=bags.list.length; + if(!bags.list.length)warnings.push((profile.alternatives||[]).some(a=>a.slot)?'Every bag item is already worn, or would break an equip limit, so there is nothing from your bags to try.':'This export lists no bag gear. Export again with the SimulationCraft addon and bags open to try what you carry.'); + } + if(asked.crests){ + // A character whose gear is not on an upgrade track cannot be upgraded; with other sources on, that is a note, not a failure. + try{const crests=crestCandidates(profile,text,asked.crests,season,catalog);pool.push(...crests.list);counts.crests=crests.list.length;} + catch(e){if(!asked.loot&&!asked.bags)throw e;warnings.push(`Crest upgrades: ${e.message}`);} + } + // The same item line from two places is one candidate with both sources. + const byLine=new Map(); + for(const c of pool){ + const id=c.freed?`${c.line}|${c.freed.slot}`:c.line,known=byLine.get(id); + if(!known)byLine.set(id,c); + else for(const s of c.sources)if(!known.sources.some(x=>x.label===s.label))known.sources.push(s); + } + const list=[...byLine.values()].sort((a,b)=>allSlots.indexOf(a.slot)-allSlots.indexOf(b.slot)||(Number(b.itemLevel)||0)-(Number(a.itemLevel)||0)||a.name.localeCompare(b.name)); + if(!list.length)throw new Error(warnings.length?warnings.join(' '):'No usable items were found in the selected sources for this character.'); + 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}; +} diff --git a/lib/upgradepage.mjs b/lib/upgradepage.mjs index 7982143..48d690f 100644 --- a/lib/upgradepage.mjs +++ b/lib/upgradepage.mjs @@ -8,7 +8,7 @@ const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format( const signed=(n,digits=2)=>`${n>=0?'+':''}${Number(n).toFixed(digits)}`; const classColors={deathknight:'#C41E3A',demonhunter:'#A330C9',druid:'#FF7C0A',evoker:'#33937F',hunter:'#AAD372',mage:'#3FC7EB',monk:'#00FF98',paladin:'#F48CBA',priest:'#DFDFDF',rogue:'#FFF468',shaman:'#0070DD',warlock:'#8788EE',warrior:'#C69B6D'}; const classNames={deathknight:'Death Knight',demonhunter:'Demon Hunter',druid:'Druid',evoker:'Evoker',hunter:'Hunter',mage:'Mage',monk:'Monk',paladin:'Paladin',priest:'Priest',rogue:'Rogue',shaman:'Shaman',warlock:'Warlock',warrior:'Warrior'}; -const originNames={raid:'Raid',mplus:'Mythic+',vault:'Great Vault',delves:'Delves',crafted:'Crafted'}; +const originNames={raid:'Raid',mplus:'Mythic+',vault:'Great Vault',delves:'Delves',crafted:'Crafted',bags:'Bags',crests:'Crest upgrades'}; const families=[['head','Head'],['neck','Neck'],['shoulder','Shoulders'],['back','Back'],['chest','Chest'],['wrist','Wrists'],['hands','Hands'],['waist','Waist'],['legs','Legs'],['feet','Feet'],['finger','Rings'],['trinket','Trinkets'],['main_hand','Main hand'],['off_hand','Off hand']]; const slotNames={finger1:'Ring 1',finger2:'Ring 2',trinket1:'Trinket 1',trinket2:'Trinket 2',...Object.fromEntries(families)}; const family=slot=>String(slot||'').replace(/[12]$/,''); diff --git a/package-lock.json b/package-lock.json index 26fabda..150ac4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.43.0", + "version": "1.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.43.0", + "version": "1.44.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 2a6cb51..d7aa8d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.43.0", + "version": "1.44.0", "private": true, "type": "module", "scripts": { diff --git a/public/app.js b/public/app.js index 12629e8..373e0a2 100644 --- a/public/app.js +++ b/public/app.js @@ -23,7 +23,7 @@ const $$=s=>[...document.querySelectorAll(s)]; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); const labels={head:'Head',neck:'Neck',shoulder:'Shoulders',back:'Back',chest:'Chest',wrist:'Wrists',hands:'Hands',waist:'Waist',legs:'Legs',feet:'Feet',finger1:'Ring 1',finger2:'Ring 2',main_hand:'Main hand',off_hand:'Offhand'}; -const modes={logs:['Log Analysis','Read a combat log and see what your rotation did, and what to change.'],quick:['Quick Sim','Import your character. Find out what your gear can do.'],enchants:['Enchant Lab','Test Midnight enchants, ranks and combinations on your character.'],compare:['Gear Compare','Compare current-expansion gear, gems, talents and consumables.'],tank:['Tank Sim','Your tank against a boss and its adds, all hitting you: damage taken, healing, deaths and how long you live.'],upgrades:['Upgrade Finder','Search raid, Mythic+, Great Vault, delve and crafted loot for your best simulated upgrades.'],crests:['Crest Planner','Find which equipped item to upgrade first with the crests you have.'],vault:['Great Vault','Simulate every choice in your Great Vault and see which one to take.'],weapons:['Weapon Lab','Rank this season’s weapons, off-hands and shields for every specialization at one item level.'],trinkets:['Trinket Lab','Find the best two trinkets for every specialization, per raid and Mythic+ scenario.'],talents:['Talent Search','Automatically generate legal builds and find the strongest tested talents for your gear.'],history:['History','Open previous results and download complete SimC reports.'],wow:['WoW addon','Bring your sims and gear farm into the game.']}; +const modes={logs:['Log Analysis','Read a combat log and see what your rotation did, and what to change.'],quick:['Quick Sim','Import your character. Find out what your gear can do.'],enchants:['Enchant Lab','Test Midnight enchants, ranks and combinations on your character.'],compare:['Gear Compare','Compare current-expansion gear, gems, talents and consumables.'],tank:['Tank Sim','Your tank against a boss and its adds, all hitting you: damage taken, healing, deaths and how long you live, with the gear that makes it better.'],upgrades:['Upgrade Finder','Search raid, Mythic+, Great Vault, delve and crafted loot for your best simulated upgrades.'],crests:['Crest Planner','Find which equipped item to upgrade first with the crests you have.'],vault:['Great Vault','Simulate every choice in your Great Vault and see which one to take.'],weapons:['Weapon Lab','Rank this season’s weapons, off-hands and shields for every specialization at one item level.'],trinkets:['Trinket Lab','Find the best two trinkets for every specialization, per raid and Mythic+ scenario.'],talents:['Talent Search','Automatically generate legal builds and find the strongest tested talents for your gear.'],history:['History','Open previous results and download complete SimC reports.'],wow:['WoW addon','Bring your sims and gear farm into the game.']}; const states={queued:'Queued',running:'Running',complete:'Complete',partial:'Partially complete',failed:'Failed',cancelled:'Cancelled',interrupted:'Interrupted on restart'}; let token='',engine=null,mode='quick',profile=null,importedText='',selections={},currentJob=null,pollTimer=null,previewTimer=null; let variants=[{name:'Alternative 1',text:''}]; @@ -35,12 +35,15 @@ async function setMode(value){mode=value;$$('.nav').forEach(b=>b.classList.toggl const lab=['weapons','trinkets'].includes(value); // Trinket Lab carries every setting it uses in its own panel, so the rest of the app's settings are out of sight. document.body.classList.toggle('clean-lab',value==='trinkets'); + // Tank Sim does the same: its page holds every setting, so the settings column and the shared buffs stay out of sight, + // and the boss settings move onto its page. + document.body.classList.toggle('clean-tank',value==='tank');tank.mount(value==='tank'?$('#tanksim-boss'):null); $('.import-panel').hidden=lab;tank.forWeapons(value==='weapons');syncTankStyles(); if(value==='history')await loadHistory();else if(value==='wow')await wow.refresh();else updateCount();} $$('.nav').forEach(b=>b.addEventListener('click',()=>setMode(b.dataset.mode).catch(e=>notice(e.message)))); -async function importProfile(){notice('');const text=$('#profile').value;const parsed=await api('/api/import',{profile:text});profile=parsed;importedText=text;selections={};safeStore('simc-lab-profile',text);$('#character').hidden=false;$('#character').innerHTML=`◈
${esc(parsed.info.name)}

${esc(parsed.info.race)} · ${esc(parsed.info.spec)} ${esc(parsed.info.class)} · Level ${esc(parsed.info.level)}

${parsed.armory?'Armory':''}${Object.keys(parsed.gear).length} gear slots`;$('#import-status').textContent=`Imported · ${Object.keys(parsed.gear).length} gear slots · ready to simulate`;renderEnchants();renderImportedChoices();features.render(parsed);crests.show(parsed);tank.show(parsed);syncTankStyles();updateCount();return parsed;} +async function importProfile(){notice('');const text=$('#profile').value;const parsed=await api('/api/import',{profile:text});profile=parsed;importedText=text;selections={};safeStore('simc-lab-profile',text);$('#character').hidden=false;$('#character').innerHTML=`◈
${esc(parsed.info.name)}

${esc(parsed.info.race)} · ${esc(parsed.info.spec)} ${esc(parsed.info.class)} · Level ${esc(parsed.info.level)}

${parsed.armory?'Armory':''}${Object.keys(parsed.gear).length} gear slots`;$('#import-status').textContent=`Imported · ${Object.keys(parsed.gear).length} gear slots · ready to simulate`;renderEnchants();renderImportedChoices();features.render(parsed);crests.show(parsed);tankSim.show(parsed);tank.show(parsed);syncTankStyles();updateCount();return parsed;} $('#import').addEventListener('click',async()=>{try{$('#import').disabled=true;await importProfile();}catch(e){notice(e.message);}finally{$('#import').disabled=false;}}); -$('#profile').addEventListener('input',()=>{$('#import-status').textContent='The profile changed. Import it again before selecting enchants.';profile=null;selections={};$('#character').hidden=true;renderEnchants();updateCount();}); +$('#profile').addEventListener('input',()=>{$('#import-status').textContent='The profile changed. Import it again before selecting enchants.';profile=null;selections={};$('#character').hidden=true;tankSim.show(null);renderEnchants();updateCount();}); $('#example').addEventListener('click',async()=>{try{$('#profile').value=(await api('/api/example')).text;await importProfile();$('#import-status').textContent='SimC example: MID2 Frost Mage. Replace it with your own /simc export.';}catch(e){notice(e.message);}}); function renderEnchants(){ if(!profile){$('#enchant-slots').innerHTML='

Import a character to select enchants.

';return;} @@ -61,9 +64,9 @@ 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];const route=$('#fight-style').value===routeStyle;return {profile:$('#profile').value,mode:mode==='tank'&&tankSim.upgrades()?'upgrades':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:mode==='tank'?tankSim.scenarios($('#fight-style').value):route?mplus.scenarios():scenarioTargets.map(n=>({style:$('#fight-style').value,targets:n,...(dummyStyles[$('#fight-style').value]?{dummyHealth:{mode:$('#dummy-health').value}}:{}),...($('#bleed-tail-on').checked?{bleedTail:Number($('#bleed-tail').value)}:{})}))};} -// Upgrade Finder's sources show in its own mode, and in Tank Sim while it is asked to find gear upgrades. -function syncUpgradePanel(value=mode){$('#upgrade-panel').hidden=!(value==='upgrades'||value==='tank'&&tankSim.upgrades());} +function request(){if(mode==='trinkets')return trinkets.request();if(mode==='tank')return {profile:$('#profile').value,tank:tank.settings(),...tankSim.request()};const targets=Number($('#targets').value);const scenarioTargets=dummyStyles[$('#fight-style').value]?[5]:$('#matrix').checked?[...new Set([targets,3,5])]:[targets];const route=$('#fight-style').value===routeStyle;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:route?mplus.scenarios():scenarioTargets.map(n=>({style:$('#fight-style').value,targets:n,...(dummyStyles[$('#fight-style').value]?{dummyHealth:{mode:$('#dummy-health').value}}:{}),...($('#bleed-tail-on').checked?{bleedTail:Number($('#bleed-tail').value)}:{})}))};} +// Upgrade Finder's sources show in its own mode only; Tank Sim carries its own copy of them on its own page. +function syncUpgradePanel(value=mode){$('#upgrade-panel').hidden=value!=='upgrades';} function updateCount(){ syncUpgradePanel(); syncGearSelection(); @@ -74,7 +77,7 @@ function updateCount(){ clearTimeout(previewTimer); if(mode==='crests'){$('#run-summary').textContent=profile?'Every upgrade level in one run per scenario':'Waiting for character import';crests.count(request,!!profile);} if(mode==='vault'){$('#run-summary').textContent=profile?'Every vault choice in one run per scenario':'Waiting for character import';vault.count(request,!!profile);} - if(mode==='tank'){const n=tankSim.chosen().length,find=tankSim.upgrades();$('#run-summary').textContent=!profile?'Waiting for character import':!profile.isTank?'Import a tank specialization':!n?'Choose at least one fight':find?'Screening + final round per fight':'One simulation per fight';$('#run-count').textContent=profile?`${find?2*n:n} run${(find?2*n:n)===1?'':'s'}`:'';if(find)upgrades.count(request,!!profile&&!!profile.isTank&&n>0);} + if(mode==='tank')tankSim.count(request); if(mode==='upgrades'){$('#run-summary').textContent=profile?'Screening + final round per scenario':'Waiting for character import';upgrades.count(request,!!profile);} // Weapon Lab needs no import: the reference profiles are always there, so the run summary never waits for one. if(mode==='weapons'){$('#run-summary').textContent='One tier list per specialization';$('#run-count').textContent='';weapons.count(request);} @@ -111,7 +114,7 @@ $('#bleed-tail-on').addEventListener('change',()=>{$('#bleed-tail-hint').hidden= $('#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();} + try{notice('');$('#run').disabled=true;tankSim.busy(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;tankSim.busy(false);updateCount();} } $('#run').addEventListener('click',runJob); $('#cancel').addEventListener('click',async()=>{try{if(currentJob){await api(`/api/jobs/${currentJob}/cancel`,{});await poll();}}catch(e){notice(e.message);}}); @@ -119,12 +122,12 @@ $('#job-progress').addEventListener('click',event=>{const id=event.target.closes async function watchJob(id){clearTimeout(pollTimer);currentJob=id;$('#results').hidden=false;await poll();} async function poll(){clearTimeout(pollTimer);try{const id=currentJob;const job=await api(`/api/jobs/${id}`);if(id!==currentJob)return;renderJob(job);if(['running','queued'].includes(job.status))pollTimer=setTimeout(poll,1200);}catch(e){notice(e.message);pollTimer=setTimeout(poll,3000);}} function renderJob(job){ - $('#job-status').textContent=states[job.status]||job.status;$('#job-log').textContent=job.log || (job.status==='queued'?'Waiting for earlier jobs to finish …':'Waiting for the engine …');$('#cancel').hidden=!['running','queued'].includes(job.status);trinkets.running(['running','queued'].includes(job.status)); + $('#job-status').textContent=states[job.status]||job.status;$('#job-log').textContent=job.log || (job.status==='queued'?'Waiting for earlier jobs to finish …':'Waiting for the engine …');$('#cancel').hidden=!['running','queued'].includes(job.status);trinkets.running(['running','queued'].includes(job.status));tankSim.running(['running','queued'].includes(job.status)); const waiting=job.queue?.ahead?.length?`
Waiting in the queue behind ${job.queue.ahead.length} job${job.queue.ahead.length===1?'':'s'}. Simulations run one at a time.${job.queue.ahead.map(j=>`
${esc(modes[j.mode]?.[0]||j.mode)} · ${esc(j.name)} · ${j.status==='running'?`running, ${j.done} / ${j.total}${j.current?' · '+esc(j.current):''}`:'queued'} `).join('')}
`:''; const progress=describeProgress(job);const scenario=job.current?.scenario?.route?.dungeonName?` · ${esc(job.current.scenario.route.dungeonName)} +${job.current.scenario.route.level}`:job.current?.scenario?` · ${esc(job.current.scenario.style)}, ${job.current.scenario.targets} target${job.current.scenario.targets===1?'':'s'}`:''; const took=job.started&&job.finished?` · took ${duration((new Date(job.finished)-new Date(job.started))/1000)}`:''; $('#job-progress').innerHTML=waiting+`
${esc(job.name)} · ${job.done} of ${job.total} steps done${scenario}${took}${progress.lines.map(l=>`
${l}`).join('')}${job.error?`
${esc(job.error)}`:''}
`; - let html=wow.sendButton(job)+tank.summary(job)+features.results(job)+upgrades.results(job)+tankSim.results(job)+crests.results(job)+vault.results(job)+weapons.results(job)+trinkets.results(job); + let html=wow.sendButton(job)+tank.summary(job)+features.results(job)+upgrades.results(job)+tankSim.results(job)+tankSim.loot.results(job)+crests.results(job)+vault.results(job)+weapons.results(job)+trinkets.results(job); if(job.settings.environment){const env=job.settings.environment;html+=`
Environment used for this job

External buffs: ${esc(Object.entries(env.buffs).filter(([,on])=>on).map(([key])=>key.replaceAll('_',' ')).join(', ')||'None')}
Bloodlust: ${env.buffs.bloodlust?esc(env.bloodlust.mode+' '+env.bloodlust.value):'No external override'} · Duration variation: ±${env.variation}%
Consumables: ${esc(Object.entries(env.consumables).map(([key,value])=>key.replaceAll('_',' ')+': '+value).join('; ')||'From profile / SimC')}

`;} for(let s=0;sr.scenario===s);if(!rows.length)continue; @@ -155,7 +158,7 @@ async function init(){ $('#wow-version').textContent=engine.installed||'Not found';$('#side-version').textContent=engine.version?`SimulationCraft ${engine.version}`:'Not installed';$('#engine-badge').textContent=engine.compatible&&engine.ready?'Live build verified':engine.ready?'Update SimC for your WoW version':'SimC not installed';$('#engine-badge').classList.toggle('warning',!engine.compatible||!engine.ready); // Without an engine only the install screen is useful. if(engineView.missing()){$('#workspace').hidden=true;return;} - await Promise.all([environment.init(),upgrades.init(),crests.init(),weapons.init(),trinkets.init()]);tank.init(environment.options()||{tankPresets:{}});$('#threads').value=engine.maxThreads;$('#threads').max=engine.maxThreads;if(engine.installed&&!engine.compatible)notice(`WoW ${engine.installed} and SimC ${engine.wowVersion} differ. Use Update SimC.`);renderVariants();try{$('#profile').value=localStorage.getItem('simc-lab-profile')||'';}catch{}if($('#profile').value)await importProfile();void wow.characters({autoLoad:true});updateCount();}catch(e){notice(e.message);} + await Promise.all([environment.init(),upgrades.init(),crests.init(),weapons.init(),trinkets.init()]);tank.init(environment.options()||{tankPresets:{}});await tankSim.init({options:environment.options()||{buffs:[],consumables:{}},engine});$('#threads').value=engine.maxThreads;$('#threads').max=engine.maxThreads;if(engine.installed&&!engine.compatible)notice(`WoW ${engine.installed} and SimC ${engine.wowVersion} differ. Use Update SimC.`);renderVariants();try{$('#profile').value=localStorage.getItem('simc-lab-profile')||'';}catch{}if($('#profile').value)await importProfile();void wow.characters({autoLoad:true});updateCount();}catch(e){notice(e.message);} } init(); @@ -171,7 +174,7 @@ $('#imported-choices').addEventListener('keydown',event=>{if(event.target.matche const features=featureUI({api,getProfile:()=>profile,notice,updateCount,addVariant:v=>{if(variants.length===1&&!variants[0].text)variants=[];variants.push(v);renderVariants();updateCount();}}); const upgrades=upgradeUI({api,notice,updateCount}); -const tankSim=tankSimUI({updateCount}); +const tankSim=tankSimUI({api,notice,updateCount,run:()=>runJob(),cancel:()=>$('#cancel').click()}); const crests=crestUI({api,notice,updateCount}); const vault=vaultUI({api,updateCount}); const weapons=weaponUI({api,notice,updateCount}); diff --git a/public/crests.js b/public/crests.js index 20524b0..81287fe 100644 --- a/public/crests.js +++ b/public/crests.js @@ -7,47 +7,51 @@ const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format( const signed=(n,digits=2)=>`${n>=0?'+':''}${n.toFixed(digits)}`; const plus=n=>`${n>=0?'+':''}${number(n)}`; -export function crestUI({api,notice,updateCount}){ - let data=null,countTimer=null,lastJob=null;const budgets=new Map(); - $('#quick-info').insertAdjacentHTML('beforebegin',``); +// Crest Planner has its own page. Tank Sim mounts the crest settings inside its own page (`host`), with its own fields: +// `sfx` keeps the ids apart. Only the settings are used there; Tank Sim shows the upgrades in its own results. +export function crestUI({api,notice,updateCount,host=null,sfx=''}){ + let data=null,countTimer=null,lastJob=null,scope=document;const budgets=new Map(); + const $=s=>scope.querySelector(s),$$=s=>[...scope.querySelectorAll(s)]; + if(host){host.innerHTML=`
Crest upgradesLoading season

Loading upgrade tracks …

`;scope=host;} + else{document.querySelector('#quick-info').insertAdjacentHTML('beforebegin',``);scope=document.querySelector('#crest-panel');} const icon=c=>c?.icon?``:''; const currency=id=>data?.crests.currencies.find(c=>c.id===Number(id)); const crestText=(amount,id)=>amount?`${icon(currency(id))}${number(amount)} ${esc(currency(id)?.name||'crests')}`:'No crests'; function render(){ - $('#crest-season').textContent=data.season.name; - $('#crest-options').innerHTML=`
+ $(`#crest-season${sfx}`).textContent=data.season.name; + $(`#crest-options${sfx}`).innerHTML=`

Crests to spend

${data.crests.currencies.map(c=>``).join('')}
-

Import a character. The SimulationCraft addon's export includes your crests and each slot's upgrade discount.

+

Import a character. The SimulationCraft addon's export includes your crests and each slot's upgrade discount.

Slots to include
${gearGroups.flatMap(([,slots])=>slots).map(s=>``).join('')}
-
+

One SimC run per scenario simulates every upgrade at your simulation settings. Enchants and gems stay as they are. An upgrade to an item level the slot has already held costs no crests, as in the game; the account-wide high watermark counts once the crest's warband achievement is done. The spending order treats gains in different slots as adding up.

`; } function settings(){ if(!data)return {}; const budget={};for(const el of $$('[data-crest-have]'))if(el.value!=='')budget[el.dataset.crestHave]=Number(el.value); - return {levels:$('#crest-levels').value,affordable:$('#crest-affordable').checked,slots:$$('[data-crest-slot]:checked').map(e=>e.value),budget:Object.keys(budget).length?budget:undefined}; + return {levels:$(`#crest-levels${sfx}`).value,affordable:$(`#crest-affordable${sfx}`).checked,slots:$$('[data-crest-slot]:checked').map(e=>e.value),budget:Object.keys(budget).length?budget:undefined}; } // Crests from the addon export fill the fields; what the player types afterwards wins. function show(profile){ if(!data)return; const state=profile?.upgradeState; for(const el of $$('[data-crest-have]')){const owned=state?.owned?.[el.dataset.crestHave];el.value=state?.owned?String(owned??0):'';} - $('#crest-state').textContent=!state?'Import a character.':state.exported?`Crests read from the export${state.watermarks?', with each slot\'s upgrade discount':''}. Lower them to keep some back, or change them if you have spent or earned some since.`:'The export has no crest data (the SimulationCraft addon writes it). Enter your crests to get a spending order; costs assume no discount.'; + $(`#crest-state${sfx}`).textContent=!state?'Import a character.':state.exported?`Crests read from the export${state.watermarks?', with each slot\'s upgrade discount':''}. Lower them to keep some back, or change them if you have spent or earned some since.`:'The export has no crest data (the SimulationCraft addon writes it). Enter your crests to get a spending order; costs assume no discount.'; } function count(request,hasProfile){ clearTimeout(countTimer);if(!data)return; - if(!hasProfile){$('#crest-items').innerHTML='';return;} - $('#crest-items').innerHTML='

Reading upgrade tracks …

'; + if(!hasProfile){$(`#crest-items${sfx}`).innerHTML='';return;} + $(`#crest-items${sfx}`).innerHTML='

Reading upgrade tracks …

'; countTimer=setTimeout(async()=>{try{ const preview=await api('/api/preview',request());const c=preview.crests; - $('#crest-items').innerHTML=`

${c.candidates} upgrades of ${c.items.length} items · ${preview.total} SimC run${preview.total===1?'':'s'}

${c.items.map(i=>``).join('')}
EquippedTrackItem levelCrests to the top
${esc(i.name)}${esc(slotNames[i.slot]||i.slot)}${esc(i.track)} ${i.level}/${i.max}${i.level===i.max?'Fully upgraded':c.affordable&&i.reach${i.reach>i.level?`Crests reach ${i.reach}/${i.max}`:'Not affordable'}`:''}${i.itemLevel}${i.level===i.max?'—':crestText(i.crests,i.currencyId)+(i.crests${number(i.fullCrests-i.crests)} waived by the slot discount`:'')}
`; - }catch(e){$('#crest-items').innerHTML=`

${esc(e.message)}

`;}},350); + $(`#crest-items${sfx}`).innerHTML=`

${c.candidates} upgrades of ${c.items.length} items · ${preview.total} SimC run${preview.total===1?'':'s'}

${c.items.map(i=>``).join('')}
EquippedTrackItem levelCrests to the top
${esc(i.name)}${esc(slotNames[i.slot]||i.slot)}${esc(i.track)} ${i.level}/${i.max}${i.level===i.max?'Fully upgraded':c.affordable&&i.reach${i.reach>i.level?`Crests reach ${i.reach}/${i.max}`:'Not affordable'}`:''}${i.itemLevel}${i.level===i.max?'—':crestText(i.crests,i.currencyId)+(i.crests${number(i.fullCrests-i.crests)} waived by the slot discount`:'')}
`; + }catch(e){$(`#crest-items${sfx}`).innerHTML=`

${esc(e.message)}

`;}},350); } async function init(){ try{data=await api('/api/upgrade-sources');render(); - $('#crest-options').addEventListener('change',()=>updateCount()); - $('#crest-options').addEventListener('click',e=>{const all=e.target.hasAttribute('data-crest-all'),none=e.target.hasAttribute('data-crest-none');if(!all&&!none)return;e.preventDefault();$$('[data-crest-slot]').forEach(el=>el.checked=all);updateCount();}); - }catch(e){$('#crest-options').textContent=e.message;notice(e.message);} + $(`#crest-options${sfx}`).addEventListener('change',()=>updateCount()); + $(`#crest-options${sfx}`).addEventListener('click',e=>{const all=e.target.hasAttribute('data-crest-all'),none=e.target.hasAttribute('data-crest-none');if(!all&&!none)return;e.preventDefault();$$('[data-crest-slot]').forEach(el=>el.checked=all);updateCount();}); + }catch(e){$(`#crest-options${sfx}`).textContent=e.message;notice(e.message);} } // Measured upgrades of one scenario: gain against the baseline, and what it costs from the equipped level. @@ -101,13 +105,13 @@ export function crestUI({api,notice,updateCount}){ } return `
${html}
`; } - $('#result-content').addEventListener('input',event=>{ + if(!host)document.querySelector('#result-content').addEventListener('input',event=>{ const el=event.target;if(!el.matches('[data-plan-have]')||!lastJob)return; const budget={};for(const input of el.closest('.crest-budget').querySelectorAll('[data-plan-have]'))if(input.value!==''&&Number(input.value)>=0)budget[input.dataset.planHave]=Math.floor(Number(input.value)); budgets.set(lastJob.id,Object.keys(budget).length?budget:null); // Only the plan is drawn again, so the field being typed in keeps its focus. const section=el.closest('[data-crest-scenario]'); - for(const other of $$('#crest-results [data-crest-scenario]')){ + for(const other of document.querySelectorAll('#crest-results [data-crest-scenario]')){ const n=Number(other.dataset.crestScenario),m=measured(lastJob,n);if(!m)continue; const box=other.querySelector('.crest-plan-box');const focus=other===section?el.dataset.planHave:null;const caret=el.selectionStart; box.innerHTML=plan(lastJob,n,m.rows); diff --git a/public/style.css b/public/style.css index 46e087a..ddb4fa5 100644 --- a/public/style.css +++ b/public/style.css @@ -136,3 +136,25 @@ .bleed-tail{display:flex;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap}.bleed-tail .inline{display:flex;align-items:center;gap:6px;font-size:12px}.bleed-tail .inline input{width:64px;padding:6px} .side-by-side{display:grid;grid-template-columns:1fr 1fr;gap:14px}.side-by-side h5{margin:0 0 8px;font-size:11px;letter-spacing:1px;color:var(--muted);text-transform:uppercase}.cast-list{list-style:none;margin:0;padding:0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:6px;font-size:12px}.cast-list li{display:flex;gap:10px;padding:5px 10px;border-bottom:1px solid #222b3a}.cast-list span{color:var(--muted);width:44px;flex-shrink:0;font-variant-numeric:tabular-nums}.cast-list small{margin-left:auto;color:var(--muted)}.cmp-legend{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0}.cmp{font-size:11px;padding:4px 8px;border-radius:12px;border:1px solid var(--line)}.cmp.same,.cmp-row.same td:first-child,.cmp-mark.same{--c:#4f9d7c}.cmp.small,.cmp-row.small td:first-child,.cmp-mark.small{--c:#d9b45a}.cmp.large,.cmp-row.large td:first-child,.cmp-mark.large{--c:#e0745e}.cmp.extra,.cmp-row.extra td:first-child,.cmp-mark.extra{--c:#6b9bd9}.cmp.missed,.cmp-row.missed td:first-child,.cmp-mark.missed{--c:#b07fd6}.cmp{border-color:var(--c);color:var(--c)}.cmp-table td{text-align:left!important;white-space:normal!important;padding:8px 6px}.cmp-row td:first-child{border-left:3px solid var(--c)}.cmp-row b{color:var(--muted);font-weight:500;margin-right:4px}.cmp-row.same td{color:#9fb0c4}.cmp-tag{font-size:11px;color:var(--muted);margin-right:6px}.cmp-row.large .cmp-tag{color:#e0745e}.cmp-timeline{display:grid;gap:8px;margin-top:6px}.cmp-timeline .lane>div{height:22px}.cmp-mark{position:absolute;top:3px;width:3px;height:14px;background:var(--c);border-radius:1px;cursor:help}.cmp-mark.same{opacity:.55}@media(max-width:720px){.side-by-side{grid-template-columns:1fr}} #log-side .engine-actions .button,#log-sim .engine-actions .button{width:auto}.cmp-timeline{overflow-x:auto;padding-bottom:6px}.cmp-timeline .lane{grid-template-columns:60px minmax(1800px,1fr)} + +.clean-tank .settings-column,.clean-tank #environment-panel{display:none!important} +.clean-tank .workspace{grid-template-columns:minmax(0,1fr)} +.tank-sections{display:grid;gap:14px;margin-top:6px} +.tank-section{border:1px solid var(--line);border-radius:8px;background:#141a25;padding:16px 18px;min-width:0} +.tank-section>h3{margin:0 0 10px;font-size:11px;letter-spacing:1.6px;text-transform:uppercase;color:var(--amber)} +.tank-section .hint{margin:4px 0 10px} +.tank-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:10px 0} +.tank-grid label{margin:0} +.tank-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:10px 0} +.tank-source{border-top:1px solid #222b3a;padding:12px 0} +.tank-source:first-of-type{border-top:0} +.tank-more{margin:8px 0 0 26px} +.tank-more>summary{cursor:pointer;color:var(--muted);font-size:12px} +.tank-source.off .tank-more{opacity:.45;pointer-events:none} +.tank-run{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin-top:18px;padding-top:18px;border-top:1px solid var(--line)} +.tank-run .run-actions{display:flex;gap:12px;align-items:center} +.tank-run .run-actions .primary{width:auto;min-width:220px;gap:18px} +.tank-run .run-actions .danger{margin-top:0;width:auto} +#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} diff --git a/public/tank.js b/public/tank.js index e51bb79..3ed7bf8 100644 --- a/public/tank.js +++ b/public/tank.js @@ -25,6 +25,8 @@ export function tankUI({updateCount}){ function label(){const w=Number($('#tank-weight').value);$('#tank-weight-label').textContent=`${w}% survival · ${100-w}% DPS`;} return { init(options){presets=options.tankPresets;render();}, + // Tank Sim carries the boss settings on its own page; every other mode keeps them above the results. + mount(slot){const panel=$('#tank-panel');panel.classList.toggle('embedded',!!slot);if(slot)slot.appendChild(panel);else $('#results').insertAdjacentElement('beforebegin',panel);}, show(profile){active=!!profile?.isTank;scope();}, forWeapons(on){weapons=!!on;scope();}, settings(){ diff --git a/public/tanksim.js b/public/tanksim.js index 0b62783..195cd59 100644 --- a/public/tanksim.js +++ b/public/tanksim.js @@ -1,5 +1,7 @@ -import {signed} from '/tank.js'; +import {upgradeUI} from '/upgrades.js'; +import {crestUI} from '/crests.js'; const $=s=>document.querySelector(s); +const $$=s=>[...document.querySelectorAll(s)]; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); @@ -9,22 +11,113 @@ export const fights=[ {id:'pack',label:'Boss + 2 adds',targets:3,note:'A small pack: two mobs swing at you for the whole fight on top of the boss.'}, {id:'pull',label:'Boss + 4 adds',targets:5,note:'A big pull: four mobs swing at you for the whole fight on top of the boss.'} ]; +// Only the fight styles where the boss and its adds can attack a tank; the others have no one hitting you. +const styles=[['Patchwerk','Patchwerk · stationary'],['CastingPatchwerk','Casting Patchwerk · boss casts'],['LightMovement','Light movement'],['HeavyMovement','Heavy movement']]; +const consumableKinds=[['flask','Flask'],['food','Food'],['potion','Potion'],['augmentation','Augment rune']]; -// Tank Sim: one imported tank, run against a boss and its adds, with the numbers a tank cares about. -export function tankSimUI({updateCount}){ - $('#quick-info').insertAdjacentHTML('beforebegin',``); - $('#tanksim-panel').addEventListener('change',()=>updateCount()); +// Tank Sim is a page of its own: its fights, its simulation settings, its buffs, its boss and its gear sources all live +// here, and nothing in the settings column beside the other modes is used. The boss settings (see tank.js) move in. +export function tankSimUI({api,notice,updateCount,run,cancel}){ + let countTimer=null,profile=null; + $('#quick-info').insertAdjacentHTML('beforebegin',``); + 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'}); + $('#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(); + }); + $('#tanksim-panel').addEventListener('input',event=>{if(event.target.matches('input[type=number]'))updateCount();}); + $('#tank-run').addEventListener('click',()=>run()); + $('#tank-cancel').addEventListener('click',()=>cancel()); const chosen=()=>fights.filter(f=>$(`[data-tanksim="${f.id}"]`)?.checked); - const row=(job,s)=>job.results.find(r=>r.scenario===s&&r.baseline&&r.status==='complete'); - const upgrades=()=>!!$('[data-tanksim-upgrades]')?.checked; + const row=(job,s)=>{ + const plain=job.results.find(r=>r.scenario===s&&r.baseline&&r.status==='complete');if(plain)return plain; + // With gear searched, the baseline comes with each round; the last one is the most precise. + const last=(job.stages||[]).filter(st=>st.scenario===s&&st.status==='complete'&&st.baseline).sort((a,b)=>b.stage-a.stage)[0]; + return last?{...last.baseline,scenario:s,baseline:true,status:'complete'}:null; + }; + function environment(){ + return {buffs:Object.fromEntries($$('[data-tank-buff]').map(el=>[el.dataset.tankBuff,el.checked])),consumables:Object.fromEntries($$('[data-tank-consumable]').map(el=>[el.dataset.tankConsumable,el.value])),bloodlust:{mode:'pull',value:0},variation:20}; + } + // The sources that are switched on; none at all means plain fights. + function gear(){ + const out={finalists:Number(loot.settings().finalists)||48}; + if($('#tank-bags').checked)out.bags={}; + if($('#tank-crests').checked)out.crests=crests.settings(); + if($('#tank-loot').checked)out.loot=loot.settings(); + return out; + } + const sources=()=>[$('#tank-bags'),$('#tank-crests'),$('#tank-loot')].filter(el=>el.checked).length; return { - chosen,upgrades, - // One scenario per fight, in the fight style chosen in the settings. - scenarios(style,extra={}){return chosen().map(f=>({style,targets:f.targets,...extra}));}, + chosen,gear:()=>sources()>0, + async init({options,engine}){ + await Promise.all([loot.init(),crests.init()]); + const buffs=['Raid buffs','Target debuffs'].map(group=>`

${group}

${options.buffs.filter(b=>b.group===group).map(b=>``).join('')}
`).join(''); + const consumables=consumableKinds.map(([key,label])=>``).join(''); + $('#tank-environment').innerHTML=`
${buffs}
${consumables}

Disabling a buff removes the assumed external one; your tank can still provide its own. Bloodlust comes at the pull. These settings are only used by Tank Sim.

`; + $('#tank-environment').addEventListener('click',event=>{const all=event.target.closest('[data-tank-buffs]');if(!all)return;event.preventDefault();$$('[data-tank-buff]').forEach(el=>el.checked=all.dataset.tankBuffs==='1');updateCount();}); + if(engine?.maxThreads){$('#tank-threads').value=engine.maxThreads;$('#tank-threads').max=engine.maxThreads;} + }, + // A new character: crests fill from its export, and the bags say what is in them. + show(parsed){ + profile=parsed;crests.show(parsed); + $('#tanksim-need').hidden=!parsed||!!parsed.isTank; + const bags=(parsed?.alternatives||[]).filter(a=>a.slot); + const sections=[...bags.reduce((m,a)=>m.set(a.section||'Bags',(m.get(a.section||'Bags')||0)+1),new Map())].map(([name,n])=>`${n} in ${esc(name)}`); + $('#tank-bags-note').innerHTML=!parsed?' · Import a character to see what you carry.':bags.length?` · ${bags.length} item${bags.length===1?'':'s'} in the export: ${sections.join(', ')}.`:' · This export lists no bag gear. Export with the SimulationCraft addon to try what you carry.'; + }, + // Everything the server needs for a tank job except the character and the boss, which the page adds. + request(){ + const style=$('#tank-style').value; + return {mode:'tank',iterations:Number($('#tank-iterations').value),targetError:Number($('#tank-target-error').value),duration:Number($('#tank-duration').value),threads:Number($('#tank-threads').value), + environment:environment(),scenarios:chosen().map(f=>({style,targets:f.targets})),tankGear:gear()}; + }, + // What a run will be, from the server's own count, so the numbers match what will run. + count(request){ + clearTimeout(countTimer); + const n=chosen().length; + const say=(summary,count='',detail='')=>{$('#tank-run-summary').textContent=summary;$('#tank-run-count').textContent=count?` · ${count}`:'';$('#tank-gear-count').innerHTML=detail;}; + $('#tank-run').disabled=!profile||!profile.isTank||!n; + if(!profile)return say('Waiting for character import'); + if(!profile.isTank)return say('Import a tank specialization'); + if(!n)return say('Choose at least one fight'); + say(sources()?'Counting what to try …':'One simulation per fight',sources()?'':`${n} run${n===1?'':'s'}`); + if(!sources())return; + countTimer=setTimeout(async()=>{try{ + const preview=await api('/api/preview',request());const u=preview.upgrade;if(!u)return; + const c=u.counts||{};const parts=[['bags','from your bags'],['crests','crest upgrades'],['loot','from instances']].filter(([k])=>c[k]!==undefined&&$(`#tank-${k}`).checked).map(([k,label])=>`${c[k]} ${label}`); + say('Screening and a final round per fight',`${preview.total} SimC runs`,`${u.candidates} candidates across ${u.slots} slots${parts.length?` (${parts.join(', ')})`:''}${u.blocked?` · ${u.blocked} left out by the equip limit`:''}${(u.warnings||[]).map(w=>`
${esc(w)}`).join('')}`); + }catch(e){say('Nothing to run yet','',esc(e.message));}},350); + }, + busy(on){$('#tank-run').disabled=!!on;}, + running(on){$('#tank-cancel').hidden=!on;}, results(job){ if(job.mode!=='tank')return ''; const t=job.settings?.tank; - let html=`
Tank Sim · ${esc(job.name)}

${chosen().length||job.scenarios.length} fight${job.scenarios.length===1?'':'s'}, each one simulation of ${number(job.settings.iterations)} iterations at most, ${job.settings.duration} sec. The adds hit for ${t?.addDamage??40}% of a boss swing each, for the whole fight.

`; + let html=`
Tank Sim · ${esc(job.name)}

${job.scenarios.length} fight${job.scenarios.length===1?'':'s'}, ${job.settings.duration} sec, up to ${number(job.settings.iterations)} iterations each. The adds hit for ${t?.addDamage??40}% of a boss swing each, for the whole fight.${job.upgrade?' Every fight also searches your gear, below.':''}

`; const rows=job.scenarios.map((sc,s)=>({sc,r:row(job,s)})).filter(x=>x.r?.tank); if(!rows.length)return html+'

The fights show here as they finish.

'; html+=`${rows.map(({sc,r})=>{ @@ -32,6 +125,7 @@ export function tankSimUI({updateCount}){ const pct=n=>health?`${(100*n/health).toFixed(1)}% of your health`:''; return ``;}).join('')}
FightDamage takenHealing and absorbsSurvivedDeathsYour DPS
${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)}

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; - } + }, + loot }; } diff --git a/public/upgrades.js b/public/upgrades.js index 939ef58..217ca74 100644 --- a/public/upgrades.js +++ b/public/upgrades.js @@ -3,11 +3,16 @@ const $=s=>document.querySelector(s); const $$=s=>[...document.querySelectorAll(s)]; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); -const originNames={raid:'Raid',mplus:'Mythic+',delves:'Delves',vault:'Great Vault',crafted:'Crafted'}; +const originNames={raid:'Raid',mplus:'Mythic+',delves:'Delves',vault:'Great Vault',crafted:'Crafted',bags:'Bags',crests:'Crest upgrades'}; -export function upgradeUI({api,notice,updateCount}){ - let data=null,countTimer=null,slotFilter='all',lastJob=null; - $('#quick-info').insertAdjacentHTML('beforebegin',``); +// Upgrade Finder has its own page. Tank Sim mounts the same loot picker inside its own page (`host`), with its own +// state: `sfx` keeps the ids apart, and `owner` decides which jobs each one shows results for. +export function upgradeUI({api,notice,updateCount,host=null,sfx='',owner='upgrades'}){ + let data=null,countTimer=null,slotFilter='all',lastJob=null,scope=document; + const mine=job=>owner==='tank'?job.mode==='tank':job.mode!=='tank'; + const $=s=>scope.querySelector(s),$$=s=>[...scope.querySelectorAll(s)]; + if(host){host.innerHTML=`
Loot from instancesLoading season

Loading loot tables …

`;scope=host;} + else{document.querySelector('#quick-info').insertAdjacentHTML('beforebegin',``);scope=document.querySelector('#upgrade-panel');} const trackOptions=selected=>data.tracks.map(t=>``).join(''); const levelOptions=(trackId,selected)=>(data.tracks.find(t=>t.id===Number(trackId))||data.tracks[0]).levels.map(l=>``).join(''); const trackPicker=(name,trackId,level)=>`
`; @@ -23,16 +28,16 @@ export function upgradeUI({api,notice,updateCount}){ function render(){ const hero=(data.tracks.find(t=>t.name==='Hero')||data.tracks.at(-1)).id,myth=data.tracks.at(-1).id; const heroTop=data.tracks.find(t=>t.id===hero).levels.at(-1).itemLevel; - $('#upgrade-season').textContent=data.season.name; - $('#upgrade-sources').innerHTML=`
-

${esc(data.raids.map(r=>r.name).join(' · '))}. Tier tokens become your class's set piece.

${esc(dropNote(data.difficulties.at(-1).track))}

Bosses
${data.raids.map(r=>`

${esc(r.name)}

${groupChecks('raid',r.encounters)}`).join('')}
+ $(`#upgrade-season${sfx}`).textContent=data.season.name; + $(`#upgrade-sources${sfx}`).innerHTML=`
+

${esc(data.raids.map(r=>r.name).join(' · '))}. Tier tokens become your class's set piece.

${esc(dropNote(data.difficulties.at(-1).track))}

Bosses
${data.raids.map(r=>`

${esc(r.name)}

${groupChecks('raid',r.encounters)}`).join('')}

${data.dungeons.length} dungeons in the current rotation, including reissued older dungeons.

${trackPicker('mplus',hero,1)}
Dungeons
${groupChecks('mplus',data.dungeons)}

Any boss or dungeon item from the full season loot tables, one track per vault row.

${[['raid','Raid row'],['mplus','Dungeon row'],['delves','World row']].filter(([row])=>row!=='delves'||data.delves).map(([row,label])=>`
${trackPicker('vault-'+row,myth,1)}
`).join('')}
${data.delves?`

${esc(data.delves.name)} loot table.

${trackPicker('delves',hero,1)}
`:''} - ${data.crafted?`

Epic profession gear. Some pieces require the matching profession to equip. Pieces with open secondary stats are tried in every legal pair unless you pick one.

${embellishmentPicker()}
`:''} + ${data.crafted?`

Epic profession gear. Some pieces require the matching profession to equip. Pieces with open secondary stats are tried in every legal pair unless you pick one.

${embellishmentPicker()}
`:''}
Slots to search
${gearGroups.flatMap(([,slots])=>slots).map(s=>``).join('')}
-
Import a character to count candidates.
+
${host?'':'Import a character to count candidates.'}

Screening runs every candidate with at most 2,000 iterations and a 0.5% target error. Candidates that could beat your gear within that uncertainty go to the final round with your simulation settings. Weapons are compared like for like with what you wield. Your enchant carries over, and existing gems carry over into sockets the new item already has. Crafted pieces are tried with the embellishments you choose, within the equip limit. Vault sockets are not added.

`; } const checked=name=>$$(`[data-group="${name}"]:checked`).map(e=>Number(e.value)||e.value); @@ -40,32 +45,32 @@ export function upgradeUI({api,notice,updateCount}){ const on=name=>!!$(`[data-source="${name}"]`)?.checked; function settings(){ if(!data)return {}; - const result={slots:checked('slot'),finalists:Number($('#upgrade-finalists').value)}; + const result={slots:checked('slot'),finalists:Number($(`#upgrade-finalists${sfx}`).value)}; result.raid={enabled:on('raid'),difficulty:Number($('[data-difficulty="raid"]').value),upgrade:Number($('[data-upgrade="raid"]').value),encounters:checked('raid')}; result.mplus={enabled:on('mplus'),...pick('mplus'),dungeons:checked('mplus')}; result.vault={enabled:on('vault')};for(const row of ['raid','mplus','delves'])if($(`[data-vault-row="${row}"]`))result.vault[row]={enabled:$(`[data-vault-row="${row}"]`).checked,...pick('vault-'+row)}; if(data.delves)result.delves={enabled:on('delves'),...pick('delves')}; - if(data.crafted)result.crafted={enabled:on('crafted'),itemLevel:Number($('#crafted-ilevel').value),stats:$('#crafted-stats').value==='all'?'all':Number($('#crafted-stats').value),...(data.embellishments?.length?{embellishments:checked('emb')}:{})}; + if(data.crafted)result.crafted={enabled:on('crafted'),itemLevel:Number($(`#crafted-ilevel${sfx}`).value),stats:$(`#crafted-stats${sfx}`).value==='all'?'all':Number($(`#crafted-stats${sfx}`).value),...(data.embellishments?.length?{embellishments:checked('emb')}:{})}; return result; } function changed(event){ - if(event.target.dataset.difficulty){const up=$('[data-upgrade="raid"]');up.innerHTML=upgradeOptions(event.target.value,Number(up.value));$('#raid-drop-note').textContent=dropNote(event.target.value);} + if(event.target.dataset.difficulty){const up=$('[data-upgrade="raid"]');up.innerHTML=upgradeOptions(event.target.value,Number(up.value));$(`#raid-drop-note${sfx}`).textContent=dropNote(event.target.value);} const track=event.target.dataset.track; if(track){const level=$(`[data-level="${track}"]`);const previous=Number(level.value);level.innerHTML=levelOptions(event.target.value,previous);} updateCount(); } async function init(){ try{data=await api('/api/upgrade-sources');render(); - $('#upgrade-sources').addEventListener('change',changed);$('#upgrade-sources').addEventListener('input',e=>{if(e.target.id==='crafted-ilevel')updateCount();}); - $('#upgrade-sources').addEventListener('click',e=>{const all=e.target.dataset.all,none=e.target.dataset.none;if(!all&&!none)return;e.preventDefault();$$(`[data-group="${all||none}"]`).forEach(el=>el.checked=!!all);updateCount();}); - }catch(e){$('#upgrade-sources').textContent=e.message;notice(e.message);} + $(`#upgrade-sources${sfx}`).addEventListener('change',changed);$(`#upgrade-sources${sfx}`).addEventListener('input',e=>{if(e.target.id===`crafted-ilevel${sfx}`)updateCount();}); + $(`#upgrade-sources${sfx}`).addEventListener('click',e=>{const all=e.target.dataset.all,none=e.target.dataset.none;if(!all&&!none)return;e.preventDefault();$$(`[data-group="${all||none}"]`).forEach(el=>el.checked=!!all);updateCount();}); + }catch(e){$(`#upgrade-sources${sfx}`).textContent=e.message;notice(e.message);} } // Candidate counts come from the server so the numbers match what will run. function count(request,hasProfile){ clearTimeout(countTimer);if(!data)return; - if(!hasProfile){$('#upgrade-count').textContent='Import a character to count candidates.';return;} - $('#upgrade-count').textContent='Counting candidates …'; - countTimer=setTimeout(async()=>{try{const preview=await api('/api/preview',request());const u=preview.upgrade;$('#upgrade-count').innerHTML=`${u.candidates} candidates across ${u.slots} slots · ${preview.total} SimC runs${u.embellished?`
${u.embellished} with an embellishment`:''}${u.limitsUsed?.length?`
You wear: ${u.limitsUsed.map(h=>esc(h.name)).join(', ')}`:''}${u.freed?`
${u.freed} worn in place of an embellishment you wear now`:''}${u.blocked?` · ${u.blocked} left out by the equip limit`:''}`;}catch(e){$('#upgrade-count').textContent=e.message;}},350); + if(!hasProfile){$(`#upgrade-count${sfx}`).textContent='Import a character to count candidates.';return;} + $(`#upgrade-count${sfx}`).textContent='Counting candidates …'; + countTimer=setTimeout(async()=>{try{const preview=await api('/api/preview',request());const u=preview.upgrade;$(`#upgrade-count${sfx}`).innerHTML=`${u.candidates} candidates across ${u.slots} slots · ${preview.total} SimC runs${u.embellished?`
${u.embellished} with an embellishment`:''}${u.limitsUsed?.length?`
You wear: ${u.limitsUsed.map(h=>esc(h.name)).join(', ')}`:''}${u.freed?`
${u.freed} worn in place of an embellishment you wear now`:''}${u.blocked?` · ${u.blocked} left out by the equip limit`:''}`;}catch(e){$(`#upgrade-count${sfx}`).textContent=e.message;}},350); } // Rings and trinkets are simulated in both of their slots; the filter treats each pair as one slot. const family=slot=>String(slot||'').replace(/[12]$/,''); @@ -84,10 +89,10 @@ export function upgradeUI({api,notice,updateCount}){ } function results(job){ - if(!job.upgrade)return ''; + if(!job.upgrade||!mine(job))return ''; lastJob=job; const candidates=new Map(job.upgrade.candidates.map(c=>[c.key,c]));const screen=job.upgrade.screen; - let html=`
Upgrade Finder · ${job.upgrade.candidates.length} candidates · ${esc(job.upgrade.season?.name)}

Screening: up to ${number(screen.iterations)} iterations, ${screen.targetError}% target error. Final round: up to ${job.upgrade.finalists} candidates with ${number(job.settings.iterations)} iterations and ${job.settings.targetError}% target error.

`; + let html=`
${owner==='tank'?'Gear for your tank':'Upgrade Finder'} · ${job.upgrade.candidates.length} candidates · ${esc(job.upgrade.season?.name)}

Screening: up to ${number(screen.iterations)} iterations, ${screen.targetError}% target error. Final round: up to ${job.upgrade.finalists} candidates with ${number(job.settings.iterations)} iterations and ${job.settings.targetError}% target error.

`; for(let s=0;sr.scenario===s);if(!stages.length)continue; const baselines={};for(const st of stages)if(st.baseline)baselines[st.stage]=st.baseline; @@ -151,7 +156,7 @@ export function upgradeUI({api,notice,updateCount}){ html+=`

${stages.filter(st=>st.stem).map(st=>`${stageName(st.stage)} (${st.count}): HTML JSON Input`).join(' · ')}

`; } if(['complete','partial'].includes(job.status)&&job.results.some(r=>r.stage===2&&r.status==='complete'))html=``+html; - return `
${html}
`; + return `
${html}
`; } // The best embellished upgrades worn two at a time. Each pair is one simulation against the current gear. function pairsSection(job,s,candidates,base){ @@ -167,11 +172,11 @@ export function upgradeUI({api,notice,updateCount}){ const max=Math.max(...rows.map(r=>Math.abs(r.rank)),1e-9);const tanky=rows.some(r=>r.tanky); return `${rows.map((r,i)=>``).join('')}
ItemSource${tanky?'Score':'vs current'}
${itemLink(r.c.itemId,r.c.name,r.c.value)}${esc(slotNames[r.c.slot]||r.c.slot)} · item level ${r.c.itemLevel}${r.c.embellishment?` · ${esc(r.c.embellishment)}`:''}${freedNote(r.c)}${r.stage===1?' · screening only':''}${r.rank>0?`
`:''}
${r.c.sources.map(src=>`${esc(src.label)}`).join('')}${r.tanky?`${r.score>=0?'+':''}${r.score.toFixed(2)}DPS ${r.dpsGain>=0?'+':''}${r.dpsGain.toFixed(2)} % · survival ${r.survival>=0?'+':''}${r.survival.toFixed(2)} %${r.uncertain?' · uncertain':''}`:`${r.delta>=0?'+':''}${r.percent.toFixed(2)} %${r.delta>=0?'+':''}${number(r.delta)} DPS${r.uncertain?' · uncertain':''}`}
`; } - $('#result-content').addEventListener('click',event=>{ + document.querySelector('#result-content').addEventListener('click',event=>{ const chip=event.target.closest('[data-slot-filter]'); - if(!chip||!lastJob)return; + const container=document.querySelector(`#upgrade-results${sfx}`); + if(!chip||!lastJob||!container?.contains(chip))return; slotFilter=chip.dataset.slotFilter; - const container=$('#upgrade-results'); if(container)container.outerHTML=results(lastJob); }); diff --git a/server.mjs b/server.mjs index 757fefb..8419c7e 100644 --- a/server.mjs +++ b/server.mjs @@ -186,7 +186,7 @@ const server=http.createServer(async(req,res)=>{ steps:levelSteps(season,season.tracks.map(t=>t.id)),defaultSteps:levelSteps(season).map(s=>s.track),craftedCap:await craftedItemLevel(engine.source),limits:trinketLimits,scenarios:scenarioPresets,engine:await engineStatus().then(e=>({version:e.version,commit:e.commit,maxThreads:e.maxThreads})),tankPresets,season:season.season}); } if(req.method==='POST'&&route==='/api/preview'){ - const plan=await prepare(await body(req),catalog,talentData,season);const upgrade=plan.upgrade&&{candidates:plan.upgrade.candidates.length,slots:new Set(plan.upgrade.candidates.map(c=>c.slot)).size,finalists:plan.upgrade.finalists,embellished:plan.upgrade.candidates.filter(c=>c.embellishment).length,blocked:plan.upgrade.blocked,freed:plan.upgrade.freed,limitsUsed:plan.upgrade.limitsUsed,steps:upgradeSteps(plan.upgrade,plan.scenarios.length)}; + const plan=await prepare(await body(req),catalog,talentData,season);const upgrade=plan.upgrade&&{candidates:plan.upgrade.candidates.length,slots:new Set(plan.upgrade.candidates.map(c=>c.slot)).size,finalists:plan.upgrade.finalists,embellished:plan.upgrade.candidates.filter(c=>c.embellishment).length,blocked:plan.upgrade.blocked,freed:plan.upgrade.freed,counts:plan.upgrade.counts,warnings:plan.upgrade.warnings,limitsUsed:plan.upgrade.limitsUsed,steps:upgradeSteps(plan.upgrade,plan.scenarios.length)}; const crests=plan.crests&&{affordable:plan.crests.affordable,candidates:plan.crests.candidates.length,items:plan.crests.items,budget:plan.crests.budget,state:plan.crests.state}; const vault=plan.vault&&{candidates:plan.vault.candidates.length,items:plan.vault.items,skipped:plan.vault.skipped,upgraded:plan.vault.upgraded}; const weapons=plan.weapons&&{specs:plan.weapons.specs.length,candidates:plan.weapons.candidates,skipped:plan.weapons.skipped,tanks:plan.weapons.specs.filter(s=>s.tank).length,healers:plan.weapons.specs.filter(s=>s.healer).length,craftedStats:plan.weapons.craftedStats.length,sources:plan.weapons.sources,levels:plan.weapons.levels,steps:weaponSteps(plan.weapons,plan.scenarios.length)}; diff --git a/tests/tank-gear-integration.mjs b/tests/tank-gear-integration.mjs new file mode 100644 index 0000000..d60abbc --- /dev/null +++ b/tests/tank-gear-integration.mjs @@ -0,0 +1,41 @@ +// Tank Sim with gear: bags, crest upgrades and instance loot in one tank job. Needs the server on 127.0.0.1:8642 and SimC installed. +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +const base='http://127.0.0.1:8642'; +const {token}=await(await fetch(base+'/api/status')).json(); +const post=async(route,data,ok=true)=>{const r=await fetch(base+route,{method:'POST',headers:{'Content-Type':'application/json','X-SimC-Token':token},body:JSON.stringify(data)});const body=await r.json();if(ok)assert.ok(r.ok,body.error||route);return {status:r.status,body};}; +const wait=async id=>{let job;for(let i=0;i<1800;i++){job=await(await fetch(base+'/api/jobs/'+id)).json();if(!['queued','running'].includes(job.status))return job;await new Promise(r=>setTimeout(r,1000));}throw new Error('The job did not finish.');}; +const source=await fs.readFile(new URL('../profiles/druid-guardian.simc',import.meta.url),'utf8'); +// The addon lists bag gear as comments below the equipped gear: a name with its item level, then the line. +const waist=source.match(/^waist=([^,]*),(id=\d+[^\n]*)$/m); +const bag=`\n### Gear from Bags\n#\n# Spare Sash (250)\n# waist=,${waist[2].replace(/,bonus_id=[^,]+/,'')},ilevel=250\n`; +const profile=source.split('\n').filter(l=>!l.startsWith('actions')).join('\n')+bag; +const imported=await post('/api/import',{profile});assert.equal(imported.body.isTank,true); +assert.ok(imported.body.alternatives.some(a=>a.slot==='waist'),'The bag item is read from the export.'); +const sources=await(await fetch(base+'/api/upgrade-sources')).json(); +const raid=sources.raids.at(-1); +const common={profile,mode:'tank',tank:{preset:'heroic',weight:60},iterations:400,duration:120,threads:8,targetError:0,scenarios:[{style:'Patchwerk',targets:1},{style:'Patchwerk',targets:3}]}; + +// No source: plain fights, no gear search. +const plain=await post('/api/preview',{...common,tankGear:{finalists:24}}); +assert.equal(plain.body.upgrade,undefined,'No gear source means plain fights.'); + +// Bags alone: the one bag item is a candidate with its item level and its own source. +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 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); +assert.equal(job.status,'complete',JSON.stringify(job.stages)+job.error); +assert.equal(job.mode,'tank');assert.ok(job.settings.tank.adds&&job.settings.tank.boss.health>0); +const origins=new Set(job.upgrade.candidates.flatMap(c=>c.sources.map(s=>s.origin))); +assert.ok(origins.has('bags')&&origins.has('raid'),[...origins].join()); +for(const s of [0,1]){ + const stages=job.stages.filter(st=>st.scenario===s&&st.status==='complete'); + assert.ok(stages.length>=1&&stages[0].baseline.tank,`Fight ${s} has a tank baseline.`); + 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]})); diff --git a/tests/tankgear.test.mjs b/tests/tankgear.test.mjs new file mode 100644 index 0000000..febe08c --- /dev/null +++ b/tests/tankgear.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {bagCandidates,buildTankGear} 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}}:{})}))}); +const season={season:{name:'Test season'},tracks:[ladder(617,'Hero',12841,305)],crests:{currencies:[{id:HERO,name:'Hero Mistcrest'}],warband:{}},itemLimits:{items:new Map(),bonuses:new Map([[8960,512]]),quantities:new Map([[512,2]])}}; +const catalog={items:new Map([[1,{name:'Helm',inventoryType:1}],[2,{name:'Spare Belt',inventoryType:6}],[3,{name:'Old Belt',inventoryType:6}],[4,{name:'Embellished Belt',inventoryType:6}]])}; +const gear={head:{id:1,value:',id=1,bonus_id=12843,enchant_id=9'},waist:{id:3,value:',id=3,bonus_id=12843'}}; +const alternatives=[ + {name:'Spare Belt (290)',text:'waist=,id=2,bonus_id=12843',slot:'waist',section:'Gear from Bags'}, + {name:'Old Belt (300)',text:'waist=,id=3,bonus_id=12843',slot:'waist',section:'Gear from Bags'}, + {name:'Saved build',text:'talents=ABC',slot:null,section:'Saved'} +]; +const profile={info:{class:'druid',spec:'guardian'},gear,alternatives}; +const text='druid="Test"\n# upgrade_currencies=c:3445:100'; + +test('bag items become candidates with their item level; what is already worn is not one',()=>{ + const {list,blocked}=bagCandidates(profile,season,catalog); + assert.equal(blocked,0);assert.equal(list.length,1); + const [belt]=list; + assert.equal(belt.slot,'waist');assert.equal(belt.itemId,2);assert.equal(belt.name,'Spare Belt');assert.equal(belt.itemLevel,290); + assert.equal(belt.line,'waist=,id=2,bonus_id=12843'); + assert.deepEqual(belt.sources,[{origin:'bags',group:'bags:Gear from Bags',groupName:'Gear from Bags',label:'Gear from Bags · item level 290'}]); +}); + +test('a bag item that would be a third embellishment is left out',()=>{ + const worn={...profile,gear:{head:{id:1,value:',id=1,bonus_id=8960'},waist:{id:3,value:',id=3,bonus_id=8960'}},alternatives:[{name:'Embellished Belt (300)',text:'finger1=,id=4,bonus_id=8960',slot:'finger1',section:'Bags'}]}; + const {list,blocked}=bagCandidates(worn,season,catalog); + assert.equal(list.length,0);assert.equal(blocked,1); +}); + +test('crest upgrades and bag gear are merged into one list of candidates, numbered and sorted by slot',()=>{ + const gearList=buildTankGear(profile,text,{bags:{},crests:{levels:'max',affordable:false}},season,catalog,null); + assert.deepEqual(gearList.counts,{loot:0,bags:1,crests:2}); + assert.equal(gearList.candidates.length,3); + assert.deepEqual(gearList.candidates.map(c=>c.key),['c001','c002','c003']); + assert.deepEqual(gearList.candidates.map(c=>c.slot),['head','waist','waist']); + const up=gearList.candidates.find(c=>c.slot==='head'); + assert.equal(up.upgrade.to,6);assert.equal(up.sources[0].origin,'crests');assert.match(up.sources[0].label,/3\/6 to 6\/6/); + assert.equal(up.value,',id=1,bonus_id=12846,enchant_id=9'); + assert.equal(gearList.finalists,48);assert.equal(gearList.embellished,false); +}); + +test('with no source asked for there is no gear search',()=>{ + assert.equal(buildTankGear(profile,text,{finalists:24},season,catalog,null),null); + assert.equal(buildTankGear(profile,text,undefined,season,catalog,null),null); +}); + +test('a crest source that cannot apply is a note when another source has items, and an error when it stands alone',()=>{ + const off={...profile,gear:{head:{id:1,value:',id=1,bonus_id=1'}}}; + const both=buildTankGear(off,text,{bags:{},crests:{}},season,catalog,null); + assert.equal(both.counts.crests,0);assert.match(both.warnings[0],/^Crest upgrades:/); + assert.throws(()=>buildTankGear(off,text,{crests:{}},season,catalog,null),/upgrade track/); +}); + +test('an empty bag source says why, and a finalist size outside the list is refused',()=>{ + const none={...profile,alternatives:[]}; + 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/); +});