diff --git a/IWantToHeal-Thor-v1.1.29.apk b/IWantToHeal-Thor-v1.1.29.apk new file mode 100644 index 0000000..8f2e908 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.29.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index f45e7dc..648cab9 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 108 - versionName "1.1.28" + versionCode 109 + versionName "1.1.29" 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/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index 0872c49..70205c0 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -46,6 +46,11 @@ import { type Iwt2RoguelikeSelfBuffId, type Iwt2RoguelikeVariant, } from './content/roguelike' +import { + createIwt2PveRoguelikeBossPair, + createUniformIwt2RoguelikeBossPair, + IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED, +} from './content/roguelikeBossProgression' type Iwt2Screen = | 'menu' @@ -522,7 +527,7 @@ function createRoguelikeRun( contentType: Iwt2RoguelikeContentType, ): Iwt2RoguelikeRunState { return { - bossIds: createRandomRoguelikeBossPair(), + bossIds: createRoguelikeBossPair(variant, 1), buffs: [], contentType, debuffs: [], @@ -590,15 +595,17 @@ function applyRoguelikeChoice( return { ...run, ...nextBase, - bossIds: createRandomRoguelikeBossPair(), + bossIds: createRoguelikeBossPair(run.variant, run.stage + 1), ...buildRoguelikeChoices(save, run.variant), stage: run.stage + 1, } } -function createRandomRoguelikeBossPair(): Iwt2BossId[] { - const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] - return chooseRunChoices(pool, 2) +function createRoguelikeBossPair(variant: Iwt2RoguelikeVariant, stage: number): Iwt2BossId[] { + if (variant === 'pve' && IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED) { + return createIwt2PveRoguelikeBossPair(stage) + } + return createUniformIwt2RoguelikeBossPair() } function chooseRunChoices(items: readonly T[], count: number): T[] { diff --git a/src/modes/iwt2/content/roguelikeBossProgression.ts b/src/modes/iwt2/content/roguelikeBossProgression.ts new file mode 100644 index 0000000..c4cd1b5 --- /dev/null +++ b/src/modes/iwt2/content/roguelikeBossProgression.ts @@ -0,0 +1,160 @@ +import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses' + +export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true + +type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late' + +type TierWeight = { + tier: Iwt2RoguelikeBossTier + weight: number +} + +const IWT2_ROGUELIKE_BOSS_TIERS: Record = { + early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'], + mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'], + late: ['rimebastion', 'cinderback-ricochet', 'obsidian-ram-golem', 'venom-orchid-hydra', 'sandglass-scorpion'], +} + +const IWT2_ROGUELIKE_BOSS_THREAT: Record = { + early: 1, + mid: 2, + late: 3, +} + +const IWT2_ROGUELIKE_BOSS_TIER_BY_ID: Record = { + bulldrome: 'early', + 'yian-kut-ku': 'early', + 'great-jaggi': 'early', + rathian: 'early', + 'stormcoil-wyrm': 'early', + khezu: 'mid', + barroth: 'mid', + 'tobi-kadachi': 'mid', + 'ember-mantis-duelist': 'mid', + 'crystal-bat-matriarch': 'mid', + 'hollowcrown-revenant': 'mid', + rimebastion: 'late', + 'cinderback-ricochet': 'late', + 'obsidian-ram-golem': 'late', + 'venom-orchid-hydra': 'late', + 'sandglass-scorpion': 'late', +} + +export function createIwt2PveRoguelikeBossPair( + stage: number, + random: () => number = Math.random, +): Iwt2BossId[] { + const choices: Iwt2BossId[] = [] + const maxThreat = maxThreatForStage(stage) + + while (choices.length < 2) { + const next = chooseWeightedBossForStage(stage, choices, maxThreat, random) + if (!next) break + choices.push(next) + } + + return choices.length === 2 + ? choices + : createUniformIwt2RoguelikeBossPair(random) +} + +export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.random): Iwt2BossId[] { + const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] + const choices: Iwt2BossId[] = [] + while (pool.length > 0 && choices.length < 2) { + const index = randomIndex(pool.length, random) + const [choice] = pool.splice(index, 1) + if (choice) choices.push(choice) + } + return choices +} + +function chooseWeightedBossForStage( + stage: number, + selected: readonly Iwt2BossId[], + maxThreat: number, + random: () => number, +): Iwt2BossId | undefined { + const selectedSet = new Set(selected) + const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0) + const weightedTiers = tierWeightsForStage(stage) + .map((entry) => ({ + ...entry, + bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => ( + !selectedSet.has(bossId) && selectedThreat + threatForBoss(bossId) <= maxThreat + )), + })) + .filter((entry) => entry.weight > 0 && entry.bosses.length > 0) + + const totalWeight = weightedTiers.reduce((total, entry) => total + entry.weight, 0) + if (totalWeight <= 0) return undefined + + let roll = safeRandom(random) * totalWeight + for (const entry of weightedTiers) { + roll -= entry.weight + if (roll <= 0) { + return entry.bosses[randomIndex(entry.bosses.length, random)] + } + } + + const lastEntry = weightedTiers[weightedTiers.length - 1] + return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)] +} + +function tierWeightsForStage(stage: number): TierWeight[] { + const safeStage = Math.max(1, Math.floor(stage)) + if (safeStage <= 2) { + return [ + { tier: 'early', weight: 85 }, + { tier: 'mid', weight: 15 }, + { tier: 'late', weight: 0 }, + ] + } + if (safeStage === 3) { + return [ + { tier: 'early', weight: 60 }, + { tier: 'mid', weight: 35 }, + { tier: 'late', weight: 5 }, + ] + } + if (safeStage <= 5) { + return [ + { tier: 'early', weight: 25 }, + { tier: 'mid', weight: 60 }, + { tier: 'late', weight: 15 }, + ] + } + if (safeStage === 6) { + return [ + { tier: 'early', weight: 10 }, + { tier: 'mid', weight: 50 }, + { tier: 'late', weight: 40 }, + ] + } + return [ + { tier: 'early', weight: 5 }, + { tier: 'mid', weight: 30 }, + { tier: 'late', weight: 65 }, + ] +} + +function maxThreatForStage(stage: number): number { + const safeStage = Math.max(1, Math.floor(stage)) + if (safeStage <= 2) return 3 + if (safeStage === 3) return 4 + if (safeStage <= 5) return 5 + return 6 +} + +function threatForBoss(bossId: Iwt2BossId): number { + return IWT2_ROGUELIKE_BOSS_THREAT[IWT2_ROGUELIKE_BOSS_TIER_BY_ID[bossId]] +} + +function randomIndex(length: number, random: () => number): number { + return Math.min(length - 1, Math.floor(safeRandom(random) * length)) +} + +function safeRandom(random: () => number): number { + const value = random() + return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0 +}