diff --git a/README.md b/README.md index 8f9d39a..8e3ae42 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,11 @@ Trinket Lab is one trinket tier list per specialization, charted the way Bloodma The candidates are every trinket in the active season's loot tables — raid, Mythic+ (reissued older dungeon trinkets included, through the season's own tables), delves and crafting — filtered for the specialization as Upgrade Finder filters them. Each is simulated at every chosen item level, the top of an upgrade track (Champion, Hero and Myth by default), below the level its own source can give it, and then at that level: a delve or dungeon trinket stops at the top of the Hero track, a raid trinket reaches the Myth track and a last boss's its own higher drop level, and a crafted trinket its crafting cap. Source limits are set as in Weapon Lab. The Great Vault is on by default: it gives Mythic+ loot on the Myth track, above the dungeon chest's Hero track, so a dungeon trinket runs up to the vault's level (Myth 6/6 unless you choose another) and the chest's level stays one of its steps. Crafted trinkets have no track; their level is chosen from the item levels the season uses, up to the crafting cap SimulationCraft's own profiles craft to. Equal footing shows every trinket at every level instead. The bar for a trinket is what it adds over no trinket, split at each level; its tier is read at its top level, with the same thresholds as Weapon Lab. -A specialization with more trinkets than the final round size (16, 32 or 64; 64 covers every list in Midnight Season 2) is screened first, one run per trinket at its top level, and the best go on to be simulated at every level; the rest keep their screened number and no curve. Tanks are ranked on the weighted DPS and survival score with a calibrated boss each, and Augmentation on the raid's damage against its measured share, as in Weapon Lab. A trinket that completes an item set with the reference gear — Zul'jin's Guillotine Technique beside Maze'roa — is marked, and the rest are measured from the best trinket without one. A full season list is about 2,700 trinket and item level runs per scenario, with the vault on. +A specialization with more trinkets than the final round size (16, 32 or 64; 64 covers every list in Midnight Season 2) is screened first, one run per trinket at its top level, and the best go on to be simulated at every level; the rest keep their screened number and no curve. Tanks are ranked on the weighted DPS and survival score with a calibrated boss each, and Augmentation on the raid's damage against its measured share, as in Weapon Lab. A trinket that completes an item set with the reference gear — Zul'jin's Guillotine Technique beside Maze'roa — is simulated twice and listed twice: with the set's bonus, as that character would wear it, and with the bonus switched off (`set_bonus=bite_of_zuljan_2pc=0`), the trinket alone. The rest are measured from the best trinket without a set bonus. A full season list is about 2,700 trinket and item level runs per scenario, with the vault on. -**The tier list page.** A finished job offers one page with every class and specialization, each trinket in its tier with its split bar, source, item level and Wowhead tooltip, and the gain at every level in the row's data so the website can import it. Two trinkets together are not the sum of their bars; the page says so. +**Fight style and targets.** Trinket Lab simulates the fight style chosen under the run settings, and **Also compare 3 and 5 targets** runs every specialization at 1, 3 and 5 targets in that style — AoE trinkets move a lot between them. + +**The tier list page.** A finished job offers one page in the design of mythicpersona.com's weapon tier list, with its fonts inside the file: every class and specialization, each trinket in its tier with its split bar, source, item level and Wowhead tooltip, and the gain at every level in the row's data so the website can import it. When the job ran 1, 3 and 5 targets, tabs at the top switch between them, with no script. Two trinkets together are not the sum of their bars; the page says so. ## Tank simulation diff --git a/desktop/package.json b/desktop/package.json index dc40099..56d9bb1 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.19.0", + "version": "1.20.0", "description": "Local SimulationCraft workbench with upgrade finder and tank simulation.", "author": "Roburmaster", "private": true, diff --git a/lib/catalog.mjs b/lib/catalog.mjs index c36c718..2882246 100644 --- a/lib/catalog.mjs +++ b/lib/catalog.mjs @@ -29,11 +29,11 @@ export async function loadCatalog(source) { export function parseSetBonuses(text){ const out=[]; for(const line of String(text).split(/\r?\n/)){ - const match=line.match(/^\s*\{\s*"((?:[^"\\]|\\.)*)"\s*,\s*"[^"]*"\s*,\s*"[^"]*"\s*,([^{]+)\{([^}]*)\}/); + const match=line.match(/^\s*\{\s*"((?:[^"\\]|\\.)*)"\s*,\s*"([^"]*)"\s*,\s*"[^"]*"\s*,([^{]+)\{([^}]*)\}/); if(!match)continue; - const [,setId,pieces,classId,specId,,spellId]=match[2].split(',').map(v=>Number(v.trim())); - const items=match[3].split(',').map(v=>Number(v.trim())).filter(Boolean); - if(items.length&&pieces>0)out.push({name:match[1].replace(/\\"/g,'"'),setId,pieces,classId,specId,spellId,items}); + const [,setId,pieces,classId,specId,,spellId]=match[3].split(',').map(v=>Number(v.trim())); + const items=match[4].split(',').map(v=>Number(v.trim())).filter(Boolean); + if(items.length&&pieces>0)out.push({name:match[1].replace(/\\"/g,'"'),option:match[2],setId,pieces,classId,specId,spellId,items}); } return out; } diff --git a/lib/trinketpage.mjs b/lib/trinketpage.mjs index 41834c4..2bdbb4e 100644 --- a/lib/trinketpage.mjs +++ b/lib/trinketpage.mjs @@ -1,8 +1,14 @@ // One page holding the whole Trinket Lab result: every class and specialization, each trinket as a bar of what it -// adds over wearing none, split at every item level it was simulated at, and sorted into its tier. Built like the -// weapon tier list: from a finished job, with no script of its own but Wowhead's tooltips, so it reads the same in -// the app, from a folder, and when the website imports it. +// adds over wearing none, split at every item level it was simulated at, and sorted into its tier. It wears the +// website's weapon tier list design (mythicpersona.com/weapon-tier-list): the same type, colours, tier badges and +// cards, with the site's two fonts carried inside the file. +// There is no script of its own but Wowhead's tooltips, so it reads the same in the app, from a folder, and when +// the website imports it. The 1, 3 and 5 target results sit side by side and are switched with radio buttons and +// CSS alone. +import fs from 'node:fs'; +import path from 'node:path'; import {tiers} from './weapons.mjs'; +import {appRoot} from './paths.mjs'; const escape=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const number=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n); @@ -10,6 +16,20 @@ const anchor=s=>String(s).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|- 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'}; // One colour per item level, dark to bright, so the bright end of every bar is the highest level simulated. const levelColors=['#3b4a63','#4f6b8f','#5f8fb8','#7fb6d6','#a6dcc1','#f3d27a','#f3b754','#ff9d4a']; +// The website's tier colours. +const tierColors={S:'#f3bd65',A:'#7edcb0',B:'#8bbffa',C:'#c2cbd9',D:'#d1a6b6'}; + +// The site's two fonts (SIL Open Font License, beside them in public/fonts), inlined once so a saved page keeps them. +let fontFaces=null; +function fonts(){ + if(fontFaces!==null)return fontFaces; + const face=(family,file)=>{ + try{return `@font-face{font-family:"${family}";src:url(data:font/ttf;base64,${fs.readFileSync(path.join(appRoot,'public','fonts',file)).toString('base64')}) format("truetype");font-weight:400;font-display:swap}`;} + catch{return '';} + }; + fontFaces=face('Oswald','Oswald-Regular.ttf')+face('Roboto Mono','RobotoMono-Regular.ttf'); + return fontFaces; +} function itemParams(candidate){ const params=[`item=${Number(candidate.itemId)}`]; @@ -20,6 +40,7 @@ function itemParams(candidate){ return params; } const itemUrl=candidate=>{const [item,...rest]=itemParams(candidate);return `https://www.wowhead.com/${item}${rest.length?`?${rest.join('&')}`:''}`;}; +const groupOf=c=>c.group||String(c.itemId); // Every item level any trinket in the job was simulated at, lowest first: the bar's colour key. export function jobLevels(job){ @@ -27,19 +48,23 @@ export function jobLevels(job){ } // One entry per ranked trinket of a spec and scenario: its ranked row (the top level) and the gain at every level -// the final round simulated, lowest first. A trinket screened out of the final round has its top level only. +// the final round simulated, lowest first. A trinket screened out of the final round has its top level only. A set +// trinket is two entries, with and without its set bonus. export function trinketSeries(job,spec,scenario){ const byKey=new Map(spec.candidates.map(c=>[c.key,c])); const rows=job.results.filter(r=>r.spec===spec.key&&r.scenario===scenario&&!r.superseded&&r.status==='complete'); return rows.filter(r=>Number.isFinite(r.rank)).sort((a,b)=>a.rank-b.rank).map(row=>{ const top=byKey.get(row.key); - const levels=rows.filter(r=>byKey.get(r.key)?.itemId===top.itemId&&Number.isFinite(r.gain)&&r.stage===row.stage) + const levels=rows.filter(r=>byKey.has(r.key)&&groupOf(byKey.get(r.key))===groupOf(top)&&Number.isFinite(r.gain)&&r.stage===row.stage) .map(r=>({itemLevel:byKey.get(r.key).itemLevel,label:byKey.get(r.key).levelLabel,gain:r.gain,percent:r.percent})) .sort((a,b)=>a.itemLevel-b.itemLevel); return {row,candidate:top,levels}; }); } +// "Patchwerk · 1 target", or just "1 target" when every scenario shares one fight style. +const scenarioName=(s,shared)=>`${shared?'':`${s.style} · `}${s.targets} target${s.targets===1?'':'s'}`; + function specSection(job,spec,scenario,palette){ const series=trinketSeries(job,spec,scenario); if(!series.length)return ''; @@ -49,30 +74,32 @@ function specSection(job,spec,scenario,palette){ const share=stages.find(s=>s.idle&&s.status==='complete')?.share; const most=Math.max(...series.flatMap(s=>s.levels.map(l=>l.gain)),0)||1; const unit=tanky?' score':' %'; - const bar=entry=>{ + const bar=levels=>{ let before=0; - return entry.levels.map(level=>{ + return levels.map(level=>{ const width=Math.max(0,Math.min(level.gain,most)-Math.max(before,0)); const left=Math.max(before,0);before=Math.max(before,level.gain); return width>0?``:''; }).join(''); }; - const chip=({row,candidate:c,levels})=>{ + const item=({row,candidate:c,levels})=>{ const top=levels.at(-1)||{gain:row.gain,percent:row.percent,itemLevel:c.itemLevel}; const gain=Number.isFinite(top.gain)?(tanky?`+${top.gain.toFixed(2)} score`:`+${top.percent.toFixed(2)} % · +${number(top.gain)} ${spec.support?'raid ':''}DPS`):'no gain measured'; const behind=c.set&&row.behind<0?`+${(-row.behind).toFixed(2)}${unit} with the set`:row.behind<=0?'best trinket':`−${row.behind.toFixed(2)}${unit}`; const data=levels.map(l=>`${l.itemLevel}:${l.gain.toFixed(tanky?3:1)}:${l.percent.toFixed(3)}`).join('|'); - return `
  • + return `
  • ${row.rank} ${escape(c.name)} - ${escape(c.sources[0]||'')}${c.sources.length>1?` +${c.sources.length-1}`:''} · ${c.itemLevel}${c.levelLabel?` ${escape(c.levelLabel)}`:''}${c.onUse?' · on use':''} - ${bar({levels})} - ${escape(gain)}${escape(behind)}${row.tied?' ~':''}${row.screened?'screened only':''} - ${c.set?`${escape(c.set.name)} ${c.set.pieces}-set with ${escape(c.set.with.join(', '))}`:''} -
  • `; + ${escape(c.sources[0]||'')}${c.sources.length>1?` +${c.sources.length-1}`:''} · ${c.itemLevel}${c.levelLabel?` ${escape(c.levelLabel)}`:''}${c.onUse?' · on use':''} + ${bar(levels)} + ${row.screened?'screened only':''} + ${c.set?`With the ${escape(c.set.name)} ${c.set.pieces}-set bonus, beside ${escape(c.set.with.join(', '))}`:''} + ${c.setOff?`Without the ${escape(c.setOff.name)} set bonus: the trinket alone`:''} + + ${escape(gain)}${escape(behind)}${row.tied?' ~':''}`; }; - return `
    + return `

    ${escape(spec.specName)}${escape(spec.className)}

    ${series.length} trinkets${spec.gear?` · reference gear ${spec.gear.itemLevel} ilvl`:''}${baseline?` · ${number(baseline.dps)} ${spec.support?'raid ':''}DPS with no trinket`:''}${tanky?' · DPS and survival':''} · ${escape(spec.file.replace(/\.simc$/,''))}

    @@ -81,7 +108,7 @@ function specSection(job,spec,scenario,palette){ ${spec.stale?`

    Ranked on last season's character: SimulationCraft has not rebuilt this profile for the current season, so its gear sits well below the others. The order here holds; the numbers do not belong next to another specialization's.

    `:''} ${spec.ours?`

    Carried by SimC Lab's own character: SimulationCraft has no profile for this specialization this season${spec.provenance?`, so its gear and talents come from ${escape(spec.provenance.name)}'s best in slot for patch ${escape(spec.provenance.patch)}, read ${escape(spec.provenance.readAt)}`:''}. Item levels, enchant ranks and the crafting cap are taken from the game data, not from the guide.

    `:''} ${tiers.map(t=>({tier:t.tier,list:series.filter(s=>s.row.tier===t.tier)})).filter(g=>g.list.length) - .map(g=>`
    ${g.tier}
      ${g.list.map(chip).join('')}
    `).join('')} + .map(g=>`
    ${g.tier}
      ${g.list.map(item).join('')}
    `).join('')}
    `; } @@ -95,6 +122,7 @@ export function trinketListPage(job){ const existing=classes.find(c=>c.name===spec.className); if(existing)existing.specs.push(spec);else classes.push({name:spec.className,key:spec.class,specs:[spec]}); } + const shared=new Set(job.scenarios.map(s=>s.style)).size===1; const scenarios=job.scenarios.map((scenario,index)=>{ const present=classes.map(c=>({...c,sections:c.specs.map(spec=>specSection(job,spec,index,palette)).join('')})).filter(c=>c.sections); return present.length?{scenario,index,present}:null; @@ -102,119 +130,157 @@ export function trinketListPage(job){ const when=new Date(job.finished||job.created).toISOString().slice(0,10); const range=lab.levels||{min:levels[0],max:levels.at(-1)}; const levelText=range.min===range.max?`item level ${range.max}`:`item level ${range.min}–${range.max}`; - const ranked=new Set(job.results.filter(r=>Number.isFinite(r.rank)).map(r=>`${r.scenario}|${r.spec}|${r.key}`)).size; + const ranked=new Set(job.results.filter(r=>Number.isFinite(r.rank)&&r.scenario===scenarios[0]?.index).map(r=>`${r.spec}|${r.key}`)).size; const src=lab.sources||{}; const stat=(value,label)=>`
    ${escape(value)}${escape(label)}
    `; + // One radio per scenario, before everything it switches: the checked one shows its results and lights its tab. + const switchCss=scenarios.map((s,i)=>`#scn-${i}:checked~.targets label[for="scn-${i}"]{background:var(--surface-2);color:var(--ink);border-bottom-color:var(--brand)}#scn-${i}:checked~.scenarios>.scenario[data-scenario="${i}"]{display:block}`).join(''); return ` Trinket tier list · ${escape(lab.season?.name||'')} · ${escape(levelText)} -
    + +
    SimC Lab +
    +
    ${escape(lab.season?.name||'Trinket Lab')} -

    Trinket tier list

    +

    Trinkettier list.

    ${src.equal ? `Every trinket at every chosen item level (${escape((src.steps||[]).join(', '))}), equal footing: this answers what a trinket is worth, not what you can reach with it.` - : `Each trinket up to the level its own source can give it: ${escape([src.raid&&`raid ${src.raid.label}`,src.mplus&&`Mythic+ ${src.mplus.label}`,src.delves&&`delves ${src.delves.label}`,src.vault&&`Mythic+ through the Great Vault ${src.vault.label.replace(/^Great Vault · /,'')}`,src.crafted&&`crafted ${src.crafted.label}`].filter(Boolean).join(', '))}. A trinket's tier is read at the highest of those levels.`} + : `Each trinket up to the level its own source can give it: ${escape([src.raid&&`raid ${src.raid.label}`,src.mplus&&`Mythic+ ${src.mplus.label}`,src.vault&&`Mythic+ through the Great Vault ${src.vault.label.replace(/^Great Vault · /,'')}`,src.delves&&`delves ${src.delves.label}`,src.crafted&&`crafted ${src.crafted.label}`].filter(Boolean).join(', '))}. A trinket's tier is read at the highest of those levels.`} Each trinket is worn alone, the second slot empty, and the bar is what it adds over the same character with no trinket at all. Simulated locally with SimulationCraft ${escape(job.engine.version)} on WoW ${escape(job.engine.wowVersion)}.

    + ${stat(when,'simulated')} ${stat(String(specs.length),'specializations')} ${stat(String(ranked),'trinkets ranked')} ${stat(range.min===range.max?String(range.max):`${range.min}–${range.max}`,'item level')} ${stat(number(job.settings.iterations),'iterations')} - ${stat(when,'simulated')}
    -

    Item level:${levels.map(level=>`${level}`).join('')}

    -

    Distance behind the best trinket of the same specialization:${tiers.map((t,i)=>`${t.tier}${Number.isFinite(t.behind)?`under ${t.behind}${i

    -${scenarios.map(s=>`
    + +${scenarios.map((s,i)=>``).join('')} +${scenarios.length>1?``:''} +

    Item level:${levels.map(level=>`${level}`).join('')}

    +

    Distance behind the best trinket of the same specialization:${tiers.map((t,i)=>`${t.tier}${Number.isFinite(t.behind)?`under ${t.behind}${i +

    +${scenarios.map((s,i)=>`

    ${escape(s.scenario.style)} · ${s.scenario.targets} target${s.scenario.targets===1?'':'s'} ${job.settings.duration} sec · ${number(job.settings.iterations)} iterations · ${job.settings.targetError}% target error

    - - ${s.present.map(c=>`

    ${escape(c.name)}

    ${c.sections}
    `).join('')}
    `).join('')} -
    + + ${s.present.map(c=>`

    ${escape(c.name)}

    ${c.sections}
    `).join('')}
    `).join('')} +
    +
    The simulation context Built by SimC Lab · powered by SimulationCraft · items link to Wowhead, and hovering one shows its tooltip from there.
    -Reference characters are SimulationCraft's own profiles, one per specialization, with both trinkets taken off: the gear, gems and enchants around the trinket are theirs and not yours.${specs.some(s=>s.stale)?` ${specs.filter(s=>s.stale).map(s=>escape(s.label)).join(', ')} are marked: their profile is a season behind.`:''} Healing specializations are not ranked: SimulationCraft cannot simulate healing, and a trinket is mostly its effect, which no stat score can value. A trinket that completes an item set with the reference gear is marked ◆: its lead is the set's bonus.
    -${number(job.settings.iterations)} iterations at ${job.settings.targetError}% target error${lab.screen?`; screening at ${number(lab.screen.iterations)} iterations and ${lab.screen.targetError}%`:''}. Numbers compare trinkets within one specialization; they are not a ranking between classes, and a pair of trinkets is not the sum of two bars. +Reference characters are SimulationCraft's own profiles, one per specialization, with both trinkets taken off: the gear, gems and enchants around the trinket are theirs and not yours.${specs.some(s=>s.stale)?` ${specs.filter(s=>s.stale).map(s=>escape(s.label)).join(', ')} are marked: their profile is a season behind.`:''} Healing specializations are not ranked: SimulationCraft cannot simulate healing, and a trinket is mostly its effect, which no stat score can value.
    +A trinket that completes an item set with the reference gear is shown twice: ◆ with the set's bonus, as that character would wear it, and ◇ without it, the trinket alone. The rest of the list is measured from the best trinket without a set bonus.
    +${number(job.settings.iterations)} iterations at ${job.settings.targetError}% target error${lab.screen?`; screening at ${number(lab.screen.iterations)} iterations and ${lab.screen.targetError}%`:''}. Numbers compare trinkets within one specialization; they are not a ranking between classes, and a pair of trinkets is not the sum of two bars.
    +Oswald and Roboto Mono are used under the SIL Open Font License.
    `; } diff --git a/lib/trinkets.mjs b/lib/trinkets.mjs index 136d37b..6549aa8 100644 --- a/lib/trinkets.mjs +++ b/lib/trinkets.mjs @@ -56,12 +56,15 @@ export function trinketCandidates(spec,season,catalog,{drops,steps,equal=false}) // Crafted trinkets have no upgrade track; their level is set directly, as the game's own crafting does. const crafted=kind==='crafted'&&!equal; const levels=equal?steps:[...steps.filter(s=>s.itemLevelcrafted?{ilevel:s.itemLevel,itemLevel:s.itemLevel,label:`item level ${s.itemLevel}`}:s),cap]; + // A trinket that completes an item set with the reference gear is shown twice: with the set's bonus, as the + // character would wear it, and with the bonus switched off, as the trinket alone. Each is its own entry. const set=completedSet(spec,'trinket1',item.id,catalog); - levels.forEach((level,index)=>{ + const variants=set?[{group:`${item.id}`,set},{group:`${item.id}|noset`,setOff:{name:set.name,pieces:set.pieces},extra:set.option?[`set_bonus=${set.option}_${set.pieces}pc=0`]:[]}]:[{group:`${item.id}`}]; + for(const variant of variants)levels.forEach((level,index)=>{ const bonuses=[...(item.bonusLists||[]),...(level.bonusId?[level.bonusId]:[])]; const value=[`,id=${item.id}`,...(bonuses.length?[`bonus_id=${bonuses.join('/')}`]:[]),...(level.ilevel?[`ilevel=${level.ilevel}`]:[]),...carried(null,item,bonuses,season.bonusSockets)].join(','); list.push({slot:'trinket1',itemId:item.id,name:item.name,itemLevel:level.itemLevel,levelLabel:level.label,value,line:`trinket1=${value}`, - sources,onUse:!!item.onUseTrinket,top:index===levels.length-1,...(set?{set}:{})}); + sources,onUse:!!item.onUseTrinket,top:index===levels.length-1,...variant}); }); } list.forEach((c,i)=>c.key='t'+String(i+1).padStart(4,'0')); @@ -84,7 +87,9 @@ export function withVault(drops,vault){ return {...drops,of:{...drops.of,mplus},public:{...drops.public,vault:{track:vault.track,level:vault.level,label:vault.label,itemLevel:vault.itemLevel}}}; } -export const trinketCount=spec=>new Set(spec.candidates.map(c=>c.itemId)).size; +// A trinket entry: the item, or the item without its set bonus. +export const groupOf=c=>c.group||String(c.itemId); +export const trinketCount=spec=>new Set(spec.candidates.map(groupOf)).size; export async function prepareTrinkets(request,catalog,season,talentData,source,scenarios=1){ const options=request.trinkets||{}; @@ -124,8 +129,8 @@ export const topRows=spec=>spec.candidates.filter(c=>c.top); export function selectTopTrinkets(candidates,screen,size,tank){ const byKey=new Map(screen.rows.map(r=>[r.key,r])); const metric=r=>tank?.boss?(Number.isFinite(r.score)?r.score:-Infinity):(Number.isFinite(r.dps)?r.dps:-Infinity); - const chosen=new Set(candidates.filter(c=>c.top&&byKey.has(c.key)).sort((a,b)=>metric(byKey.get(b.key))-metric(byKey.get(a.key))).slice(0,size).map(c=>c.itemId)); - return candidates.filter(c=>chosen.has(c.itemId)); + const chosen=new Set(candidates.filter(c=>c.top&&byKey.has(c.key)).sort((a,b)=>metric(byKey.get(b.key))-metric(byKey.get(a.key))).slice(0,size).map(groupOf)); + return candidates.filter(c=>chosen.has(groupOf(c))); } // What a row adds over the character with no trinket: DPS, the raid's DPS for a support specialization, or the diff --git a/lib/upgrades.mjs b/lib/upgrades.mjs index c37eb60..8b41677 100644 --- a/lib/upgrades.mjs +++ b/lib/upgrades.mjs @@ -274,7 +274,8 @@ const clearedBy=(item,slot,profile)=>slot==='main_hand'&&weaponKinds[item?.inven // A two-hander in a single-wield main hand must clear the equipped off-hand so SimC does not wield both. export function candidateLines(candidate,profile,catalog){ - const lines=[candidate.line]; + // A candidate may carry option lines of its own, such as a set bonus switched off. + const lines=[candidate.line,...(candidate.extra||[])]; if(clearedBy(catalog.items.get(candidate.itemId),candidate.slot,profile).length)lines.push('off_hand='); return lines; } diff --git a/lib/weapons.mjs b/lib/weapons.mjs index 45a496c..e8f295a 100644 --- a/lib/weapons.mjs +++ b/lib/weapons.mjs @@ -193,7 +193,7 @@ export function completedSet(spec,slot,itemId,catalog){ if(bonus.specId>0&&bonus.specId!==spec.specId)continue; const worn=bonus.items.filter(id=>others.has(id)); if(worn.length>=bonus.pieces||worn.includes(itemId)||worn.length+1found.pieces)found={name:bonus.name,pieces:bonus.pieces,with:worn.map(id=>catalog.items.get(id)?.name||`item ${id}`)}; + if(!found||bonus.pieces>found.pieces)found={name:bonus.name,...(bonus.option?{option:bonus.option}:{}),pieces:bonus.pieces,with:worn.map(id=>catalog.items.get(id)?.name||`item ${id}`)}; } return found; } diff --git a/package-lock.json b/package-lock.json index ea55bc6..4f34ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.19.0", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.19.0", + "version": "1.20.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 71c9443..544d5b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.19.0", + "version": "1.20.0", "private": true, "type": "module", "scripts": { diff --git a/public/fonts/Oswald-OFL.txt b/public/fonts/Oswald-OFL.txt new file mode 100644 index 0000000..9d0219e --- /dev/null +++ b/public/fonts/Oswald-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Oswald-Regular.ttf b/public/fonts/Oswald-Regular.ttf new file mode 100644 index 0000000..dc593e1 Binary files /dev/null and b/public/fonts/Oswald-Regular.ttf differ diff --git a/public/fonts/RobotoMono-OFL.txt b/public/fonts/RobotoMono-OFL.txt new file mode 100644 index 0000000..8e7df33 --- /dev/null +++ b/public/fonts/RobotoMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/RobotoMono-Regular.ttf b/public/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000..eb1fde5 Binary files /dev/null and b/public/fonts/RobotoMono-Regular.ttf differ diff --git a/public/trinkets.js b/public/trinkets.js index d08d315..146d146 100644 --- a/public/trinkets.js +++ b/public/trinkets.js @@ -86,7 +86,7 @@ export function trinketUI({api,notice,updateCount}){ const rows=job.results.filter(r=>r.spec===spec.key&&r.scenario===s&&!r.superseded&&r.status==='complete'); return rows.filter(r=>Number.isFinite(r.rank)).sort((a,b)=>a.rank-b.rank).map(row=>{ const c=byKey.get(row.key); - const levels=rows.filter(r=>byKey.get(r.key)?.itemId===c.itemId&&Number.isFinite(r.gain)&&r.stage===row.stage).map(r=>({itemLevel:byKey.get(r.key).itemLevel,gain:r.gain,percent:r.percent})).sort((a,b)=>a.itemLevel-b.itemLevel); + const levels=rows.filter(r=>(byKey.get(r.key)?.group||byKey.get(r.key)?.itemId)===(c.group||c.itemId)&&Number.isFinite(r.gain)&&r.stage===row.stage).map(r=>({itemLevel:byKey.get(r.key).itemLevel,gain:r.gain,percent:r.percent})).sort((a,b)=>a.itemLevel-b.itemLevel); return {row,c,levels}; }); } @@ -128,7 +128,7 @@ export function trinketUI({api,notice,updateCount}){ for(const st of stages)if(st.status==='failed')html+=`

    ${st.stage===1?'Screening':st.idle?'Measuring its share of the raid':'Final round'} failed: ${esc(st.error)}

    `; html+=`${list.map(({row,c,levels:ls})=>{ const top=ls.at(-1)||row; - return ``; + return ``; }).join('')}
    #TrinketGain by item levelAt its top levelBehind bestTier
    ${row.rank}${itemLink(c.itemId,c.name,c.value)}${esc(c.sources.join(' · '))} · item level ${c.itemLevel}${c.levelLabel?` (${esc(c.levelLabel)})`:''}${c.onUse?' · on use':''}${row.screened?' · screening only':''}${c.set?`
    ◆ ${esc(c.set.name)} ${c.set.pieces}-set with ${esc(c.set.with.join(', '))}`:''}
    ${bar(ls.length?ls:[{itemLevel:c.itemLevel,gain:row.gain,percent:row.percent}],most,palette,tanky)}${Number.isFinite(top.gain)?(tanky?`+${top.gain.toFixed(2)}score`:`+${top.percent.toFixed(2)} %+${number(top.gain)} DPS`):'—'}${row.behind<=0?'—':`−${row.behind.toFixed(2)}${tanky?'':' %'}${row.tied?'within uncertainty':''}`}${row.tier}
    ${row.rank}${itemLink(c.itemId,c.name,c.value)}${esc(c.sources.join(' · '))} · item level ${c.itemLevel}${c.levelLabel?` (${esc(c.levelLabel)})`:''}${c.onUse?' · on use':''}${row.screened?' · screening only':''}${c.set?`
    ◆ with the ${esc(c.set.name)} ${c.set.pieces}-set bonus (with ${esc(c.set.with.join(', '))})`:''}${c.setOff?`
    ◇ without the ${esc(c.setOff.name)} set bonus`:''}
    ${bar(ls.length?ls:[{itemLevel:c.itemLevel,gain:row.gain,percent:row.percent}],most,palette,tanky)}${Number.isFinite(top.gain)?(tanky?`+${top.gain.toFixed(2)}score`:`+${top.percent.toFixed(2)} %+${number(top.gain)} DPS`):'—'}${row.behind<=0?'—':`−${row.behind.toFixed(2)}${tanky?'':' %'}${row.tied?'within uncertainty':''}`}${row.tier}
    `; html+=`

    ${stages.filter(st=>st.stem).map(st=>`${st.stage===1?'Screening':st.idle?'Raid without its buffs':'Final round'}${st.idle?'':` (${st.count})`}: HTML JSON Input`).join(' · ')}

    `; } diff --git a/server.mjs b/server.mjs index 925b4e3..e11beaf 100644 --- a/server.mjs +++ b/server.mjs @@ -60,7 +60,7 @@ const server=http.createServer(async(req,res)=>{ const url=new URL(req.url,`http://127.0.0.1:${port}`);const route=url.pathname; res.setHeader('Cache-Control','no-store'); res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Referrer-Policy','no-referrer'); - if(!route.startsWith('/reports/'))res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self' https://wow.zamimg.com https://www.wowhead.com https://nether.wowhead.com; style-src 'self' 'unsafe-inline' https://wow.zamimg.com; img-src 'self' data: https://wow.zamimg.com https://*.wowhead.com; frame-ancestors 'none'; connect-src 'self' https://www.wowhead.com https://nether.wowhead.com https://wow.zamimg.com"); + if(!route.startsWith('/reports/'))res.setHeader('Content-Security-Policy',"default-src 'self'; font-src 'self' data:; script-src 'self' https://wow.zamimg.com https://www.wowhead.com https://nether.wowhead.com; style-src 'self' 'unsafe-inline' https://wow.zamimg.com; img-src 'self' data: https://wow.zamimg.com https://*.wowhead.com; frame-ancestors 'none'; connect-src 'self' https://www.wowhead.com https://nether.wowhead.com https://wow.zamimg.com"); if(req.method==='POST'){ const received=Buffer.from(req.headers['x-simc-token'] || '');const expected=Buffer.from(token); if(received.length!==expected.length || !timingSafeEqual(received,expected))return json(res,403,{error:'Reload the page.'}); diff --git a/tests/trinketpage.test.mjs b/tests/trinketpage.test.mjs index 7c793cf..c3f6665 100644 --- a/tests/trinketpage.test.mjs +++ b/tests/trinketpage.test.mjs @@ -29,6 +29,38 @@ const job={ ] }; +// The same job with three target counts, and the set trinket shown with and without its bonus. +const multi=structuredClone(job); +multi.scenarios=[{style:'Patchwerk',targets:1},{style:'Patchwerk',targets:3},{style:'Patchwerk',targets:5}]; +const spec=multi.trinkets.specs[0]; +spec.candidates.push(candidate('t0007',54,'Technique',334,true,{group:'54',set:{name:"Bite of Zul'jan",pieces:2,with:["Maze'roa"]}}),candidate('t0008',54,'Technique',334,true,{group:'54|noset',setOff:{name:"Bite of Zul'jan",pieces:2}})); +multi.results.push({spec:'warrior-arms',scenario:0,stage:2,key:'t0007',status:'complete',dps:216000,gain:16000,percent:8,rank:4,behind:-2.86,tier:'S',tied:false}, + {spec:'warrior-arms',scenario:0,stage:2,key:'t0008',status:'complete',dps:204000,gain:4000,percent:2,rank:5,behind:2.86,tier:'B',tied:false}); +for(const s of [1,2]){ + multi.stages.push({spec:'warrior-arms',scenario:s,stage:2,stem:'00'+s,status:'complete',baseline:{dps:200000*(s+1)}}); + multi.results.push({spec:'warrior-arms',scenario:s,stage:2,key:'t0003',status:'complete',dps:210000*(s+1),gain:10000*(s+1),percent:5,rank:1,behind:0,tier:'S',tied:false}); +} + +test('1, 3 and 5 targets sit side by side and are switched without a script',()=>{ + const html=trinketListPage(multi); + assert.deepEqual(html.match(/]*>/gi),['']); @@ -40,7 +72,10 @@ test('each ranked trinket carries its gain at every simulated level, lowest firs test('the page is a tier list with a split bar per trinket and no script but Wowhead’s',()=>{ const html=trinketListPage(job); - assert.match(html,/

    Trinket tier list<\/em><\/h1>/); + assert.match(html,/

    Trinket<\/span>tier list\.<\/span><\/h1>/,'the website\'s heading'); + assert.match(html,/@font-face\{font-family:"Oswald";src:url\(data:font\/ttf;base64,/,'the site\'s fonts travel inside the file'); + assert.match(html,/Always sim your own character\./); + assert.equal(html.includes('class="targets"'),false,'one scenario needs no switch'); assert.deepEqual(html.match(/]*>/gi),['