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(` + + + + + IWT2 PvP Sim GUI + + + +
+ +
+
Idle
+
Run a simulation to see dashboard results.
+
+ Raw output +

+      
+
+
+ + +`) +} + +function readJson(request) { + return new Promise((resolveRequest, rejectRequest) => { + let body = '' + request.on('data', (chunk) => { + body += chunk.toString() + }) + request.on('end', () => { + try { + resolveRequest(body ? JSON.parse(body) : {}) + } catch (error) { + rejectRequest(error) + } + }) + request.on('error', rejectRequest) + }) +} + +function sendJson(response, value) { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify(value)) +} + +function writeEvent(response, event, data) { + response.write(`event: ${event}\n`) + response.write(`data: ${JSON.stringify(data)}\n\n`) +} + +function listOrAll(value, allowed) { + if (!Array.isArray(value) || value.length === 0 || value.includes('all')) return 'all' + const selected = value.filter((item) => allowed.includes(item)) + return selected.length ? selected.join(',') : 'all' +} + +function listOrNone(value, allowed) { + if (!Array.isArray(value) || value.length === 0 || value.includes('none')) return 'none' + const selected = value.filter((item) => allowed.includes(item)) + return selected.length ? selected.join(',') : 'none' +} + +function scalar(value, fallback) { + return value === undefined || value === null || value === '' ? fallback : String(value) +} + +function extractJson(output) { + const start = output.indexOf('{') + const end = output.lastIndexOf('}') + if (start < 0 || end < start) return '' + const candidate = output.slice(start, end + 1) + try { + return JSON.stringify(JSON.parse(candidate), null, 2) + } catch { + return '' + } +} diff --git a/scripts/iwt2_pvp_sim_gui.py b/scripts/iwt2_pvp_sim_gui.py new file mode 100644 index 0000000..a145a5e --- /dev/null +++ b/scripts/iwt2_pvp_sim_gui.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import queue +import subprocess +import threading +from pathlib import Path +try: + import tkinter as tk + from tkinter import filedialog, messagebox, ttk +except ModuleNotFoundError: + print( + "This Python install does not include Tkinter.\n" + "Use the browser GUI instead:\n" + " node scripts/iwt2-pvp-sim-gui-server.mjs\n" + "Then open http://localhost:8787\n" + ) + raise SystemExit(1) + + +ROOT = Path(__file__).resolve().parents[1] +SIM_SCRIPT = ROOT / "scripts" / "iwt2-pvp-roguelike-boss-sim.mjs" + +HEALER_CLASSES = ["dawnweaver", "lifebinder", "runesage"] +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", +] + + +class SimGui(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title("IWT2 PvP Roguelike Sim") + self.geometry("1120x780") + self.minsize(920, 640) + + self.output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() + self.process: subprocess.Popen[str] | None = None + self.last_json = "" + + self.class_vars = {name: tk.BooleanVar(value=True) for name in HEALER_CLASSES} + self.boss_vars = {boss_id: tk.BooleanVar(value=True) for boss_id in BOSS_IDS} + self.all_classes_var = tk.BooleanVar(value=True) + self.all_bosses_var = tk.BooleanVar(value=True) + + self.gear_level_var = tk.StringVar(value="5") + self.boss_count_var = tk.StringVar(value="2") + self.hp_percent_var = tk.StringVar(value="100") + self.damage_percent_var = tk.StringVar(value="100") + self.stages_var = tk.StringVar(value="1,2") + self.seconds_var = tk.StringVar(value="180") + self.workers_var = tk.StringVar(value="8") + self.top_var = tk.StringVar(value="24") + self.status_var = tk.StringVar(value="Idle") + + self._build_ui() + self.after(100, self._drain_output_queue) + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=0) + self.columnconfigure(1, weight=1) + self.rowconfigure(0, weight=1) + + controls = ttk.Frame(self, padding=12) + controls.grid(row=0, column=0, sticky="nsew") + + output_frame = ttk.Frame(self, padding=(0, 12, 12, 12)) + output_frame.grid(row=0, column=1, sticky="nsew") + output_frame.columnconfigure(0, weight=1) + output_frame.rowconfigure(1, weight=1) + + self._build_controls(controls) + + output_header = ttk.Frame(output_frame) + output_header.grid(row=0, column=0, sticky="ew", pady=(0, 8)) + output_header.columnconfigure(0, weight=1) + ttk.Label(output_header, textvariable=self.status_var).grid(row=0, column=0, sticky="w") + ttk.Button(output_header, text="Save JSON", command=self._save_json).grid(row=0, column=1, padx=(8, 0)) + ttk.Button(output_header, text="Clear", command=self._clear_output).grid(row=0, column=2, padx=(8, 0)) + + self.output = tk.Text(output_frame, wrap="none", font=("Menlo", 11)) + self.output.grid(row=1, column=0, sticky="nsew") + yscroll = ttk.Scrollbar(output_frame, orient="vertical", command=self.output.yview) + yscroll.grid(row=1, column=1, sticky="ns") + xscroll = ttk.Scrollbar(output_frame, orient="horizontal", command=self.output.xview) + xscroll.grid(row=2, column=0, sticky="ew") + self.output.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set) + + def _build_controls(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + + ttk.Label(parent, text="Simulation").grid(row=0, column=0, sticky="w") + scalar_grid = ttk.Frame(parent) + scalar_grid.grid(row=1, column=0, sticky="ew", pady=(8, 12)) + for column in range(2): + scalar_grid.columnconfigure(column, weight=1) + + fields = [ + ("Gear level", self.gear_level_var), + ("Boss count", self.boss_count_var), + ("Boss HP %", self.hp_percent_var), + ("Boss damage %", self.damage_percent_var), + ("Stages", self.stages_var), + ("Seconds", self.seconds_var), + ("Workers", self.workers_var), + ("Top rows", self.top_var), + ] + for index, (label, variable) in enumerate(fields): + row = index // 2 + column = (index % 2) * 2 + ttk.Label(scalar_grid, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=3) + ttk.Entry(scalar_grid, textvariable=variable, width=10).grid(row=row, column=column + 1, sticky="ew", pady=3) + + ttk.Checkbutton( + parent, + text="All classes", + variable=self.all_classes_var, + command=self._toggle_all_classes, + ).grid(row=2, column=0, sticky="w") + class_frame = ttk.Frame(parent) + class_frame.grid(row=3, column=0, sticky="ew", pady=(4, 12)) + for index, class_id in enumerate(HEALER_CLASSES): + ttk.Checkbutton( + class_frame, + text=class_id, + variable=self.class_vars[class_id], + command=self._sync_all_classes, + ).grid(row=0, column=index, sticky="w", padx=(0, 10)) + + ttk.Checkbutton( + parent, + text="All bosses", + variable=self.all_bosses_var, + command=self._toggle_all_bosses, + ).grid(row=4, column=0, sticky="w") + + boss_canvas = tk.Canvas(parent, height=300, highlightthickness=0) + boss_scroll = ttk.Scrollbar(parent, orient="vertical", command=boss_canvas.yview) + boss_frame = ttk.Frame(boss_canvas) + boss_frame.bind( + "", + lambda _event: boss_canvas.configure(scrollregion=boss_canvas.bbox("all")), + ) + boss_canvas.create_window((0, 0), window=boss_frame, anchor="nw") + boss_canvas.configure(yscrollcommand=boss_scroll.set) + boss_canvas.grid(row=5, column=0, sticky="nsew", pady=(4, 12)) + boss_scroll.grid(row=5, column=1, sticky="ns", pady=(4, 12)) + + for index, boss_id in enumerate(BOSS_IDS): + ttk.Checkbutton( + boss_frame, + text=boss_id, + variable=self.boss_vars[boss_id], + command=self._sync_all_bosses, + ).grid(row=index, column=0, sticky="w", pady=2) + + button_row = ttk.Frame(parent) + button_row.grid(row=6, column=0, sticky="ew") + button_row.columnconfigure(0, weight=1) + self.run_button = ttk.Button(button_row, text="Run Simulation", command=self._start_sim) + self.run_button.grid(row=0, column=0, sticky="ew") + self.stop_button = ttk.Button(button_row, text="Stop", command=self._stop_sim, state="disabled") + self.stop_button.grid(row=0, column=1, padx=(8, 0)) + + ttk.Button(parent, text="Copy Command", command=self._copy_command).grid(row=7, column=0, sticky="ew", pady=(8, 0)) + + def _toggle_all_classes(self) -> None: + value = self.all_classes_var.get() + for variable in self.class_vars.values(): + variable.set(value) + + def _sync_all_classes(self) -> None: + self.all_classes_var.set(all(variable.get() for variable in self.class_vars.values())) + + def _toggle_all_bosses(self) -> None: + value = self.all_bosses_var.get() + for variable in self.boss_vars.values(): + variable.set(value) + + def _sync_all_bosses(self) -> None: + self.all_bosses_var.set(all(variable.get() for variable in self.boss_vars.values())) + + def _selected_classes(self) -> str: + if self.all_classes_var.get(): + return "all" + selected = [name for name, variable in self.class_vars.items() if variable.get()] + return ",".join(selected) if selected else "all" + + def _selected_bosses(self) -> str: + if self.all_bosses_var.get(): + return "all" + selected = [boss_id for boss_id, variable in self.boss_vars.items() if variable.get()] + return ",".join(selected) if selected else "all" + + def _command(self) -> list[str]: + return [ + "node", + str(SIM_SCRIPT), + "--classes", + self._selected_classes(), + "--gear-level", + self.gear_level_var.get().strip() or "5", + "--boss-count", + self.boss_count_var.get().strip() or "2", + "--bosses", + self._selected_bosses(), + "--boss-hp-percent", + self.hp_percent_var.get().strip() or "100", + "--boss-damage-percent", + self.damage_percent_var.get().strip() or "100", + "--stages", + self.stages_var.get().strip() or "1,2", + "--seconds", + self.seconds_var.get().strip() or "180", + "--workers", + self.workers_var.get().strip() or "8", + "--top", + self.top_var.get().strip() or "24", + ] + + def _start_sim(self) -> None: + if self.process is not None: + return + command = self._command() + self._clear_output() + self._append_output("$ " + " ".join(command) + "\n\n") + self.status_var.set("Running...") + self.run_button.configure(state="disabled") + self.stop_button.configure(state="normal") + + thread = threading.Thread(target=self._run_subprocess, args=(command,), daemon=True) + thread.start() + + def _run_subprocess(self, command: list[str]) -> None: + try: + self.process = subprocess.Popen( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert self.process.stdout is not None + chunks: list[str] = [] + for line in self.process.stdout: + chunks.append(line) + self.output_queue.put(("line", line)) + exit_code = self.process.wait() + output = "".join(chunks) + self.output_queue.put(("done", json.dumps({"exitCode": exit_code, "output": output}))) + except Exception as exc: # noqa: BLE001 + self.output_queue.put(("error", str(exc))) + + def _drain_output_queue(self) -> None: + try: + while True: + kind, payload = self.output_queue.get_nowait() + if kind == "line" and payload is not None: + self._append_output(payload) + elif kind == "done" and payload is not None: + result = json.loads(payload) + self.last_json = self._extract_json(result["output"]) + self.status_var.set(f"Finished: exit {result['exitCode']}") + self.process = None + self.run_button.configure(state="normal") + self.stop_button.configure(state="disabled") + elif kind == "error" and payload is not None: + self.status_var.set("Failed") + self._append_output(f"\nERROR: {payload}\n") + self.process = None + self.run_button.configure(state="normal") + self.stop_button.configure(state="disabled") + except queue.Empty: + pass + self.after(100, self._drain_output_queue) + + def _stop_sim(self) -> None: + if self.process is None: + return + self.process.terminate() + self.status_var.set("Stopping...") + + def _clear_output(self) -> None: + self.output.delete("1.0", "end") + self.last_json = "" + + def _append_output(self, text: str) -> None: + self.output.insert("end", text) + self.output.see("end") + + def _copy_command(self) -> None: + command = " ".join(self._command()) + self.clipboard_clear() + self.clipboard_append(command) + self.status_var.set("Command copied") + + def _save_json(self) -> None: + output = self.last_json or self._extract_json(self.output.get("1.0", "end")) + if not output: + messagebox.showinfo("No JSON", "Run a simulation first.") + return + path = filedialog.asksaveasfilename( + defaultextension=".json", + filetypes=[("JSON", "*.json"), ("All files", "*.*")], + initialfile="iwt2-pvp-roguelike-sim.json", + ) + if not path: + return + Path(path).write_text(output, encoding="utf-8") + self.status_var.set(f"Saved {path}") + + @staticmethod + def _extract_json(text: str) -> str: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end < start: + return "" + candidate = text[start : end + 1] + try: + parsed = json.loads(candidate) + except json.JSONDecodeError: + return "" + return json.dumps(parsed, indent=2) + + +if __name__ == "__main__": + SimGui().mainloop() diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index a305e1c..7d1ba88 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -49,6 +49,11 @@ import { import { createIwt2WeightedRoguelikeBossPair, createUniformIwt2RoguelikeBossPair, + enabledIwt2RoguelikeBossPool, + IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED, + IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS, + IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED, + IWT2_PVP_STADIUM_ENABLED_BOSS_IDS, IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED, IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED, IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED, @@ -613,11 +618,27 @@ function createRoguelikeBossPair( : contentType === 'stadium' ? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED : IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED + const bossPool = roguelikeBossPoolFor(variant, contentType) if (weightedProgressionEnabled) { - return createIwt2WeightedRoguelikeBossPair(stage) + return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool }) } - return createUniformIwt2RoguelikeBossPair() + return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool }) +} + +function roguelikeBossPoolFor( + variant: Iwt2RoguelikeVariant, + contentType: Iwt2RoguelikeContentType, +): Iwt2BossId[] | undefined { + if (variant !== 'pvp') return undefined + if (contentType === 'stadium') { + return IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED + ? enabledIwt2RoguelikeBossPool(IWT2_PVP_STADIUM_ENABLED_BOSS_IDS) + : undefined + } + return IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED + ? enabledIwt2RoguelikeBossPool(IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS) + : undefined } function chooseRunChoices(items: readonly T[], count: number): T[] { diff --git a/src/modes/iwt2/content/bosses.ts b/src/modes/iwt2/content/bosses.ts index 2b44b66..0947db4 100644 --- a/src/modes/iwt2/content/bosses.ts +++ b/src/modes/iwt2/content/bosses.ts @@ -220,11 +220,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = { firePuddleRadius: 42, firePuddleDamage: 15, firePuddleSeconds: 8, - birdWaveThresholds: [0.9, 0.4], + birdWaveThresholds: [0.5], birdFlightCooldown: 10, birdFlightWindup: 0.85, birdFlightSpeed: 360, - birdHealth: 100, + birdHealth: 55, birdRadius: 16, birdContactDamage: 26, birdStunSeconds: 0.75, diff --git a/src/modes/iwt2/content/pvpGearNormalization.ts b/src/modes/iwt2/content/pvpGearNormalization.ts index e450a7a..9a29692 100644 --- a/src/modes/iwt2/content/pvpGearNormalization.ts +++ b/src/modes/iwt2/content/pvpGearNormalization.ts @@ -10,9 +10,11 @@ export type Iwt2PvpGearNormalizationConfig = { gearLevel: Iwt2GearLevel } +export const IWT2_PVP_NORMALIZED_GEAR_LEVEL: Iwt2GearLevel = 5 + export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = { enabled: true, - gearLevel: 5, + gearLevel: IWT2_PVP_NORMALIZED_GEAR_LEVEL, } export function createIwt2PvpNormalizedGearProgress( diff --git a/src/modes/iwt2/content/roguelikeBossProgression.ts b/src/modes/iwt2/content/roguelikeBossProgression.ts index 2fb799c..a7fd6a7 100644 --- a/src/modes/iwt2/content/roguelikeBossProgression.ts +++ b/src/modes/iwt2/content/roguelikeBossProgression.ts @@ -3,6 +3,12 @@ import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses' export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true export const IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true export const IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED = true +export const IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED = true +export const IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED = true + +export const IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] + +export const IWT2_PVP_STADIUM_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late' @@ -11,6 +17,10 @@ type TierWeight = { weight: number } +type Iwt2RoguelikeBossPoolOptions = { + bossPool?: readonly Iwt2BossId[] +} + const IWT2_ROGUELIKE_BOSS_TIERS: Record = { early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'], mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'], @@ -52,23 +62,28 @@ export function createIwt2PveRoguelikeBossPair( export function createIwt2WeightedRoguelikeBossPair( stage: number, random: () => number = Math.random, + options: Iwt2RoguelikeBossPoolOptions = {}, ): Iwt2BossId[] { const choices: Iwt2BossId[] = [] const maxThreat = maxThreatForStage(stage) + const bossPool = normalizeBossPool(options.bossPool) while (choices.length < 2) { - const next = chooseWeightedBossForStage(stage, choices, maxThreat, random) + const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool) if (!next) break choices.push(next) } return choices.length === 2 ? choices - : createUniformIwt2RoguelikeBossPair(random) + : createUniformIwt2RoguelikeBossPair(random, { bossPool }) } -export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.random): Iwt2BossId[] { - const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] +export function createUniformIwt2RoguelikeBossPair( + random: () => number = Math.random, + options: Iwt2RoguelikeBossPoolOptions = {}, +): Iwt2BossId[] { + const pool = normalizeBossPool(options.bossPool) const choices: Iwt2BossId[] = [] while (pool.length > 0 && choices.length < 2) { const index = randomIndex(pool.length, random) @@ -78,19 +93,29 @@ export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.r return choices } +export function enabledIwt2RoguelikeBossPool( + bossIds: readonly Iwt2BossId[], +): Iwt2BossId[] { + return normalizeBossPool(bossIds) +} + function chooseWeightedBossForStage( stage: number, selected: readonly Iwt2BossId[], maxThreat: number, random: () => number, + bossPool: readonly Iwt2BossId[], ): Iwt2BossId | undefined { const selectedSet = new Set(selected) + const bossPoolSet = new Set(bossPool) const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0) const weightedTiers = tierWeightsForStage(stage) .map((entry) => ({ ...entry, bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => ( - !selectedSet.has(bossId) && selectedThreat + threatForBoss(bossId) <= maxThreat + bossPoolSet.has(bossId) + && !selectedSet.has(bossId) + && selectedThreat + threatForBoss(bossId) <= maxThreat )), })) .filter((entry) => entry.weight > 0 && entry.bosses.length > 0) @@ -110,6 +135,21 @@ function chooseWeightedBossForStage( return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)] } +function normalizeBossPool(bossPool: readonly Iwt2BossId[] | undefined): Iwt2BossId[] { + const knownBosses = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] + if (!bossPool) return knownBosses + + const knownBossSet = new Set(knownBosses) + const normalized: Iwt2BossId[] = [] + for (const bossId of bossPool) { + if (knownBossSet.has(bossId) && !normalized.includes(bossId)) { + normalized.push(bossId) + } + } + + return normalized.length > 0 ? normalized : knownBosses +} + function tierWeightsForStage(stage: number): TierWeight[] { const safeStage = Math.max(1, Math.floor(stage)) if (safeStage <= 2) { diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index bf96703..d6ddd51 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -52,16 +52,17 @@ type OverlayAction = 'primary' | 'requeue' | 'menu' type OverlayNavEntry = { action: OverlayAction row: number + column: number } const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ - { action: 'primary', row: 0 }, - { action: 'menu', row: 1 }, + { action: 'primary', row: 0, column: 0 }, + { action: 'menu', row: 1, column: 0 }, ] const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ - { action: 'primary', row: 0 }, - { action: 'requeue', row: 1 }, - { action: 'menu', row: 2 }, + { action: 'primary', row: 0, column: 0 }, + { action: 'requeue', row: 0, column: 1 }, + { action: 'menu', row: 0, column: 2 }, ] const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = [] const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7 @@ -278,12 +279,18 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, const active = entries.find((entry) => entry.action === current) ?? entries[0] const candidates = entries.filter((entry) => { if (entry.action === current) return false - if (action === 'navigateUp') return entry.row < active.row - if (action === 'navigateDown') return entry.row > active.row + if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column + if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column + if (action === 'navigateUp') return entry.column === active.column && entry.row < active.row + if (action === 'navigateDown') return entry.column === active.column && entry.row > active.row return false }) if (candidates.length === 0) return current - candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row)) + candidates.sort((a, b) => { + const aDistance = Math.abs(a.row - active.row) + Math.abs(a.column - active.column) + const bDistance = Math.abs(b.row - active.row) + Math.abs(b.column - active.column) + return aDistance - bDistance + }) return candidates[0]?.action ?? current }) }, [pvpRoguelike]) diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts index 4d25760..1834ed0 100644 --- a/src/modes/iwt2/sim/arena.ts +++ b/src/modes/iwt2/sim/arena.ts @@ -69,8 +69,8 @@ export function createInitialIwt2ArenaState( roguelikePressure?: Iwt2RoguelikePressureState, bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT }, ): Iwt2ArenaState { - const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId) - const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale, bounds)) + const initialBossIds = bossIds?.length ? [...bossIds] : chooseInitialBossIds(bossId) + const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, initialBossIds.length, bossHealthScale, bounds)) return { schemaVersion: 1, time: 0, @@ -97,9 +97,15 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] { return [primaryBossId, random] } -function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number, bounds: Iwt2ArenaBounds): Iwt2BossEntityState { +function createBossEntity( + bossId: Iwt2BossId, + index: number, + bossCount: number, + healthScale: number, + bounds: Iwt2ArenaBounds, +): Iwt2BossEntityState { const bossMetadata = IWT2_BOSS_METADATA[bossId] - const position = scaleArenaPoint(initialBossPosition(index), bounds) + const position = scaleArenaPoint(initialBossPosition(index, bossCount), bounds) const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale))) return { id: bossId, @@ -136,7 +142,14 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number } } -function initialBossPosition(index: number) { +function initialBossPosition(index: number, bossCount = 2) { + if (bossCount > 2) { + const angle = -Math.PI * 0.5 + (Math.PI * (index + 0.5)) / bossCount + return { + x: 690 + Math.cos(angle) * 120, + y: DEFAULT_ARENA_HEIGHT * 0.5 + Math.sin(angle) * 165, + } + } return { x: index === 0 ? 660 : 760, y: index === 0 ? 190 : 345, @@ -757,6 +770,7 @@ function advanceBossProjectile({ radius: hitMember.radius + projectile.radius, }, { damage: projectile.damage, + damageEventType: 'bossProjectileHit', sourceId: projectile.sourceId, time, }) @@ -774,13 +788,13 @@ function advanceBossProjectile({ bounced = true } - if (bounced) { + if (hitMember) { const puddle = addFirePuddle({ damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!, duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!, hazards: nextHazards, nextHazardId: nextHazardIdValue, - position: hitMember?.position ?? position, + position: hitMember.position, radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!, sourceId: projectile.sourceId, time, diff --git a/src/modes/iwt2/sim/hazards.ts b/src/modes/iwt2/sim/hazards.ts index dc5cdaa..70efd6d 100644 --- a/src/modes/iwt2/sim/hazards.ts +++ b/src/modes/iwt2/sim/hazards.ts @@ -224,6 +224,7 @@ export function tickGroundHazards({ }, { damage: hazard.damage, + damageEventType: 'groundHazardTick', sourceId: hazard.sourceId, time, }, diff --git a/src/modes/iwt2/sim/types.ts b/src/modes/iwt2/sim/types.ts index bdba562..28e1af8 100644 --- a/src/modes/iwt2/sim/types.ts +++ b/src/modes/iwt2/sim/types.ts @@ -288,6 +288,8 @@ export type Iwt2ArenaEventType = | 'partyAttack' | 'partyHealed' | 'partyDamaged' + | 'bossProjectileHit' + | 'groundHazardTick' | 'partyStunned' | 'bossChargeStart' | 'bossChargeHit' diff --git a/src/modes/iwt2/sim/yianKutKuAi.ts b/src/modes/iwt2/sim/yianKutKuAi.ts index 3c131ea..24c339f 100644 --- a/src/modes/iwt2/sim/yianKutKuAi.ts +++ b/src/modes/iwt2/sim/yianKutKuAi.ts @@ -46,7 +46,7 @@ const BIRD_COUNT = 3 const BIRD_MELEE_RANGE = 24 const BIRD_MELEE_COOLDOWN = 1.15 const BIRD_FLIGHT_DAMAGE = 26 -const YIAN_FIREBALL_BOUNCES = 8 +const YIAN_FIREBALL_BOUNCES = 3 const YIAN_SAFE_WALL_MARGIN = 118 const YIAN_CENTER_CAST_DISTANCE = 36 const YIAN_CENTER_CHARGE_SPEED = 430