diff --git a/IWantToHeal-Thor-v1.1.34.apk b/IWantToHeal-Thor-v1.1.34.apk new file mode 100644 index 0000000..0f3fcd5 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.34.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 64be3c1..db5c189 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 114 - versionName "1.1.33" + versionCode 115 + versionName "1.1.34" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/scripts/iwt2-pvp-roguelike-boss-sim.mjs b/scripts/iwt2-pvp-roguelike-boss-sim.mjs new file mode 100644 index 0000000..00dfb60 --- /dev/null +++ b/scripts/iwt2-pvp-roguelike-boss-sim.mjs @@ -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 +} diff --git a/scripts/iwt2-pvp-sim-gui-server.mjs b/scripts/iwt2-pvp-sim-gui-server.mjs new file mode 100644 index 0000000..60ec5d2 --- /dev/null +++ b/scripts/iwt2-pvp-sim-gui-server.mjs @@ -0,0 +1,567 @@ +import http from 'node:http' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const SIM_SCRIPT = resolve(SCRIPT_DIR, 'iwt2-pvp-roguelike-boss-sim.mjs') +const PORT = Number.parseInt(process.env.IWT2_SIM_GUI_PORT ?? '8787', 10) + +const HEALER_CLASSES = ['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', +] + +let activeRun = null + +const server = http.createServer(async (request, response) => { + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${PORT}`}`) + if (request.method === 'GET' && url.pathname === '/') { + sendHtml(response) + return + } + if (request.method === 'GET' && url.pathname === '/config') { + sendJson(response, { bosses: BOSS_IDS, classes: HEALER_CLASSES }) + return + } + if (request.method === 'POST' && url.pathname === '/run') { + const body = await readJson(request) + startRun(body, response) + return + } + if (request.method === 'POST' && url.pathname === '/stop') { + stopRun() + sendJson(response, { ok: true }) + return + } + response.writeHead(404) + response.end('not found') +}) + +server.on('error', (error) => { + if (error.code === 'EADDRINUSE') { + console.error(`Port ${PORT} is already in use. Try: IWT2_SIM_GUI_PORT=${PORT + 1} node scripts/iwt2-pvp-sim-gui-server.mjs`) + process.exit(1) + } + throw error +}) + +server.listen(PORT, () => { + console.log(`IWT2 PvP sim GUI: http://localhost:${PORT}`) +}) + +function startRun(options, response) { + if (activeRun) { + response.writeHead(409, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'A simulation is already running.' })) + return + } + + const command = [ + process.execPath, + SIM_SCRIPT, + '--classes', + listOrAll(options.classes, HEALER_CLASSES), + '--gear-level', + scalar(options.gearLevel, '5'), + '--boss-count', + scalar(options.bossCount, '2'), + '--bosses', + listOrAll(options.bosses, BOSS_IDS), + '--boss-hp-percent', + scalar(options.bossHpPercent, '100'), + '--boss-damage-percent', + scalar(options.bossDamagePercent, '100'), + '--stages', + scalar(options.stages, '1,2'), + '--seconds', + scalar(options.seconds, '180'), + '--workers', + scalar(options.workers, '8'), + '--repeats', + scalar(options.repeats, '1'), + '--required-bosses', + listOrNone(options.requiredBosses, BOSS_IDS), + '--top', + scalar(options.top, '24'), + ] + + response.writeHead(200, { + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + 'content-type': 'text/event-stream', + }) + writeEvent(response, 'status', { command: command.join(' '), status: 'started' }) + + const child = spawn(command[0], command.slice(1), { + cwd: ROOT, + env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + activeRun = child + let output = '' + + child.stdout.on('data', (chunk) => { + const text = chunk.toString() + output += text + writeEvent(response, 'chunk', { text }) + }) + child.stderr.on('data', (chunk) => { + const text = chunk.toString() + output += text + writeEvent(response, 'chunk', { text }) + }) + child.on('close', (code) => { + activeRun = null + writeEvent(response, 'done', { + code, + json: extractJson(output), + }) + response.end() + }) + child.on('error', (error) => { + activeRun = null + writeEvent(response, 'error', { error: String(error) }) + response.end() + }) +} + +function stopRun() { + if (!activeRun) return + activeRun.kill('SIGTERM') +} + +function sendHtml(response) { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(` + +
+ + +