diff --git a/README.md b/README.md index b83b7a2..8f9d39a 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Crafted weapons and shields carry no secondary stats of their own. Without a cho Each hand is ranked on its own. What the reference profile happens to wield in the other hand decides how much a swap is worth — a Fury Warrior whose main hand is item level 344 and off hand 331 gains from every off-hand swap and loses from every main-hand one — so a single list across both hands would rank the hands rather than the weapons. Main hand and off hand therefore get a list each, and each hand gets its own share of the final round. -Each specialization runs on its own. A list no longer than the final round size is simulated once with your iteration and target-error settings; a longer one is screened first at up to 2,000 iterations and a 0.5% target error, and the best 12, 24 or 48 go on to the full round. Screening-only numbers are labelled. Tank specializations are ranked on the same weighted DPS and survival score as the rest of the app, with a boss calibrated once for each of them, so shields can be judged on more than damage. A job is limited to 2,400 candidate runs, counting every scenario. +Each specialization runs on its own. A list no longer than the final round size is simulated once with your iteration and target-error settings; a longer one is screened first at up to 2,000 iterations and a 0.5% target error, and the best 12, 24 or 48 go on to the full round. Screening-only numbers are labelled. Tank specializations are ranked on the same weighted DPS and survival score as the rest of the app, with a boss calibrated once for each of them, so shields can be judged on more than damage. Tiers measure the distance behind the best weapon of the same specialization: S under 0.5, A under 1.5, B under 3, C under 5, then D — in percent of DPS, or in score points for tanks. Weapons within the combined 95% uncertainty of the best one are marked as such. Absolute numbers are comparable within a specialization only; reference profiles are built by different authors and are not a class ranking. @@ -143,7 +143,7 @@ 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 job is limited to 8,000 trinket and item level runs, counting every scenario; a full season list is about 2,400. +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. **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. @@ -177,7 +177,7 @@ The search is bounded and deterministic. It is not exhaustive, adaptive across s Results include DPS, differences from baseline, approximate 95% confidence intervals, actual SimC input and downloadable HTML/JSON reports. The History tab persists completed jobs across server restarts. Unfinished jobs are marked interrupted after a restart. -Jobs run sequentially with configurable CPU threads. Limits are 128 alternative builds, 256 runs per job (Upgrade Finder: 800 candidates, two profileset runs per scenario; Weapon Lab: 2,400 candidate runs, one or two profileset runs per specialization and scenario; Trinket Lab: 8,000 trinket and item level runs) and 5 queued/running jobs. Active and queued jobs can be cancelled. Files are stored in runs/; the import field is also remembered in browser localStorage. +Jobs run sequentially with configurable CPU threads. There is no cap on how much a job may simulate — enchant combinations, alternatives, Upgrade Finder candidates, Crest Planner upgrades, and Weapon Lab and Trinket Lab lists all run in full, however long that takes on your PC; the count before you start says how many runs it will be. Up to 5 jobs can be queued or running at once. Active and queued jobs can be cancelled. Files are stored in runs/; the import field is also remembered in browser localStorage. The server listens only on 127.0.0.1, verifies Host and POST tokens, rejects file/network/output directives in profile imports, and launches SimC without a shell. The compiled engine has networking disabled. diff --git a/desktop/package.json b/desktop/package.json index acfae11..dc40099 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.18.0", + "version": "1.19.0", "description": "Local SimulationCraft workbench with upgrade finder and tank simulation.", "author": "Roburmaster", "private": true, diff --git a/lib/crests.mjs b/lib/crests.mjs index 5cc0389..bd596fa 100644 --- a/lib/crests.mjs +++ b/lib/crests.mjs @@ -2,7 +2,7 @@ import {slots as allSlots} from './profile.mjs'; // Crest Planner: every equipped item on a season upgrade track is simulated at each higher level of that track, // and the cost of getting there is counted in the track's own crests. -export const limits={candidates:120}; +export const limits={}; // Enum.ItemRedundancySlot, the slots the game keeps an upgrade high watermark for. const redundancy={head:0,neck:1,shoulder:2,chest:3,waist:4,legs:5,feet:6,wrist:7,hands:8,finger1:9,finger2:9,trinket1:10,trinket2:10,back:11}; @@ -82,7 +82,6 @@ export function buildCrestCandidates(profile,text,request,season,catalog){ if(!items.length)throw new Error('None of the equipped items is on one of this season’s upgrade tracks.'); if(!list.length&&cheapest)throw new Error(`Nothing is affordable with these crests. The cheapest upgrade needs ${cheapest.crests} ${season.crests?.currencies.find(c=>c.id===cheapest.currencyId)?.name||'crests'}.`); if(!list.length)throw new Error('Every equipped item on an upgrade track is already fully upgraded.'); - if(list.length>limits.candidates)throw new Error(`${list.length} upgrades to simulate. Choose fewer slots or only fully upgraded items (at most ${limits.candidates}).`); list.forEach((c,i)=>c.key='u'+String(i+1).padStart(3,'0')); return {candidates:list,items,levels,affordable,state:{exported:state.exported,watermarks:!!state.watermarks},budget:budget||null,currencies:season.crests?.currencies||[]}; } diff --git a/lib/engine.mjs b/lib/engine.mjs index 42413b7..5baa413 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -116,7 +116,6 @@ export async function prepare(request,catalog,talentData,season) { } const optimization=request.mode==='talents'?await prepareTalents(profile,request,talentData,source):null; const variants=optimization?.variants||createVariants(profile,request,catalog); - if(variants.length*scenarios.length>256) throw new Error('Maximum 256 runs per job.'); return {engine,profile,settings,scenarios,variants,search:optimization?.search}; } export function inputFor(variant,settings,scenario,paths) { @@ -439,7 +438,7 @@ export class Jobs { await fs.writeFile(input,inputFor({text:profile.text},settings,scenario,paths)+(settings.tank?`profileset_metric=${tankMetricList}\nprofileset_main_actor_index=1\n`:'')+(spec?.support?'profileset_metric=raid_dps,dps\n':'')+profilesetLines(list,profile,this.catalog).join('\n')+'\n'); await this.save(this.public(job)); try{ - await this.execute(input,dir,job,6*3600000); + await this.execute(input,dir,job); if(job.status==='cancelled')return null; const report=JSON.parse(await fs.readFile(paths.json,'utf8')); const result=profilesetResults(report,settings.tank,{raid:!!spec?.support}); @@ -470,10 +469,12 @@ export class Jobs { job.done++;await this.save(this.public(job)); return boss; } - execute(input,dir,job,timeout=3600000){return new Promise((resolve,reject)=>{ + // A run takes as long as it takes: no time limit, since a job is cancelled from the app when it should stop. + // A timeout is only set where a caller asks for one. + execute(input,dir,job,timeout=null){return new Promise((resolve,reject)=>{ const child=spawn(executable,[input],{cwd:dir,windowsHide:true,shell:false});this.child=child;let tail='';job.progress=null; let localeMissing=false;const capture=data=>{tail=(tail+data.toString()).slice(-12000);if(tail.includes('_S_create_c_locale'))localeMissing=true;job.log=tail;const progress=parseProgress(tail.slice(-2000));if(progress)job.progress=progress;};child.stdout.on('data',capture);child.stderr.on('data',capture); - const timer=setTimeout(()=>{child.kill();reject(new Error(`The run exceeded ${timeout/60000} minutes.`));},timeout); + const timer=Number.isFinite(timeout)?setTimeout(()=>{child.kill();reject(new Error(`The run exceeded ${timeout/60000} minutes.`));},timeout):null; child.on('error',e=>{clearTimeout(timer);reject(e);});child.on('close',code=>{clearTimeout(timer);if(code===0)resolve(); // On Linux SimC writes its HTML report under the en_US.UTF-8 locale and fails when the system lacks it. // The JSON result is complete by then, so the run counts; only the HTML download is missing. diff --git a/lib/profile.mjs b/lib/profile.mjs index a881869..a2503c5 100644 --- a/lib/profile.mjs +++ b/lib/profile.mjs @@ -66,7 +66,6 @@ export function createVariants(profile, request, catalog) { }); let combinations = request.combine ? [{name:'',lines:[]}] : []; if (request.combine) for (const group of groups) { - if (combinations.length * group.length > 128) throw new Error('Maximum 128 enchant combinations per job.'); combinations = combinations.flatMap(c=>group.map(v=>({name:c.name ? `${c.name} + ${v.name}` : v.name,lines:[...c.lines,v.line]}))); } else combinations = groups.flat().map(v=>({name:v.name,lines:[v.line]})); for (const c of combinations) variants.push({name:c.name,text:applyOverrides(profile.text,c.lines.join('\n'))}); diff --git a/lib/trinkets.mjs b/lib/trinkets.mjs index c0daf9f..136d37b 100644 --- a/lib/trinkets.mjs +++ b/lib/trinkets.mjs @@ -6,7 +6,8 @@ import {eligible,sourceKinds,carried} from './upgrades.mjs'; import {loadReferenceSpecs,dropLevels,craftedItemLevel,completedSet} from './weapons.mjs'; import {normalizeTank} from './tank.mjs'; -export const limits={specs:48,candidates:8000,finalists:[16,32,64]}; +// No cap on how much a job may simulate: the app runs on your own PC, and a long job is your call to make. +export const limits={specs:48,finalists:[16,32,64]}; export const trinketSlots=['trinket1','trinket2']; const isTrinket=item=>item?.itemClass===4&&item?.inventoryType===12; @@ -107,7 +108,6 @@ export async function prepareTrinkets(request,catalog,season,talentData,source,s } if(!specs.length)throw new Error('No selected specialization has a trinket to rank.'); const total=specs.reduce((n,s)=>n+s.candidates.length,0); - if(total*scenarios>limits.candidates)throw new Error(`The selection produces ${total} trinket and item level pairs across ${scenarios} scenario${scenarios===1?'':'s'}. Narrow it to ${limits.candidates} runs or fewer.`); const levels=specs.flatMap(s=>s.candidates.map(c=>c.itemLevel)); return {season:season.season,sources:equal?{equal:true,steps:steps.map(s=>s.label)}:drops.public,equal,steps,finalists,specs,skipped,candidates:total, trinkets:specs.reduce((n,s)=>n+trinketCount(s),0),levels:{min:Math.min(...levels),max:Math.max(...levels)}}; diff --git a/lib/upgrades.mjs b/lib/upgrades.mjs index 9baa813..c37eb60 100644 --- a/lib/upgrades.mjs +++ b/lib/upgrades.mjs @@ -14,7 +14,7 @@ const shieldClasses=new Set(['warrior','paladin','shaman']); const statNames={32:'Critical Strike',36:'Haste',40:'Versatility',49:'Mastery'}; export const weaponKinds={17:'two',15:'two',26:'two',13:'one',21:'one',22:'one',14:'shield',23:'held'}; export const sourceKinds={raid:'Raid',mplus:'Mythic+',delves:'Delves',vault:'Great Vault',crafted:'Crafted'}; -export const limits={candidates:800,finalists:[24,48,96]}; +export const limits={finalists:[24,48,96]}; // Season pools come from the pinned client data: the active season's bonus-roll group names the raid // encounters and Mythic+ dungeons, so no instance or item ID is hardcoded here. @@ -263,7 +263,6 @@ export function buildCandidates(profile,request,data,catalog,specId){ } const list=[...candidates.values()].sort((a,b)=>allSlots.indexOf(a.slot)-allSlots.indexOf(b.slot)||b.itemLevel-a.itemLevel||a.name.localeCompare(b.name)); if(!list.length)throw new Error('No usable items were found in the selected sources for this character.'); - if(list.length>limits.candidates)throw new Error(`The selected sources produce ${list.length} candidates. Narrow the search to ${limits.candidates} or fewer.`); list.forEach((c,i)=>c.key='c'+String(i+1).padStart(3,'0')); // What already holds each limit, so a result can say why an embellished piece is missing. 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]})); diff --git a/lib/weapons.mjs b/lib/weapons.mjs index d6ff564..45a496c 100644 --- a/lib/weapons.mjs +++ b/lib/weapons.mjs @@ -15,7 +15,7 @@ import {healerSpecs,healerCandidates,contents as healerContents} from './healers export const isSupport=info=>info?.class==='evoker'&&info?.spec==='augmentation'; export const kinds={main:'Main hand',offhand:'Off-hand weapon',shield:'Shield',held:'Held in off hand'}; -export const limits={specs:48,candidates:2400,finalists:[12,24,48]}; +export const limits={specs:48,finalists:[12,24,48]}; // Distance behind the best result in the same list: percent of DPS, or score points for tanks. export const tiers=[{tier:'S',behind:0.5},{tier:'A',behind:1.5},{tier:'B',behind:3},{tier:'C',behind:5},{tier:'D',behind:Infinity}]; @@ -271,7 +271,6 @@ export async function prepareWeapons(request,catalog,season,talentData,source,sc if(!specs.length)throw new Error('No selected specialization can use the chosen weapon categories.'); // Healer weapons are read once rather than simulated per scenario, so they do not count against the limit. const total=specs.reduce((n,s)=>n+(s.healer?s.candidates.length:simulatedCount(s)),0),simulated=specs.filter(s=>!s.healer).reduce((n,s)=>n+simulatedCount(s),0); - if(simulated*scenarios>limits.candidates)throw new Error(`The selection produces ${simulated} simulated candidates across ${scenarios} scenario${scenarios===1?'':'s'}. Narrow it to ${limits.candidates} candidate runs or fewer.`); // Healer scores are measured against a whole character: SimC's own Shadow Priest, an intellect caster of the // season, stands in for the healer's other gear. Its text stays on the server like every reference profile's. const standIn=specs.some(s=>s.healer)?available.find(s=>s.key==='priest-shadow')||available.find(s=>!s.healer&&['mage','warlock'].includes(s.class)):null; diff --git a/package-lock.json b/package-lock.json index dffa7ea..ea55bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simc-lab", - "version": "1.18.0", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simc-lab", - "version": "1.18.0", + "version": "1.19.0", "license": "GPL-3.0-or-later", "devDependencies": { "luaparse": "^0.3.1", diff --git a/package.json b/package.json index 9c148df..71c9443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simc-lab", - "version": "1.18.0", + "version": "1.19.0", "private": true, "type": "module", "scripts": { diff --git a/tests/weapons.test.mjs b/tests/weapons.test.mjs index 7b15bfc..96990f1 100644 --- a/tests/weapons.test.mjs +++ b/tests/weapons.test.mjs @@ -289,7 +289,8 @@ test('a plan counts its runs and refuses a selection that cannot be simulated',a await assert.rejects(prepareWeapons({weapons:{kinds:['nonsense']}},catalog,season,talentData,dir,1),/weapon category/); await assert.rejects(prepareWeapons({weapons:{equal:true,track:track.id,level:9}},catalog,season,talentData,dir,1),/upgrade track and level/); await assert.rejects(prepareWeapons({weapons:{sources:{crafted:{itemLevel:0}}}},catalog,season,talentData,dir,1),/Crafted item level/); - await assert.rejects(prepareWeapons({weapons:{specs:['warrior-arms']}},catalog,season,talentData,dir,3000),/Narrow it/); + // No cap on how much a job may simulate: many scenarios are still accepted. + assert.equal((await prepareWeapons({weapons:{specs:['warrior-arms']}},catalog,season,talentData,dir,3000)).specs.length,1); await fs.rm(dir,{recursive:true,force:true}); });