diff --git a/IWantToHeal-Thor-v1.1.35.apk b/IWantToHeal-Thor-v1.1.35.apk new file mode 100644 index 0000000..eac6f64 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.35.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index db5c189..0240e9c 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 115 - versionName "1.1.34" + versionCode 116 + versionName "1.1.35" 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 index 00dfb60..20ea40e 100644 --- a/scripts/iwt2-pvp-roguelike-boss-sim.mjs +++ b/scripts/iwt2-pvp-roguelike-boss-sim.mjs @@ -4,10 +4,30 @@ import { createServer } from 'vite' const DEFAULT_WORKERS = Math.max(1, Math.min(8, Number.parseInt(process.env.IWT2_SIM_WORKERS ?? '8', 10))) const DEFAULT_MAX_SECONDS = Math.max(30, Number.parseFloat(process.env.IWT2_SIM_SECONDS ?? '180')) const DEFAULT_DT = 1 / 30 +const DEFAULT_TICKS_PER_WORKER_SECOND = 45000 +const ESTIMATE_STARTUP_SECONDS = 0.8 const PVP_BOSS_HEALTH_MULTIPLIER = 0.7 const BOSS_HEALTH_PER_STAGE = 0.1 const ARENA_BOUNDS = { width: 960, height: 540 } const HEALER_STYLES = ['dawnweaver', 'lifebinder', 'runesage'] +const BOSS_IDS = [ + 'bulldrome', + 'yian-kut-ku', + 'great-jaggi', + 'khezu', + 'rathian', + 'barroth', + 'tobi-kadachi', + 'rimebastion', + 'ember-mantis-duelist', + 'cinderback-ricochet', + 'obsidian-ram-golem', + 'stormcoil-wyrm', + 'venom-orchid-hydra', + 'sandglass-scorpion', + 'crystal-bat-matriarch', + 'hollowcrown-revenant', +] const activeChildren = new Set() for (const signal of ['SIGINT', 'SIGTERM']) { @@ -32,6 +52,7 @@ const config = { bossFilter: parseList(args.get('--bosses') ?? 'all'), bossHpPercent: parsePositiveNumber(args.get('--boss-hp-percent') ?? '100', 100), classes: parseList(args.get('--classes') ?? args.get('--class') ?? 'all'), + estimateOnly: args.has('--estimate-only'), gearLevel: parseNonNegativeInt(args.get('--gear-level') ?? args.get('--gear') ?? '5', 5), maxSeconds: Number.parseFloat(args.get('--seconds') ?? String(DEFAULT_MAX_SECONDS)), repeats: parsePositiveInt(args.get('--repeats') ?? '1', 1), @@ -66,6 +87,7 @@ async function runMain({ bossFilter, bossHpPercent, classes, + estimateOnly, gearLevel, maxSeconds, repeats, @@ -75,6 +97,33 @@ async function runMain({ workers, }) { const safeWorkers = Math.max(1, Math.floor(workers)) + const estimate = estimateRun({ + bossCount, + bossFilter, + classes, + maxSeconds, + repeats, + requiredBosses, + stages, + workers: safeWorkers, + }) + if (estimateOnly) { + console.log(JSON.stringify({ + config: { + bossCount, + bosses: bossFilter, + classes, + repeats, + requiredBosses, + seconds: maxSeconds, + stages, + workers: safeWorkers, + }, + estimate, + }, null, 2)) + return + } + const startedAt = Date.now() const childResults = await Promise.all(Array.from({ length: safeWorkers }, (_, shardIndex) => ( runChild({ bossCount, @@ -92,6 +141,7 @@ async function runMain({ }) ))) const results = childResults.flat() + const elapsedSeconds = round((Date.now() - startedAt) / 1000) const summary = summarizeResults(results) console.log(JSON.stringify({ config: { @@ -101,6 +151,8 @@ async function runMain({ bosses: bossFilter, classes, gearLevel, + elapsedSeconds, + estimate, orderedTrials: results.length, repeats, requiredBosses, @@ -549,6 +601,7 @@ Options: --bosses all|bulldrome,yian-kut-ku,... --required-bosses none|yian-kut-ku,... --repeats 1 + --estimate-only --boss-hp-percent 100 --boss-damage-percent 100 --stages 1,2 @@ -562,9 +615,43 @@ Examples: node scripts/iwt2-pvp-roguelike-boss-sim.mjs --boss-hp-percent 125 --boss-damage-percent 110 node scripts/iwt2-pvp-roguelike-boss-sim.mjs --bosses yian-kut-ku,rathian,rimebastion --boss-count 2 node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --repeats 10 + node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --estimate-only `) } +function estimateRun({ + bossCount, + bossFilter, + classes, + maxSeconds, + repeats, + requiredBosses, + stages, + workers, +}) { + const bossIds = filteredBossIds(BOSS_IDS, bossFilter) + const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses) + const healerStyles = filteredClasses(classes) + const orderedBossGroups = permutations(bossIds, bossCount) + .filter((group) => requiredBossIds.every((bossId) => group.includes(bossId))) + const trials = orderedBossGroups.length * healerStyles.length * stages.length * repeats + const maxTicks = trials * maxSeconds * (1 / DEFAULT_DT) + const estimatedWallSeconds = ESTIMATE_STARTUP_SECONDS + (maxTicks / (Math.max(1, workers) * DEFAULT_TICKS_PER_WORKER_SECOND)) + return { + bossPoolSize: bossIds.length, + classCount: healerStyles.length, + orderedBossGroupCount: orderedBossGroups.length, + requiredBosses: requiredBossIds, + stageCount: stages.length, + repeats, + trials, + maxSimulatedSeconds: round(trials * maxSeconds), + maxTicks: Math.round(maxTicks), + estimatedWallSeconds: round(estimatedWallSeconds), + note: 'Rough wall-time estimate; victories or defeats can end trials early.', + } +} + function filteredBossIds(allBossIds, filter) { if (filter.length === 0 || filter.includes('all')) return allBossIds const known = new Set(allBossIds) diff --git a/scripts/iwt2-pvp-sim-gui-server.mjs b/scripts/iwt2-pvp-sim-gui-server.mjs index 60ec5d2..13aaa95 100644 --- a/scripts/iwt2-pvp-sim-gui-server.mjs +++ b/scripts/iwt2-pvp-sim-gui-server.mjs @@ -169,6 +169,9 @@ function sendHtml(response) { .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; } + .estimate { display: grid; gap: 6px; background: #111419; border: 1px solid #2a3039; border-radius: 6px; color: #d9e3ee; font-size: 13px; margin-top: 16px; padding: 10px; } + .estimate strong { font-size: 18px; } + .estimate span { color: #96a3b5; } 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; } @@ -232,6 +235,8 @@ function sendHtml(response) {

Required Bosses

+
+
@@ -255,6 +260,7 @@ function sendHtml(response) { const status = document.getElementById("status"); const raw = document.getElementById("raw"); const dashboard = document.getElementById("dashboard"); + const estimateBox = document.getElementById("estimate"); const run = document.getElementById("run"); const stop = document.getElementById("stop"); @@ -265,6 +271,8 @@ function sendHtml(response) { renderChecks("requiredBosses", config.bosses, false); wireAll("allClasses", "classes"); wireAll("allBosses", "bosses"); + wireEstimateUpdates(); + updateEstimate(); }); function renderChecks(containerId, values, checked) { @@ -277,9 +285,11 @@ function sendHtml(response) { const container = document.getElementById(containerId); master.addEventListener("change", () => { container.querySelectorAll("input").forEach(input => input.checked = master.checked); + updateEstimate(); }); container.addEventListener("change", () => { master.checked = [...container.querySelectorAll("input")].every(input => input.checked); + updateEstimate(); }); } @@ -333,15 +343,100 @@ function sendHtml(response) { ].join(" "); } + function wireEstimateUpdates() { + document.querySelector("aside").addEventListener("input", updateEstimate); + document.querySelector("aside").addEventListener("change", updateEstimate); + } + + function updateEstimate() { + if (!state.config) return; + const estimate = estimateFor(payload()); + estimateBox.innerHTML = '' + number(estimate.trials) + ' tests' + + '' + number(estimate.orderedBossGroupCount) + ' ordered boss groups x ' + number(estimate.classCount) + ' classes x ' + number(estimate.stageCount) + ' stages x ' + number(estimate.repeats) + ' repeats' + + 'Max sim time: ' + duration(estimate.maxSimulatedSeconds) + ' / rough wall time: ' + duration(estimate.estimatedWallSeconds) + ''; + } + + function estimateFor(options) { + const bossPool = options.bosses.includes("all") ? state.config.bosses : options.bosses; + const requiredBosses = options.requiredBosses.includes("none") + ? [] + : options.requiredBosses.filter(bossId => bossPool.includes(bossId)); + const bossCount = intValue(options.bossCount, 2); + const orderedBossGroupCount = countOrderedBossGroups(bossPool.length, bossCount, requiredBosses.length); + const classCount = options.classes.includes("all") ? state.config.classes.length : Math.max(1, options.classes.length); + const stageCount = Math.max(1, parseStages(options.stages).length); + const repeats = intValue(options.repeats, 1); + const seconds = numberValue(options.seconds, 180); + const workers = intValue(options.workers, 8); + const trials = orderedBossGroupCount * classCount * stageCount * repeats; + const maxSimulatedSeconds = trials * seconds; + const maxTicks = maxSimulatedSeconds * 30; + const estimatedWallSeconds = 0.8 + (maxTicks / (Math.max(1, workers) * 45000)); + return { classCount, estimatedWallSeconds, maxSimulatedSeconds, orderedBossGroupCount, repeats, stageCount, trials }; + } + + function countOrderedBossGroups(poolSize, bossCount, requiredCount) { + const safeBossCount = Math.max(1, Math.min(poolSize, Math.floor(bossCount))); + if (requiredCount > safeBossCount || requiredCount > poolSize) return 0; + return combination(poolSize - requiredCount, safeBossCount - requiredCount) * permutation(safeBossCount, safeBossCount); + } + + function combination(n, k) { + if (k < 0 || k > n) return 0; + const safeK = Math.min(k, n - k); + let result = 1; + for (let index = 1; index <= safeK; index += 1) { + result = (result * (n - safeK + index)) / index; + } + return Math.round(result); + } + + function permutation(n, k) { + if (k < 0 || k > n) return 0; + let result = 1; + for (let value = n - k + 1; value <= n; value += 1) result *= value; + return result; + } + + function parseStages(value) { + return String(value).split(",").map(stage => Number.parseInt(stage.trim(), 10)).filter(Number.isFinite); + } + + function intValue(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function numberValue(value, fallback) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function duration(seconds) { + const safeSeconds = Math.max(0, Number(seconds) || 0); + if (safeSeconds < 60) return fixed(safeSeconds) + 's'; + const minutes = Math.floor(safeSeconds / 60); + const remaining = Math.round(safeSeconds % 60); + if (minutes < 60) return minutes + 'm ' + remaining + 's'; + const hours = Math.floor(minutes / 60); + return hours + 'h ' + (minutes % 60) + 'm'; + } + + function number(value) { + return Number(value ?? 0).toLocaleString(); + } + run.addEventListener("click", async () => { const options = payload(); - raw.textContent = "$ " + commandFor(options) + "\\n\\n"; + const estimate = estimateFor(options); + raw.textContent = "$ " + commandFor(options) + "\\n" + + "# estimated tests: " + number(estimate.trials) + ", rough wall time: " + duration(estimate.estimatedWallSeconds) + "\\n\\n"; dashboard.innerHTML = '
Simulation running...
'; state.lastJson = null; state.running = true; run.disabled = true; stop.disabled = false; - status.textContent = "Running..."; + status.textContent = "Running " + number(estimate.trials) + " tests, rough estimate " + duration(estimate.estimatedWallSeconds) + "..."; 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"; @@ -393,8 +488,8 @@ function sendHtml(response) { return [ '
', metric('Trials', totalTrials), + metric('Elapsed', duration(data.config?.elapsedSeconds ?? 0)), 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'), '
', totalFailures === 0 ? '
No failures found in returned hardest-combo rows. Review deaths and fight time for near-fails.
' : '',