Files
i-want-to-heal/scripts/iwt2-pvp-roguelike-boss-sim.mjs
T
2026-07-08 22:30:44 -04:00

780 lines
25 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 DEFAULT_TICKS_PER_WORKER_SECOND = 45000
const ESTIMATE_STARTUP_SECONDS = 0.8
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 BOSS_IDS = [
'bulldrome',
'yian-kut-ku',
'great-jaggi',
'khezu',
'rathian',
'barroth',
'tobi-kadachi',
'rimebastion',
'ember-mantis-duelist',
'cinderback-ricochet',
'obsidian-ram-golem',
'stormcoil-wyrm',
'venom-orchid-hydra',
'sandglass-scorpion',
'crystal-bat-matriarch',
'hollowcrown-revenant',
]
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'),
estimateOnly: args.has('--estimate-only'),
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,
estimateOnly,
gearLevel,
maxSeconds,
repeats,
requiredBosses,
stages,
top,
workers,
}) {
const safeWorkers = Math.max(1, Math.floor(workers))
const estimate = estimateRun({
bossCount,
bossFilter,
classes,
maxSeconds,
repeats,
requiredBosses,
stages,
workers: safeWorkers,
})
if (estimateOnly) {
console.log(JSON.stringify({
config: {
bossCount,
bosses: bossFilter,
classes,
repeats,
requiredBosses,
seconds: maxSeconds,
stages,
workers: safeWorkers,
},
estimate,
}, null, 2))
return
}
const startedAt = Date.now()
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 elapsedSeconds = round((Date.now() - startedAt) / 1000)
const summary = summarizeResults(results)
console.log(JSON.stringify({
config: {
bossCount,
bossDamagePercent,
bossHpPercent,
bosses: bossFilter,
classes,
gearLevel,
elapsedSeconds,
estimate,
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
--estimate-only
--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
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --estimate-only
`)
}
function estimateRun({
bossCount,
bossFilter,
classes,
maxSeconds,
repeats,
requiredBosses,
stages,
workers,
}) {
const bossIds = filteredBossIds(BOSS_IDS, bossFilter)
const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses)
const healerStyles = filteredClasses(classes)
const orderedBossGroups = permutations(bossIds, bossCount)
.filter((group) => requiredBossIds.every((bossId) => group.includes(bossId)))
const trials = orderedBossGroups.length * healerStyles.length * stages.length * repeats
const maxTicks = trials * maxSeconds * (1 / DEFAULT_DT)
const estimatedWallSeconds = ESTIMATE_STARTUP_SECONDS + (maxTicks / (Math.max(1, workers) * DEFAULT_TICKS_PER_WORKER_SECOND))
return {
bossPoolSize: bossIds.length,
classCount: healerStyles.length,
orderedBossGroupCount: orderedBossGroups.length,
requiredBosses: requiredBossIds,
stageCount: stages.length,
repeats,
trials,
maxSimulatedSeconds: round(trials * maxSeconds),
maxTicks: Math.round(maxTicks),
estimatedWallSeconds: round(estimatedWallSeconds),
note: 'Rough wall-time estimate; victories or defeats can end trials early.',
}
}
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
}