693 lines
23 KiB
JavaScript
693 lines
23 KiB
JavaScript
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
|
|
}
|