Android build v1.1.34
This commit is contained in:
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 114
|
versionCode 115
|
||||||
versionName "1.1.33"
|
versionName "1.1.34"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>IWT2 PvP Sim GUI</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111419; color: #eef2f6; }
|
||||||
|
body { margin: 0; min-height: 100vh; background: #111419; }
|
||||||
|
main { display: grid; grid-template-columns: 340px minmax(0, 1fr); min-height: 100vh; }
|
||||||
|
aside { border-right: 1px solid #2a3039; padding: 18px; background: #171b22; overflow: auto; }
|
||||||
|
section { padding: 18px; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
h1 { font-size: 18px; margin: 0 0 16px; }
|
||||||
|
h2 { font-size: 13px; margin: 18px 0 8px; color: #b7c2d0; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
label { display: flex; align-items: center; gap: 8px; margin: 7px 0; font-size: 13px; }
|
||||||
|
input[type="text"], input[type="number"] { width: 100%; box-sizing: border-box; background: #0f1217; color: #eef2f6; border: 1px solid #333b47; border-radius: 6px; padding: 8px 9px; }
|
||||||
|
input[type="checkbox"] { accent-color: #61dafb; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.bosses { max-height: 300px; overflow: auto; border: 1px solid #2a3039; border-radius: 6px; padding: 8px; background: #111419; }
|
||||||
|
.buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 16px; }
|
||||||
|
button { border: 1px solid #3a4656; color: #eef2f6; background: #202733; border-radius: 6px; padding: 9px 10px; cursor: pointer; }
|
||||||
|
button.primary { background: #245a7a; border-color: #327aa3; }
|
||||||
|
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
pre { margin: 0; padding: 12px; overflow: auto; background: #0b0e12; border: 1px solid #2a3039; border-radius: 6px; min-height: 180px; white-space: pre; }
|
||||||
|
#dashboard { display: grid; gap: 12px; }
|
||||||
|
.empty { border: 1px dashed #3a4656; border-radius: 6px; color: #96a3b5; padding: 22px; text-align: center; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.metric { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; padding: 12px; }
|
||||||
|
.metric span { color: #96a3b5; display: block; font-size: 12px; margin-bottom: 6px; }
|
||||||
|
.metric strong { display: block; font-size: 24px; }
|
||||||
|
.panel { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
|
||||||
|
.panel h2 { margin: 0; padding: 12px; border-bottom: 1px solid #2a3039; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th, td { border-bottom: 1px solid #242a33; padding: 9px 10px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #111419; color: #b7c2d0; font-weight: 600; position: sticky; top: 0; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
.number { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.pill { display: inline-block; border: 1px solid #3a4656; border-radius: 999px; color: #d9e3ee; background: #202733; padding: 2px 7px; margin: 0 4px 4px 0; font-size: 12px; }
|
||||||
|
.risk-high { color: #ff8d7a; }
|
||||||
|
.risk-mid { color: #ffd166; }
|
||||||
|
.risk-low { color: #7fe3a1; }
|
||||||
|
.bars { display: grid; gap: 9px; padding: 12px; }
|
||||||
|
.bar-row { display: grid; grid-template-columns: minmax(170px, 260px) 1fr 90px; gap: 10px; align-items: center; font-size: 13px; }
|
||||||
|
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d9e3ee; }
|
||||||
|
.bar-track { height: 12px; background: #0f1217; border: 1px solid #2a3039; border-radius: 999px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; background: linear-gradient(90deg, #61dafb, #7fe3a1); border-radius: inherit; }
|
||||||
|
.bar-value { color: #b7c2d0; font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
details { border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
|
||||||
|
summary { cursor: pointer; padding: 10px 12px; background: #171b22; color: #b7c2d0; }
|
||||||
|
#raw { min-height: 220px; border-radius: 0; border-left: 0; border-right: 0; border-bottom: 0; }
|
||||||
|
.status { color: #b7c2d0; font-size: 13px; }
|
||||||
|
@media (max-width: 1100px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 820px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #2a3039; } .cards { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<aside>
|
||||||
|
<h1>IWT2 PvP Sim</h1>
|
||||||
|
<h2>Scalars</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Gear <input id="gearLevel" type="number" value="5" min="0"></label>
|
||||||
|
<label>Boss count <input id="bossCount" type="number" value="2" min="1"></label>
|
||||||
|
<label>Boss HP % <input id="bossHpPercent" type="number" value="100" min="1"></label>
|
||||||
|
<label>Boss dmg % <input id="bossDamagePercent" type="number" value="100" min="1"></label>
|
||||||
|
<label>Stages <input id="stages" type="text" value="1,2"></label>
|
||||||
|
<label>Seconds <input id="seconds" type="number" value="180" min="1"></label>
|
||||||
|
<label>Workers <input id="workers" type="number" value="8" min="1"></label>
|
||||||
|
<label>Repeats <input id="repeats" type="number" value="1" min="1"></label>
|
||||||
|
<label>Top <input id="top" type="number" value="24" min="1"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Classes</h2>
|
||||||
|
<label><input id="allClasses" type="checkbox" checked> All classes</label>
|
||||||
|
<div id="classes"></div>
|
||||||
|
|
||||||
|
<h2>Bosses</h2>
|
||||||
|
<label><input id="allBosses" type="checkbox" checked> All bosses</label>
|
||||||
|
<div id="bosses" class="bosses"></div>
|
||||||
|
|
||||||
|
<h2>Required Bosses</h2>
|
||||||
|
<div id="requiredBosses" class="bosses"></div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button id="run" class="primary">Run</button>
|
||||||
|
<button id="stop" disabled>Stop</button>
|
||||||
|
<button id="copy">Copy Command</button>
|
||||||
|
<button id="save">Save JSON</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section>
|
||||||
|
<div id="status" class="status">Idle</div>
|
||||||
|
<div id="dashboard"><div class="empty">Run a simulation to see dashboard results.</div></div>
|
||||||
|
<details open>
|
||||||
|
<summary>Raw output</summary>
|
||||||
|
<pre id="raw"></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const state = { lastJson: null, running: false, config: null };
|
||||||
|
const ids = ["gearLevel","bossCount","bossHpPercent","bossDamagePercent","stages","seconds","workers","repeats","top"];
|
||||||
|
const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
const raw = document.getElementById("raw");
|
||||||
|
const dashboard = document.getElementById("dashboard");
|
||||||
|
const run = document.getElementById("run");
|
||||||
|
const stop = document.getElementById("stop");
|
||||||
|
|
||||||
|
fetch("/config").then(r => r.json()).then(config => {
|
||||||
|
state.config = config;
|
||||||
|
renderChecks("classes", config.classes, true);
|
||||||
|
renderChecks("bosses", config.bosses, true);
|
||||||
|
renderChecks("requiredBosses", config.bosses, false);
|
||||||
|
wireAll("allClasses", "classes");
|
||||||
|
wireAll("allBosses", "bosses");
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderChecks(containerId, values, checked) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
container.innerHTML = values.map(value => '<label><input type="checkbox" value="' + value + '"' + (checked ? ' checked' : '') + '> ' + value + '</label>').join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireAll(masterId, containerId) {
|
||||||
|
const master = document.getElementById(masterId);
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
master.addEventListener("change", () => {
|
||||||
|
container.querySelectorAll("input").forEach(input => input.checked = master.checked);
|
||||||
|
});
|
||||||
|
container.addEventListener("change", () => {
|
||||||
|
master.checked = [...container.querySelectorAll("input")].every(input => input.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected(containerId, masterId) {
|
||||||
|
if (document.getElementById(masterId).checked) return ["all"];
|
||||||
|
const values = [...document.getElementById(containerId).querySelectorAll("input:checked")].map(input => input.value);
|
||||||
|
return values.length ? values : ["all"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedRequiredBosses() {
|
||||||
|
const values = [...document.getElementById("requiredBosses").querySelectorAll("input:checked")].map(input => input.value);
|
||||||
|
return values.length ? values : ["none"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
const requiredBosses = selectedRequiredBosses();
|
||||||
|
let bosses = selected("bosses", "allBosses");
|
||||||
|
if (!bosses.includes("all") && !requiredBosses.includes("none")) {
|
||||||
|
bosses = [...new Set([...bosses, ...requiredBosses])];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bossCount: el.bossCount.value,
|
||||||
|
bossDamagePercent: el.bossDamagePercent.value,
|
||||||
|
bossHpPercent: el.bossHpPercent.value,
|
||||||
|
bosses,
|
||||||
|
classes: selected("classes", "allClasses"),
|
||||||
|
gearLevel: el.gearLevel.value,
|
||||||
|
repeats: el.repeats.value,
|
||||||
|
requiredBosses,
|
||||||
|
seconds: el.seconds.value,
|
||||||
|
stages: el.stages.value,
|
||||||
|
top: el.top.value,
|
||||||
|
workers: el.workers.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFor(options) {
|
||||||
|
return ["node", "scripts/iwt2-pvp-roguelike-boss-sim.mjs",
|
||||||
|
"--classes", options.classes.join(","),
|
||||||
|
"--gear-level", options.gearLevel,
|
||||||
|
"--boss-count", options.bossCount,
|
||||||
|
"--bosses", options.bosses.join(","),
|
||||||
|
"--boss-hp-percent", options.bossHpPercent,
|
||||||
|
"--boss-damage-percent", options.bossDamagePercent,
|
||||||
|
"--stages", options.stages,
|
||||||
|
"--seconds", options.seconds,
|
||||||
|
"--workers", options.workers,
|
||||||
|
"--repeats", options.repeats,
|
||||||
|
"--required-bosses", options.requiredBosses.join(","),
|
||||||
|
"--top", options.top,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
run.addEventListener("click", async () => {
|
||||||
|
const options = payload();
|
||||||
|
raw.textContent = "$ " + commandFor(options) + "\\n\\n";
|
||||||
|
dashboard.innerHTML = '<div class="empty">Simulation running...</div>';
|
||||||
|
state.lastJson = null;
|
||||||
|
state.running = true;
|
||||||
|
run.disabled = true;
|
||||||
|
stop.disabled = false;
|
||||||
|
status.textContent = "Running...";
|
||||||
|
const response = await fetch("/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(options) });
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
status.textContent = "Failed to start";
|
||||||
|
run.disabled = false;
|
||||||
|
stop.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split("\\n\\n");
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
for (const part of parts) handleEvent(part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEvent(block) {
|
||||||
|
const lines = block.split("\\n");
|
||||||
|
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
|
||||||
|
const data = JSON.parse(lines.find(line => line.startsWith("data:"))?.slice(5) ?? "{}");
|
||||||
|
if (event === "chunk") {
|
||||||
|
raw.textContent += data.text;
|
||||||
|
raw.scrollTop = raw.scrollHeight;
|
||||||
|
} else if (event === "status") {
|
||||||
|
status.textContent = data.status;
|
||||||
|
} else if (event === "done") {
|
||||||
|
state.running = false;
|
||||||
|
run.disabled = false;
|
||||||
|
stop.disabled = true;
|
||||||
|
status.textContent = "Finished: exit " + data.code;
|
||||||
|
state.lastJson = data.json;
|
||||||
|
dashboard.innerHTML = data.json ? renderDashboard(JSON.parse(data.json)) : '<div class="empty">No JSON result found.</div>';
|
||||||
|
} else if (event === "error") {
|
||||||
|
status.textContent = data.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard(data) {
|
||||||
|
const combos = data.hardestCombos ?? [];
|
||||||
|
const stageCombos = data.hardestStageCombos ?? [];
|
||||||
|
const singleBosses = data.weakestSingleBosses ?? [];
|
||||||
|
const totalTrials = data.config?.orderedTrials ?? 0;
|
||||||
|
const totalFailures = combos.reduce((total, combo) => total + (combo.failures ?? 0), 0);
|
||||||
|
const worst = combos[0];
|
||||||
|
return [
|
||||||
|
'<div class="cards">',
|
||||||
|
metric('Trials', totalTrials),
|
||||||
|
metric('Worst fail rate', worst ? percent(failRate(worst)) : 'n/a'),
|
||||||
|
metric('Worst combo', worst ? escapeHtml(worst.key) : 'n/a'),
|
||||||
|
metric('Gear / repeats', 'Gear +' + escapeHtml(data.config?.gearLevel ?? '?') + ' / ' + escapeHtml(data.config?.repeats ?? '?') + 'x'),
|
||||||
|
'</div>',
|
||||||
|
totalFailures === 0 ? '<div class="empty risk-low">No failures found in returned hardest-combo rows. Review deaths and fight time for near-fails.</div>' : '',
|
||||||
|
panel('Top Damage By Boss Mechanic', mechanicBars(data.topMechanics ?? [])),
|
||||||
|
panel('Hardest Combos', comboTable(combos, true)),
|
||||||
|
panel('Hardest By Stage', comboTable(stageCombos, false)),
|
||||||
|
panel('Bosses Associated With Most Risk', comboTable(singleBosses, true)),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value) {
|
||||||
|
return '<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + value + '</strong></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(title, content) {
|
||||||
|
return '<div class="panel"><h2>' + escapeHtml(title) + '</h2>' + content + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function comboTable(rows, showDamage) {
|
||||||
|
if (!rows.length) return '<div class="empty">No rows.</div>';
|
||||||
|
return '<table><thead><tr>' +
|
||||||
|
'<th>Combo</th><th class="number">Fail</th><th class="number">Win</th><th class="number">Deaths</th><th class="number">Time</th><th class="number">Boss HP Left</th><th>Top Mechanic</th><th>Top Source</th>' +
|
||||||
|
'</tr></thead><tbody>' +
|
||||||
|
rows.map(row => '<tr>' +
|
||||||
|
'<td><strong>' + escapeHtml(row.key) + '</strong><br>' + outcomePills(row.outcomes) + '</td>' +
|
||||||
|
'<td class="number ' + riskClass(failRate(row)) + '">' + escapeHtml(row.failures ?? 0) + '/' + escapeHtml(row.trials ?? 0) + '</td>' +
|
||||||
|
'<td class="number">' + percent(row.winRate ?? 0) + '</td>' +
|
||||||
|
'<td class="number">' + fixed(row.avgDeaths) + '</td>' +
|
||||||
|
'<td class="number">' + fixed(row.avgSeconds) + 's</td>' +
|
||||||
|
'<td class="number">' + percent(row.avgRemainingBossHealth ?? 0) + '</td>' +
|
||||||
|
'<td>' + damagePills(row.topMechanics) + '</td>' +
|
||||||
|
'<td>' + (showDamage ? damagePills(row.topDamageSources) : damagePills(row.topDamageSources)) + '</td>' +
|
||||||
|
'</tr>').join('') +
|
||||||
|
'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanicBars(sources) {
|
||||||
|
if (!sources.length) return '<div class="empty">No mechanic damage recorded.</div>';
|
||||||
|
const max = Math.max(...sources.map(source => Number(source.value) || 0), 1);
|
||||||
|
return '<div class="bars">' + sources.slice(0, 12).map(source => {
|
||||||
|
const value = Number(source.value) || 0;
|
||||||
|
const width = Math.max(2, (value / max) * 100);
|
||||||
|
return '<div class="bar-row">' +
|
||||||
|
'<div class="bar-label" title="' + escapeHtml(source.sourceId) + '">' + escapeHtml(source.sourceId) + '</div>' +
|
||||||
|
'<div class="bar-track"><div class="bar-fill" style="width:' + width + '%"></div></div>' +
|
||||||
|
'<div class="bar-value">' + fixed(value) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomePills(outcomes) {
|
||||||
|
if (!outcomes) return '';
|
||||||
|
return Object.entries(outcomes)
|
||||||
|
.map(([name, count]) => '<span class="pill">' + escapeHtml(name) + ': ' + escapeHtml(count) + '</span>')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function damagePills(sources) {
|
||||||
|
if (!sources || sources.length === 0) return '<span class="pill">none</span>';
|
||||||
|
return sources.slice(0, 3)
|
||||||
|
.map(source => '<span class="pill">' + escapeHtml(source.sourceId) + ': ' + fixed(source.value) + '</span>')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function failRate(row) {
|
||||||
|
return row.trials ? (row.failures ?? 0) / row.trials : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskClass(rate) {
|
||||||
|
if (rate >= 0.5) return 'risk-high';
|
||||||
|
if (rate > 0) return 'risk-mid';
|
||||||
|
return 'risk-low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(value) {
|
||||||
|
return fixed(value * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixed(value) {
|
||||||
|
const number = Number(value ?? 0);
|
||||||
|
return Number.isInteger(number) ? String(number) : number.toFixed(3).replace(/0+$/, '').replace(/\\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop.addEventListener("click", () => {
|
||||||
|
fetch("/stop", { method: "POST" });
|
||||||
|
status.textContent = "Stopping...";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("copy").addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(commandFor(payload()));
|
||||||
|
status.textContent = "Command copied";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("save").addEventListener("click", () => {
|
||||||
|
if (!state.lastJson) return;
|
||||||
|
const blob = new Blob([state.lastJson], { type: "application/json" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = "iwt2-pvp-roguelike-sim.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
"<Configure>",
|
||||||
|
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()
|
||||||
@@ -49,6 +49,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
createIwt2WeightedRoguelikeBossPair,
|
createIwt2WeightedRoguelikeBossPair,
|
||||||
createUniformIwt2RoguelikeBossPair,
|
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_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
@@ -613,11 +618,27 @@ function createRoguelikeBossPair(
|
|||||||
: contentType === 'stadium'
|
: contentType === 'stadium'
|
||||||
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
||||||
|
|
||||||
if (weightedProgressionEnabled) {
|
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<T>(items: readonly T[], count: number): T[] {
|
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||||
|
|||||||
@@ -220,11 +220,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
firePuddleRadius: 42,
|
firePuddleRadius: 42,
|
||||||
firePuddleDamage: 15,
|
firePuddleDamage: 15,
|
||||||
firePuddleSeconds: 8,
|
firePuddleSeconds: 8,
|
||||||
birdWaveThresholds: [0.9, 0.4],
|
birdWaveThresholds: [0.5],
|
||||||
birdFlightCooldown: 10,
|
birdFlightCooldown: 10,
|
||||||
birdFlightWindup: 0.85,
|
birdFlightWindup: 0.85,
|
||||||
birdFlightSpeed: 360,
|
birdFlightSpeed: 360,
|
||||||
birdHealth: 100,
|
birdHealth: 55,
|
||||||
birdRadius: 16,
|
birdRadius: 16,
|
||||||
birdContactDamage: 26,
|
birdContactDamage: 26,
|
||||||
birdStunSeconds: 0.75,
|
birdStunSeconds: 0.75,
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ export type Iwt2PvpGearNormalizationConfig = {
|
|||||||
gearLevel: Iwt2GearLevel
|
gearLevel: Iwt2GearLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const IWT2_PVP_NORMALIZED_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||||
|
|
||||||
export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = {
|
export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
gearLevel: 5,
|
gearLevel: IWT2_PVP_NORMALIZED_GEAR_LEVEL,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createIwt2PvpNormalizedGearProgress(
|
export function createIwt2PvpNormalizedGearProgress(
|
||||||
|
|||||||
@@ -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_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
export const IWT2_PVP_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_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'
|
type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
|
||||||
|
|
||||||
@@ -11,6 +17,10 @@ type TierWeight = {
|
|||||||
weight: number
|
weight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Iwt2RoguelikeBossPoolOptions = {
|
||||||
|
bossPool?: readonly Iwt2BossId[]
|
||||||
|
}
|
||||||
|
|
||||||
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
||||||
early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'],
|
early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'],
|
||||||
mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'],
|
mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'],
|
||||||
@@ -52,23 +62,28 @@ export function createIwt2PveRoguelikeBossPair(
|
|||||||
export function createIwt2WeightedRoguelikeBossPair(
|
export function createIwt2WeightedRoguelikeBossPair(
|
||||||
stage: number,
|
stage: number,
|
||||||
random: () => number = Math.random,
|
random: () => number = Math.random,
|
||||||
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
): Iwt2BossId[] {
|
): Iwt2BossId[] {
|
||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
const maxThreat = maxThreatForStage(stage)
|
const maxThreat = maxThreatForStage(stage)
|
||||||
|
const bossPool = normalizeBossPool(options.bossPool)
|
||||||
|
|
||||||
while (choices.length < 2) {
|
while (choices.length < 2) {
|
||||||
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random)
|
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
||||||
if (!next) break
|
if (!next) break
|
||||||
choices.push(next)
|
choices.push(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return choices.length === 2
|
return choices.length === 2
|
||||||
? choices
|
? choices
|
||||||
: createUniformIwt2RoguelikeBossPair(random)
|
: createUniformIwt2RoguelikeBossPair(random, { bossPool })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.random): Iwt2BossId[] {
|
export function createUniformIwt2RoguelikeBossPair(
|
||||||
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
random: () => number = Math.random,
|
||||||
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const pool = normalizeBossPool(options.bossPool)
|
||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
while (pool.length > 0 && choices.length < 2) {
|
while (pool.length > 0 && choices.length < 2) {
|
||||||
const index = randomIndex(pool.length, random)
|
const index = randomIndex(pool.length, random)
|
||||||
@@ -78,19 +93,29 @@ export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.r
|
|||||||
return choices
|
return choices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function enabledIwt2RoguelikeBossPool(
|
||||||
|
bossIds: readonly Iwt2BossId[],
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
return normalizeBossPool(bossIds)
|
||||||
|
}
|
||||||
|
|
||||||
function chooseWeightedBossForStage(
|
function chooseWeightedBossForStage(
|
||||||
stage: number,
|
stage: number,
|
||||||
selected: readonly Iwt2BossId[],
|
selected: readonly Iwt2BossId[],
|
||||||
maxThreat: number,
|
maxThreat: number,
|
||||||
random: () => number,
|
random: () => number,
|
||||||
|
bossPool: readonly Iwt2BossId[],
|
||||||
): Iwt2BossId | undefined {
|
): Iwt2BossId | undefined {
|
||||||
const selectedSet = new Set(selected)
|
const selectedSet = new Set(selected)
|
||||||
|
const bossPoolSet = new Set(bossPool)
|
||||||
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
|
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
|
||||||
const weightedTiers = tierWeightsForStage(stage)
|
const weightedTiers = tierWeightsForStage(stage)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => (
|
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)
|
.filter((entry) => entry.weight > 0 && entry.bosses.length > 0)
|
||||||
@@ -110,6 +135,21 @@ function chooseWeightedBossForStage(
|
|||||||
return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)]
|
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[] {
|
function tierWeightsForStage(stage: number): TierWeight[] {
|
||||||
const safeStage = Math.max(1, Math.floor(stage))
|
const safeStage = Math.max(1, Math.floor(stage))
|
||||||
if (safeStage <= 2) {
|
if (safeStage <= 2) {
|
||||||
|
|||||||
@@ -52,16 +52,17 @@ type OverlayAction = 'primary' | 'requeue' | 'menu'
|
|||||||
type OverlayNavEntry = {
|
type OverlayNavEntry = {
|
||||||
action: OverlayAction
|
action: OverlayAction
|
||||||
row: number
|
row: number
|
||||||
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'menu', row: 1 },
|
{ action: 'menu', row: 1, column: 0 },
|
||||||
]
|
]
|
||||||
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'requeue', row: 1 },
|
{ action: 'requeue', row: 0, column: 1 },
|
||||||
{ action: 'menu', row: 2 },
|
{ action: 'menu', row: 0, column: 2 },
|
||||||
]
|
]
|
||||||
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
||||||
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
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 active = entries.find((entry) => entry.action === current) ?? entries[0]
|
||||||
const candidates = entries.filter((entry) => {
|
const candidates = entries.filter((entry) => {
|
||||||
if (entry.action === current) return false
|
if (entry.action === current) return false
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
|
||||||
if (action === 'navigateDown') return entry.row > active.row
|
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
|
return false
|
||||||
})
|
})
|
||||||
if (candidates.length === 0) return current
|
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
|
return candidates[0]?.action ?? current
|
||||||
})
|
})
|
||||||
}, [pvpRoguelike])
|
}, [pvpRoguelike])
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export function createInitialIwt2ArenaState(
|
|||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
const initialBossIds = bossIds?.length ? [...bossIds] : chooseInitialBossIds(bossId)
|
||||||
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale, bounds))
|
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, initialBossIds.length, bossHealthScale, bounds))
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
time: 0,
|
time: 0,
|
||||||
@@ -97,9 +97,15 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
|
|||||||
return [primaryBossId, random]
|
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 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)))
|
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
||||||
return {
|
return {
|
||||||
id: bossId,
|
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 {
|
return {
|
||||||
x: index === 0 ? 660 : 760,
|
x: index === 0 ? 660 : 760,
|
||||||
y: index === 0 ? 190 : 345,
|
y: index === 0 ? 190 : 345,
|
||||||
@@ -757,6 +770,7 @@ function advanceBossProjectile({
|
|||||||
radius: hitMember.radius + projectile.radius,
|
radius: hitMember.radius + projectile.radius,
|
||||||
}, {
|
}, {
|
||||||
damage: projectile.damage,
|
damage: projectile.damage,
|
||||||
|
damageEventType: 'bossProjectileHit',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
time,
|
time,
|
||||||
})
|
})
|
||||||
@@ -774,13 +788,13 @@ function advanceBossProjectile({
|
|||||||
bounced = true
|
bounced = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bounced) {
|
if (hitMember) {
|
||||||
const puddle = addFirePuddle({
|
const puddle = addFirePuddle({
|
||||||
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
||||||
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
||||||
hazards: nextHazards,
|
hazards: nextHazards,
|
||||||
nextHazardId: nextHazardIdValue,
|
nextHazardId: nextHazardIdValue,
|
||||||
position: hitMember?.position ?? position,
|
position: hitMember.position,
|
||||||
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
time,
|
time,
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function tickGroundHazards({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
damage: hazard.damage,
|
damage: hazard.damage,
|
||||||
|
damageEventType: 'groundHazardTick',
|
||||||
sourceId: hazard.sourceId,
|
sourceId: hazard.sourceId,
|
||||||
time,
|
time,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ export type Iwt2ArenaEventType =
|
|||||||
| 'partyAttack'
|
| 'partyAttack'
|
||||||
| 'partyHealed'
|
| 'partyHealed'
|
||||||
| 'partyDamaged'
|
| 'partyDamaged'
|
||||||
|
| 'bossProjectileHit'
|
||||||
|
| 'groundHazardTick'
|
||||||
| 'partyStunned'
|
| 'partyStunned'
|
||||||
| 'bossChargeStart'
|
| 'bossChargeStart'
|
||||||
| 'bossChargeHit'
|
| 'bossChargeHit'
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const BIRD_COUNT = 3
|
|||||||
const BIRD_MELEE_RANGE = 24
|
const BIRD_MELEE_RANGE = 24
|
||||||
const BIRD_MELEE_COOLDOWN = 1.15
|
const BIRD_MELEE_COOLDOWN = 1.15
|
||||||
const BIRD_FLIGHT_DAMAGE = 26
|
const BIRD_FLIGHT_DAMAGE = 26
|
||||||
const YIAN_FIREBALL_BOUNCES = 8
|
const YIAN_FIREBALL_BOUNCES = 3
|
||||||
const YIAN_SAFE_WALL_MARGIN = 118
|
const YIAN_SAFE_WALL_MARGIN = 118
|
||||||
const YIAN_CENTER_CAST_DISTANCE = 36
|
const YIAN_CENTER_CAST_DISTANCE = 36
|
||||||
const YIAN_CENTER_CHARGE_SPEED = 430
|
const YIAN_CENTER_CHARGE_SPEED = 430
|
||||||
|
|||||||
Reference in New Issue
Block a user