Android build v1.1.34
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user