import { spawn } from 'node:child_process' import { createServer } from 'vite' const DEFAULT_WORKERS = Math.max(1, Math.min(8, Number.parseInt(process.env.IWT2_SIM_WORKERS ?? '8', 10))) const DEFAULT_MAX_SECONDS = Math.max(30, Number.parseFloat(process.env.IWT2_SIM_SECONDS ?? '180')) const DEFAULT_DT = 1 / 30 const PVP_BOSS_HEALTH_MULTIPLIER = 0.7 const BOSS_HEALTH_PER_STAGE = 0.1 const ARENA_BOUNDS = { width: 960, height: 540 } const HEALER_STYLES = ['dawnweaver', 'lifebinder', 'runesage'] const activeChildren = new Set() for (const signal of ['SIGINT', 'SIGTERM']) { process.on(signal, () => { for (const child of activeChildren) { child.kill(signal) } process.exit(signal === 'SIGINT' ? 130 : 143) }) } const args = parseArgs(process.argv.slice(2)) if (args.has('--help')) { printHelp() process.exit(0) } const config = { bossCount: parsePositiveInt(args.get('--boss-count') ?? '2', 2), bossDamagePercent: parsePositiveNumber(args.get('--boss-damage-percent') ?? '100', 100), bossFilter: parseList(args.get('--bosses') ?? 'all'), bossHpPercent: parsePositiveNumber(args.get('--boss-hp-percent') ?? '100', 100), classes: parseList(args.get('--classes') ?? args.get('--class') ?? 'all'), gearLevel: parseNonNegativeInt(args.get('--gear-level') ?? args.get('--gear') ?? '5', 5), maxSeconds: Number.parseFloat(args.get('--seconds') ?? String(DEFAULT_MAX_SECONDS)), repeats: parsePositiveInt(args.get('--repeats') ?? '1', 1), requiredBosses: parseList(args.get('--required-bosses') ?? args.get('--include-bosses') ?? 'none'), stages: parseStages(args.get('--stages') ?? '1,2'), top: parsePositiveInt(args.get('--top') ?? '24', 24), workers: Number.parseInt(args.get('--workers') ?? String(DEFAULT_WORKERS), 10), } if (args.has('--worker')) { await runWorker({ bossCount: config.bossCount, bossDamagePercent: config.bossDamagePercent, bossFilter: config.bossFilter, bossHpPercent: config.bossHpPercent, classes: config.classes, gearLevel: config.gearLevel, repeats: config.repeats, requiredBosses: config.requiredBosses, shardIndex: Number.parseInt(args.get('--shard-index') ?? '0', 10), shardCount: Number.parseInt(args.get('--shard-count') ?? '1', 10), maxSeconds: config.maxSeconds, stages: config.stages, }) } else { await runMain(config) } async function runMain({ bossCount, bossDamagePercent, bossFilter, bossHpPercent, classes, gearLevel, maxSeconds, repeats, requiredBosses, stages, top, workers, }) { const safeWorkers = Math.max(1, Math.floor(workers)) const childResults = await Promise.all(Array.from({ length: safeWorkers }, (_, shardIndex) => ( runChild({ bossCount, bossDamagePercent, bossFilter, bossHpPercent, classes, gearLevel, repeats, requiredBosses, shardIndex, shardCount: safeWorkers, maxSeconds, stages, }) ))) const results = childResults.flat() const summary = summarizeResults(results) console.log(JSON.stringify({ config: { bossCount, bossDamagePercent, bossHpPercent, bosses: bossFilter, classes, gearLevel, orderedTrials: results.length, repeats, requiredBosses, seconds: maxSeconds, stages, workers: safeWorkers, }, hardestCombos: summary.hardestCombos.slice(0, top), hardestStageCombos: summary.hardestStageCombos.slice(0, top), topMechanics: summary.topMechanics, weakestSingleBosses: summary.weakestSingleBosses.slice(0, 16), }, null, 2)) } function runChild({ bossCount, bossDamagePercent, bossFilter, bossHpPercent, classes, gearLevel, repeats, requiredBosses, shardIndex, shardCount, maxSeconds, stages, }) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [ new URL(import.meta.url).pathname, '--worker', '--boss-count', String(bossCount), '--boss-damage-percent', String(bossDamagePercent), '--bosses', bossFilter.join(','), '--boss-hp-percent', String(bossHpPercent), '--classes', classes.join(','), '--gear-level', String(gearLevel), '--repeats', String(repeats), '--required-bosses', requiredBosses.join(','), '--shard-index', String(shardIndex), '--shard-count', String(shardCount), '--seconds', String(maxSeconds), '--stages', stages.join(','), ], { cwd: process.cwd(), env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' }, stdio: ['ignore', 'pipe', 'pipe'], }) activeChildren.add(child) let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => { stdout += chunk }) child.stderr.on('data', (chunk) => { stderr += chunk }) child.on('error', reject) child.on('close', (code) => { activeChildren.delete(child) if (code !== 0) { reject(new Error(`worker ${shardIndex} exited ${code}\n${stderr}`)) return } try { resolve(JSON.parse(stdout)) } catch (error) { reject(new Error(`worker ${shardIndex} returned invalid JSON\n${stdout}\n${stderr}\n${error}`)) } }) }) } async function runWorker({ bossCount, bossDamagePercent, bossFilter, bossHpPercent, classes, gearLevel, repeats, requiredBosses, shardIndex, shardCount, maxSeconds, stages, }) { const server = await createServer({ appType: 'custom', configFile: 'vite.config.ts', logLevel: 'error', server: { middlewareMode: true }, }) try { const modules = await loadSimModules(server) const bossIds = filteredBossIds(Object.keys(modules.IWT2_BOSS_METADATA), bossFilter) const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses) const healerStyles = filteredClasses(classes) const orderedBossGroups = permutations(bossIds, bossCount) .filter((group) => requiredBossIds.every((bossId) => group.includes(bossId))) if (orderedBossGroups.length === 0) { throw new Error(`No boss combinations generated. Boss count ${bossCount}; required bosses: ${requiredBossIds.join(', ') || 'none'}.`) } const tasks = [] for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex += 1) { for (const stage of stages) { for (const healerStyle of healerStyles) { for (const group of orderedBossGroups) { tasks.push({ bossDamagePercent, bossHpPercent, bossIds: group, gearLevel, healerStyle, repeatIndex, stage, }) } } } } const results = [] for (let index = 0; index < tasks.length; index += 1) { if (index % shardCount !== shardIndex) continue results.push(simulateTrial(modules, tasks[index], maxSeconds)) } console.log(JSON.stringify(results)) } finally { await server.close() } } async function loadSimModules(server) { const sim = await server.ssrLoadModule('/src/modes/iwt2/sim/index.ts') const bosses = await server.ssrLoadModule('/src/modes/iwt2/content/bosses.ts') const healerAbilities = await server.ssrLoadModule('/src/modes/iwt2/content/healerAbilities.ts') const gear = await server.ssrLoadModule('/src/modes/iwt2/content/pvpGearNormalization.ts') const equipment = await server.ssrLoadModule('/src/modes/iwt2/sim/equipmentStats.ts') const pressure = await server.ssrLoadModule('/src/modes/iwt2/sim/roguelikePressure.ts') return { ...sim, ...bosses, ...healerAbilities, ...gear, ...equipment, ...pressure, } } function simulateTrial(modules, task, maxSeconds) { const gearProgress = modules.createIwt2PvpNormalizedGearProgress({ enabled: true, gearLevel: task.gearLevel, }) const bossHealthScale = (1 + Math.max(0, task.stage - 1) * BOSS_HEALTH_PER_STAGE) * PVP_BOSS_HEALTH_MULTIPLIER * (task.bossHpPercent / 100) const damageScale = modules.roguelikeIncomingDamageScale(task.stage, 'dungeon') * (task.bossDamagePercent / 100) let state = modules.createInitialIwt2ArenaState( task.bossIds[0], task.bossIds, bossHealthScale, damageScale, modules.createRoguelikePressureState(task.stage, 'dungeon'), ARENA_BOUNDS, ) state = modules.applyIwt2PveGearStats(state, gearProgress) const abilities = modules.applyIwt2PveGearToHealerAbilities( modules.abilitiesForHealer(task.healerStyle), gearProgress, ) const cooldowns = {} const eventCounts = {} const mechanicDamage = {} const sourceDamage = {} const sourceDefeats = {} let casts = 0 let maxActiveHazards = 0 let maxHostileAdds = 0 let minLivingHealthRatio = 1 let minLivingCount = state.party.length let outcome = 'timeout' for (let step = 0; step < Math.ceil(maxSeconds / DEFAULT_DT); step += 1) { const healResult = maybeCastHeal(modules, state, abilities, cooldowns) state = healResult.state casts += healResult.cast ? 1 : 0 tickCooldowns(cooldowns, DEFAULT_DT) const previousState = state const previousEventId = state.nextEventId state = modules.tickIwt2Arena(state, scriptedMovement(state, task.repeatIndex), DEFAULT_DT) const newEvents = state.events.filter((event) => event.id >= previousEventId) for (const event of newEvents) { eventCounts[event.type] = (eventCounts[event.type] ?? 0) + 1 if (isDamageEvent(event) && typeof event.value === 'number') { sourceDamage[event.sourceId] = (sourceDamage[event.sourceId] ?? 0) + event.value const mechanic = mechanicLabelForEvent(event, previousState) mechanicDamage[mechanic] = (mechanicDamage[mechanic] ?? 0) + event.value } else if (event.type === 'entityDefeated' && typeof event.targetId === 'string' && event.targetId !== 'yian-kut-ku') { sourceDefeats[event.sourceId] = (sourceDefeats[event.sourceId] ?? 0) + 1 } } maxActiveHazards = Math.max(maxActiveHazards, state.hazards.filter((hazard) => hazard.remainingSeconds > hazard.fadeSeconds).length) maxHostileAdds = Math.max(maxHostileAdds, state.hostileAdds.filter((add) => add.health > 0).length) const living = state.party.filter((member) => member.health > 0) minLivingCount = Math.min(minLivingCount, living.length) for (const member of living) { minLivingHealthRatio = Math.min(minLivingHealthRatio, member.health / member.maxHealth) } if (state.bosses.every((boss) => boss.health <= 0)) { outcome = 'victory' break } if (state.party.every((member) => member.health <= 0)) { outcome = 'defeat' break } } const remainingBossHealth = state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0) const maxBossHealth = state.bosses.reduce((total, boss) => total + boss.maxHealth, 0) const partyHealth = state.party.reduce((total, member) => total + Math.max(0, member.health), 0) const partyMaxHealth = state.party.reduce((total, member) => total + member.maxHealth, 0) return { bossIds: task.bossIds, comboKey: comboKey(task.bossIds), deaths: state.party.filter((member) => member.health <= 0).length, eventCounts, gearLevel: task.gearLevel, healerStyle: task.healerStyle, mechanicDamage, maxActiveHazards, maxHostileAdds, minLivingCount, minLivingHealthRatio: round(minLivingHealthRatio), outcome, partyHealthRatio: round(partyHealth / partyMaxHealth), remainingBossHealthRatio: round(remainingBossHealth / maxBossHealth), repeatIndex: task.repeatIndex, sourceDamage, sourceDefeats, seconds: round(state.time), stage: task.stage, casts, } } function maybeCastHeal(modules, state, abilities, cooldowns) { const healer = state.party.find((member) => member.id === 'player-healer') if (!healer || healer.health <= 0) return { cast: false, state } const living = state.party.filter((member) => member.health > 0) if (living.length === 0) return { cast: false, state } const lowest = [...living].sort((a, b) => healthRatio(a) - healthRatio(b))[0] const disabled = [...living] .filter((member) => member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) .sort((a, b) => healthRatio(a) - healthRatio(b))[0] const woundedCount = living.filter((member) => healthRatio(member) < 0.82).length const candidates = [ woundedCount >= 3 ? ['group', lowest.id] : null, disabled && healthRatio(disabled) < 0.95 ? ['cleanse', disabled.id] : null, lowest && healthRatio(lowest) < 0.42 ? ['direct', lowest.id] : null, lowest && healthRatio(lowest) < 0.78 && lowest.shield <= 0 ? ['shield', lowest.id] : null, lowest && healthRatio(lowest) < 0.88 && !hasHot(lowest) ? ['hot', lowest.id] : null, lowest && healthRatio(lowest) < 0.72 ? ['direct', lowest.id] : null, ].filter(Boolean) for (const [kind, targetId] of candidates) { const ability = abilities.find((candidate) => candidate.kind === kind && (cooldowns[candidate.id] ?? 0) <= 0) if (!ability) continue const result = modules.castIwt2HealerAbility(state, ability, targetId) if (!result.cast) continue cooldowns[ability.id] = ability.cooldownSeconds return result } return { cast: false, state } } function scriptedMovement(state, repeatIndex = 0) { const healer = state.party.find((member) => member.id === 'player-healer') if (!healer || healer.health <= 0) return { moveX: 0, moveY: 0 } const livingBosses = state.bosses.filter((boss) => boss.health > 0) const nearestBoss = livingBosses .map((boss) => ({ boss, distance: Math.hypot(boss.position.x - healer.position.x, boss.position.y - healer.position.y) })) .sort((a, b) => a.distance - b.distance)[0]?.boss const away = nearestBoss ? normalize({ x: healer.position.x - nearestBoss.position.x, y: healer.position.y - nearestBoss.position.y }) : { x: 0, y: 0 } const orbitSeconds = Math.max(1, state.time) + repeatIndex * 1.731 const orbit = normalize({ x: Math.cos(orbitSeconds * 0.75), y: Math.sin(orbitSeconds * 0.75) * 0.55, }) const vector = normalize({ x: away.x * 0.6 + orbit.x * 0.4, y: away.y * 0.6 + orbit.y * 0.4, }) return { moveX: vector.x, moveY: vector.y } } function summarizeResults(results) { const byCombo = new Map() const byStageCombo = new Map() const byBoss = new Map() for (const result of results) { pushGroup(byCombo, result.comboKey, result) pushGroup(byStageCombo, `${result.stage}:${result.comboKey}`, result) for (const bossId of new Set(result.bossIds)) { pushGroup(byBoss, bossId, result) } } return { hardestCombos: [...byCombo.entries()] .map(([key, items]) => summarizeGroup(key, items)) .sort(compareDifficulty), hardestStageCombos: [...byStageCombo.entries()] .map(([key, items]) => summarizeGroup(key, items)) .sort(compareDifficulty), topMechanics: topSources(results, 'mechanicDamage'), weakestSingleBosses: [...byBoss.entries()] .map(([key, items]) => summarizeGroup(key, items)) .sort(compareDifficulty), } } function summarizeGroup(key, items) { const failures = items.filter((item) => item.outcome !== 'victory').length const wins = items.length - failures return { key, trials: items.length, failures, winRate: round(wins / items.length), avgDeaths: round(average(items.map((item) => item.deaths))), avgSeconds: round(average(items.map((item) => item.seconds))), avgRemainingBossHealth: round(average(items.map((item) => item.remainingBossHealthRatio))), worstRemainingBossHealth: round(Math.max(...items.map((item) => item.remainingBossHealthRatio))), worstMinLivingCount: Math.min(...items.map((item) => item.minLivingCount)), outcomes: countBy(items.map((item) => item.outcome)), topDamageSources: topSources(items, 'sourceDamage'), topDefeatSources: topSources(items, 'sourceDefeats'), topMechanics: topSources(items, 'mechanicDamage'), maxActiveHazards: Math.max(...items.map((item) => item.maxActiveHazards)), maxHostileAdds: Math.max(...items.map((item) => item.maxHostileAdds)), } } function compareDifficulty(a, b) { return (b.failures - a.failures) || (a.winRate - b.winRate) || (b.avgDeaths - a.avgDeaths) || (b.avgRemainingBossHealth - a.avgRemainingBossHealth) || (b.avgSeconds - a.avgSeconds) } function pushGroup(map, key, item) { const items = map.get(key) if (items) items.push(item) else map.set(key, [item]) } function isDamageEvent(event) { return event.type === 'partyDamaged' || event.type === 'bossChargeHit' || event.type === 'bossProjectileHit' || event.type === 'groundHazardTick' } function mechanicLabelForEvent(event, state) { const sourceId = String(event.sourceId) if (sourceId === 'roguelike-pressure') return 'Roguelike pressure' if (sourceId.startsWith('yian-bird')) { return event.type === 'groundHazardTick' ? 'yian-kut-ku: bird fire puddle' : 'yian-kut-ku: bird add' } if (event.type === 'bossProjectileHit') return `${sourceId}: projectile` if (event.type === 'groundHazardTick') return `${sourceId}: ground hazard` if (event.type === 'bossChargeHit') return `${sourceId}: charge / dash` const boss = state.bosses?.find((entry) => entry.id === sourceId) if (!boss) return `${sourceId}: ${event.type}` return `${sourceId}: ${phaseMechanicLabel(boss.attackPhase)}` } function phaseMechanicLabel(phase) { if (phase.includes('Windup') || phase.includes('Recover')) { return phase .replace(/Windup$/, '') .replace(/Recover$/, '') .replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`) .trim() } if (phase.includes('Pouncing')) return 'pounce' if (phase.includes('Charging')) return 'charge / dash' if (phase.includes('Ricocheting')) return 'ricochet' if (phase.includes('Burrowing')) return 'burrow' if (phase === 'fireballRecover' || phase === 'fireballWindup') return 'fireball' if (phase === 'idle') return 'melee / contact' return phase.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`).trim() } function parseArgs(rawArgs) { const parsed = new Map() for (let index = 0; index < rawArgs.length; index += 1) { const arg = rawArgs[index] if (!arg.startsWith('--')) continue const inlineValueIndex = arg.indexOf('=') if (inlineValueIndex >= 0) { parsed.set(arg.slice(0, inlineValueIndex), arg.slice(inlineValueIndex + 1)) continue } const next = rawArgs[index + 1] if (next && !next.startsWith('--')) { parsed.set(arg, next) index += 1 } else { parsed.set(arg, 'true') } } return parsed } function printHelp() { console.log(`IWT2 PvP roguelike boss simulator Usage: node scripts/iwt2-pvp-roguelike-boss-sim.mjs [options] Options: --classes all|dawnweaver,lifebinder,runesage --gear-level 0|5|N --boss-count 2|3|N --bosses all|bulldrome,yian-kut-ku,... --required-bosses none|yian-kut-ku,... --repeats 1 --boss-hp-percent 100 --boss-damage-percent 100 --stages 1,2 --seconds 180 --workers 8 --top 24 Examples: node scripts/iwt2-pvp-roguelike-boss-sim.mjs --classes dawnweaver --gear-level 0 node scripts/iwt2-pvp-roguelike-boss-sim.mjs --classes all --gear-level 5 --boss-count 3 node scripts/iwt2-pvp-roguelike-boss-sim.mjs --boss-hp-percent 125 --boss-damage-percent 110 node scripts/iwt2-pvp-roguelike-boss-sim.mjs --bosses yian-kut-ku,rathian,rimebastion --boss-count 2 node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --repeats 10 `) } function filteredBossIds(allBossIds, filter) { if (filter.length === 0 || filter.includes('all')) return allBossIds const known = new Set(allBossIds) const selected = filter.filter((bossId) => known.has(bossId)) if (selected.length === 0) { throw new Error(`No valid bosses selected. Known bosses: ${allBossIds.join(', ')}`) } return selected } function filteredClasses(classes) { if (classes.length === 0 || classes.includes('all')) return HEALER_STYLES const selected = classes.filter((healerStyle) => HEALER_STYLES.includes(healerStyle)) if (selected.length === 0) { throw new Error(`No valid classes selected. Known classes: ${HEALER_STYLES.join(', ')}`) } return selected } function filteredRequiredBossIds(bossIds, requiredBosses) { if (requiredBosses.length === 0 || requiredBosses.includes('none') || requiredBosses.includes('all')) return [] const bossSet = new Set(bossIds) const selected = requiredBosses.filter((bossId) => bossSet.has(bossId)) if (selected.length === 0) { throw new Error(`No required bosses are in the selected boss pool. Required: ${requiredBosses.join(', ')}. Pool: ${bossIds.join(', ')}`) } return [...new Set(selected)] } function permutations(items, count) { const safeCount = Math.max(1, Math.min(items.length, Math.floor(count))) const results = [] const walk = (current, remaining) => { if (current.length === safeCount) { results.push(current) return } for (let index = 0; index < remaining.length; index += 1) { const next = remaining[index] walk([...current, next], remaining.filter((_, remainingIndex) => remainingIndex !== index)) } } walk([], items) return results } function parseList(value) { return value .split(',') .map((item) => item.trim()) .filter(Boolean) } function parseStages(value) { return value.split(',').map((stage) => Number.parseInt(stage, 10)).filter(Number.isFinite) } function parsePositiveInt(value, fallback) { const parsed = Number.parseInt(value, 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } function parseNonNegativeInt(value, fallback) { const parsed = Number.parseInt(value, 10) return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback } function parsePositiveNumber(value, fallback) { const parsed = Number.parseFloat(value) return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } function comboKey(bossIds) { return [...bossIds].sort().join(' + ') } function hasHot(member) { return member.hotEffects.some((effect) => effect.remainingSeconds > 1.5) } function healthRatio(member) { return member.health / member.maxHealth } function tickCooldowns(cooldowns, dt) { for (const [id, remaining] of Object.entries(cooldowns)) { const next = Math.max(0, remaining - dt) if (next > 0) cooldowns[id] = next else delete cooldowns[id] } } function countBy(items) { return items.reduce((counts, item) => { counts[item] = (counts[item] ?? 0) + 1 return counts }, {}) } function topSources(items, key) { const totals = {} for (const item of items) { for (const [sourceId, value] of Object.entries(item[key] ?? {})) { totals[sourceId] = (totals[sourceId] ?? 0) + value } } return Object.entries(totals) .sort((first, second) => second[1] - first[1]) .slice(0, 5) .map(([sourceId, value]) => ({ sourceId, value: round(value) })) } function average(values) { return values.reduce((total, value) => total + value, 0) / Math.max(1, values.length) } function normalize(vector) { const magnitude = Math.hypot(vector.x, vector.y) if (magnitude <= 0.0001) return { x: 0, y: 0 } return { x: vector.x / magnitude, y: vector.y / magnitude } } function round(value) { return Math.round(value * 1000) / 1000 }