diff --git a/IWantToHeal-Thor-v1.1.36.apk b/IWantToHeal-Thor-v1.1.36.apk new file mode 100644 index 0000000..9bae73e Binary files /dev/null and b/IWantToHeal-Thor-v1.1.36.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 0240e9c..e097e18 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 116 - versionName "1.1.35" + versionCode 117 + versionName "1.1.36" 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 7d1ba88..98c9283 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -76,9 +76,11 @@ type Iwt2Screen = const IWT2_MENU_COLUMNS = 2 const IWT2_ROGUELIKE_CHOICE_COUNT = 3 const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65 +const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5 type Iwt2RoguelikeRunState = { bossIds: Iwt2BossId[] + bossesDefeated: number buffs: Iwt2RoguelikeSelfBuffId[] contentType: Iwt2RoguelikeContentType debuffs: Iwt2RoguelikeOpponentDebuffId[] @@ -256,10 +258,13 @@ export function IWantToHeal2App({ buffs: roguelikeRun.buffs, contentType: roguelikeRun.contentType, debuffs: roguelikeRun.debuffs, + greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD, + bossesDefeated: roguelikeRun.bossesDefeated, onVictory: () => { setRoguelikeRun((current) => current ? { ...current, + bossesDefeated: current.bossesDefeated + current.bossIds.length, ...buildRoguelikeChoices(save, current.variant), } : current) @@ -534,7 +539,8 @@ function createRoguelikeRun( contentType: Iwt2RoguelikeContentType, ): Iwt2RoguelikeRunState { return { - bossIds: createRoguelikeBossPair(variant, contentType, 1), + bossIds: createRoguelikeBossPair(variant, contentType, 1, 0), + bossesDefeated: 0, buffs: [], contentType, debuffs: [], @@ -602,7 +608,7 @@ function applyRoguelikeChoice( return { ...run, ...nextBase, - bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1), + bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated), ...buildRoguelikeChoices(save, run.variant), stage: run.stage + 1, } @@ -612,6 +618,7 @@ function createRoguelikeBossPair( variant: Iwt2RoguelikeVariant, contentType: Iwt2RoguelikeContentType, stage: number, + bossesDefeated: number, ): Iwt2BossId[] { const weightedProgressionEnabled = variant === 'pve' ? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED @@ -619,11 +626,12 @@ function createRoguelikeBossPair( ? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED : IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED const bossPool = roguelikeBossPoolFor(variant, contentType) + const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2 if (weightedProgressionEnabled) { - return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool }) + return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count }) } - return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool }) + return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count }) } function roguelikeBossPoolFor( diff --git a/src/modes/iwt2/content/gear.ts b/src/modes/iwt2/content/gear.ts index e989198..4863fbb 100644 --- a/src/modes/iwt2/content/gear.ts +++ b/src/modes/iwt2/content/gear.ts @@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses' import type { Iwt2PlayerClassId } from './classes' import { iwt2BossCoinRewardFor } from './bossRewards' import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities' +import type { Iwt2RoguelikeSelfBuffId } from './roguelike' export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities' export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities' export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet' -export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 +export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 +export type Iwt2PassiveInfusionId = Exclude export type Iwt2GearStatId = | 'maxHealth' | 'moveSpeed' @@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = { export type Iwt2ClassGearProgress = { slots: Record infusionAbilityId: Iwt2InfusionAbilityId | null + passiveInfusionId: Iwt2PassiveInfusionId | null } export type Iwt2GearProgress = Record @@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = { } export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet'] +export const IWT2_MAX_GEAR_LEVEL: Iwt2GearLevel = 10 +export const IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 5 +export const IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 10 export const IWT2_GEAR_SLOT_LABELS: Record = { weapon: 'Weapon', @@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress { } export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean { - return Object.values(progress.slots).some((slot) => slot.level >= 5) + return Object.values(progress.slots).some((slot) => slot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) +} + +export function isIwt2PassiveInfusionUnlocked(progress: Iwt2GearProgress): boolean { + return Object.values(progress).some((classProgress) => ( + Object.values(classProgress.slots).some((slot) => slot.level >= IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL) + )) } export function iwt2GearUpgradeCosts( @@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts( slotId: Iwt2GearSlotId, currentLevel: Iwt2GearLevel, ): Iwt2GearUpgradeCost[] { - if (currentLevel >= 5) return [] + if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return [] const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId] const nextLevel = (currentLevel + 1) as Exclude const slug = upgradeDifficultySlug(nextLevel) @@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts( { itemId: primary.id, itemName: primary.name, quantity: 4 }, { itemId: secondary.id, itemName: secondary.name, quantity: 3 }, ] - return [ + if (nextLevel === 5) return [ { itemId: primary.id, itemName: primary.name, quantity: 5 }, { itemId: secondary.id, itemName: secondary.name, quantity: 4 }, ] + const overcap = nextLevel - 5 + return [ + { itemId: primary.id, itemName: primary.name, quantity: 5 + overcap }, + { itemId: secondary.id, itemName: secondary.name, quantity: 4 + overcap }, + ] } export function iwt2InfusionCosts( @@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress { feet: { level: 0 }, }, infusionAbilityId: null, + passiveInfusionId: null, } } @@ -193,6 +211,7 @@ function slotRecipe( function upgradeDifficultySlug(level: Exclude): string { if (level <= 2) return 'initiate' + if (level >= 6) return 'veteran' if (level === 3) return 'veteran' if (level === 4) return 'champion' return 'mythic' diff --git a/src/modes/iwt2/content/pvpGearNormalization.ts b/src/modes/iwt2/content/pvpGearNormalization.ts index 9a29692..484243a 100644 --- a/src/modes/iwt2/content/pvpGearNormalization.ts +++ b/src/modes/iwt2/content/pvpGearNormalization.ts @@ -30,6 +30,7 @@ export function createIwt2PvpNormalizedGearProgress( IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]), ), infusionAbilityId: null, + passiveInfusionId: null, }, ]), ) as Iwt2GearProgress diff --git a/src/modes/iwt2/content/roguelikeBossProgression.ts b/src/modes/iwt2/content/roguelikeBossProgression.ts index a7fd6a7..54ba01c 100644 --- a/src/modes/iwt2/content/roguelikeBossProgression.ts +++ b/src/modes/iwt2/content/roguelikeBossProgression.ts @@ -19,6 +19,7 @@ type TierWeight = { type Iwt2RoguelikeBossPoolOptions = { bossPool?: readonly Iwt2BossId[] + count?: number } const IWT2_ROGUELIKE_BOSS_TIERS: Record = { @@ -67,16 +68,17 @@ export function createIwt2WeightedRoguelikeBossPair( const choices: Iwt2BossId[] = [] const maxThreat = maxThreatForStage(stage) const bossPool = normalizeBossPool(options.bossPool) + const count = normalizeBossCount(options.count) - while (choices.length < 2) { + while (choices.length < count) { const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool) if (!next) break choices.push(next) } - return choices.length === 2 + return choices.length === count ? choices - : createUniformIwt2RoguelikeBossPair(random, { bossPool }) + : createUniformIwt2RoguelikeBossPair(random, { bossPool, count }) } export function createUniformIwt2RoguelikeBossPair( @@ -84,8 +86,9 @@ export function createUniformIwt2RoguelikeBossPair( options: Iwt2RoguelikeBossPoolOptions = {}, ): Iwt2BossId[] { const pool = normalizeBossPool(options.bossPool) + const count = normalizeBossCount(options.count) const choices: Iwt2BossId[] = [] - while (pool.length > 0 && choices.length < 2) { + while (pool.length > 0 && choices.length < count) { const index = randomIndex(pool.length, random) const [choice] = pool.splice(index, 1) if (choice) choices.push(choice) @@ -192,7 +195,7 @@ function maxThreatForStage(stage: number): number { if (safeStage <= 2) return 3 if (safeStage === 3) return 4 if (safeStage <= 5) return 5 - return 6 + return 8 } function threatForBoss(bossId: Iwt2BossId): number { @@ -203,6 +206,10 @@ function randomIndex(length: number, random: () => number): number { return Math.min(length - 1, Math.floor(safeRandom(random) * length)) } +function normalizeBossCount(count: number | undefined): number { + return Math.max(1, Math.min(3, Math.floor(count ?? 2))) +} + function safeRandom(random: () => number): number { const value = random() return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0 diff --git a/src/modes/iwt2/render/PhaserArena.tsx b/src/modes/iwt2/render/PhaserArena.tsx index 8e67898..c97a345 100644 --- a/src/modes/iwt2/render/PhaserArena.tsx +++ b/src/modes/iwt2/render/PhaserArena.tsx @@ -13,6 +13,11 @@ type PhaserArenaProps = { export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) { const hostRef = useRef(null) + const onStepRef = useRef(onStep) + + useEffect(() => { + onStepRef.current = onStep + }, [onStep]) useEffect(() => { if (!hostRef.current) return undefined @@ -21,7 +26,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef getMovement: () => movementRef.current, getSelectedPartyId: () => selectedPartyIdRef.current, getState: () => stateRef.current, - step: onStep, + step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds), }) const game = new Phaser.Game({ type: Phaser.AUTO, @@ -40,7 +45,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef return () => { game.destroy(true) } - }, [movementRef, onStep, selectedPartyIdRef, stateRef]) + }, [movementRef, selectedPartyIdRef, stateRef]) return
} diff --git a/src/modes/iwt2/save/iwt2Repository.ts b/src/modes/iwt2/save/iwt2Repository.ts index 06ee57e..09a7b1b 100644 --- a/src/modes/iwt2/save/iwt2Repository.ts +++ b/src/modes/iwt2/save/iwt2Repository.ts @@ -10,13 +10,17 @@ import { import type { Iwt2BossId } from '../content/bosses' import { createDefaultIwt2GearProgress, + IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL, IWT2_GEAR_SLOTS, + IWT2_MAX_GEAR_LEVEL, iwt2GearUpgradeCosts, iwt2InfusionCosts, isIwt2InfusionUnlocked, + isIwt2PassiveInfusionUnlocked, type Iwt2GearLevel, type Iwt2GearProgress, type Iwt2GearSlotId, + type Iwt2PassiveInfusionId, } from '../content/gear' import { IWT2_INFUSION_ABILITIES, @@ -263,7 +267,7 @@ export function upgradeIwt2GearSlot( ): Iwt2Save { const classProgress = save.gearProgress[classId] const slot = classProgress.slots[slotId] - if (slot.level >= 5) throw new Error('Gear slot already at +5.') + if (slot.level >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`) const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level) const inventory = spendInventoryCosts(save.inventory, costs) return { @@ -294,8 +298,10 @@ export function setIwt2InfusionAbility( const classProgress = save.gearProgress[classId] const ability = IWT2_INFUSION_ABILITIES[abilityId] if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.') - if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.') - if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.') + if (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`) + if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) { + throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} gear slot to anchor the infusion cost.`) + } if (classProgress.infusionAbilityId === abilityId) return save const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId)) return { @@ -312,6 +318,27 @@ export function setIwt2InfusionAbility( } } +export function setIwt2PassiveInfusion( + save: Iwt2Save, + passiveInfusionId: Iwt2PassiveInfusionId, +): Iwt2Save { + if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) { + throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`) + } + if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save + return { + ...save, + updatedAt: Date.now(), + gearProgress: { + ...save.gearProgress, + healer: { + ...save.gearProgress.healer, + passiveInfusionId, + }, + }, + } +} + export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean { return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity) } @@ -450,6 +477,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress { const classProgress = rawClassProgress as { slots?: Partial> infusionAbilityId?: unknown + passiveInfusionId?: unknown } for (const slotId of IWT2_GEAR_SLOTS) { next[classId].slots[slotId] = { @@ -461,6 +489,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress { && isIwt2InfusionUnlocked(next[classId]) ? infusionAbilityId as Iwt2InfusionAbilityId : null + next[classId].passiveInfusionId = classId === 'healer' + && isIwt2PassiveInfusionUnlocked(next) + ? asPassiveInfusionId(classProgress.passiveInfusionId) + : null } return next } @@ -470,10 +502,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress { } function asGearLevel(value: unknown): Iwt2GearLevel { - const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0))) + const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0))) return level as Iwt2GearLevel } +function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null { + if (typeof value !== 'string' || value === 'revive-party-members') return null + return value as Iwt2PassiveInfusionId +} + function normalizeInventory(value: unknown): Iwt2InventoryItem[] { if (!Array.isArray(value)) return [] const byId = new Map() diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index d6ddd51..ceb55ac 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -82,9 +82,11 @@ type BossArenaScreenProps = { onPvpRequeue?: () => void onSaveUpdated: (save: Iwt2Save) => void roguelikeRun?: { + bossesDefeated: number buffs: Iwt2RoguelikeSelfBuffId[] contentType: Iwt2RoguelikeContentType debuffs: Iwt2RoguelikeOpponentDebuffId[] + greenCoinThreshold: number onVictory: () => void stage: number variant: Iwt2RoguelikeVariant @@ -247,9 +249,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, const gearAbilities = activeGearProgress ? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress) : baseAbilities + const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId + ? [activeGearProgress.healer.passiveInfusionId] + : [] return applyRoguelikeModifiers( gearAbilities, - roguelikeRun?.buffs ?? [], + [...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])], roguelikeRun?.debuffs ?? [], ) }, @@ -402,7 +407,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, for (const defeatedBoss of newlyDefeatedBosses) { nextRecordedIds.add(defeatedBoss.bossId) const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, { - difficultySlug, + difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug), experienceMultiplier, }) updatedSave = reward.save @@ -670,6 +675,17 @@ function roguelikeBossHealthScale(stage: number): number { return 1 + Math.max(0, stage - 1) * 0.1 } +function rewardDifficultySlugForKill( + roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined, + defeatedEarlierThisArena: number, + fallbackSlug: string, +): string { + if (!roguelikeRun) return fallbackSlug + return roguelikeRun.bossesDefeated + defeatedEarlierThisArena >= roguelikeRun.greenCoinThreshold + ? 'veteran' + : fallbackSlug +} + function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] { if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES return DEFAULT_OVERLAY_NAV_ENTRIES diff --git a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx index db261dd..a0e08ee 100644 --- a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx +++ b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx @@ -12,6 +12,7 @@ import { createDefaultIwt2Save, loadIwt2OnlineSave, setIwt2InfusionAbility, + setIwt2PassiveInfusion, updateIwt2CharacterSettings, upgradeIwt2GearSlot, canAffordIwt2Costs, @@ -40,6 +41,8 @@ import { IWT2_HEALER_ORDER, } from '../content/healerAbilities' import { + buildIwt2SelfBuffChoices, + IWT2_REVIVE_PARTY_CHOICE, type Iwt2RoguelikeChoice, type Iwt2RoguelikeContentType, type Iwt2RoguelikeOpponentDebuffId, @@ -51,9 +54,13 @@ import { IWT2_GEAR_SLOT_RECIPES, IWT2_GEAR_SLOTS, IWT2_GEAR_STAT_LABELS, + IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL, + IWT2_MAX_GEAR_LEVEL, iwt2GearUpgradeCosts, iwt2InfusionCosts, isIwt2InfusionUnlocked, + isIwt2PassiveInfusionUnlocked, + type Iwt2PassiveInfusionId, type Iwt2GearStatId, type Iwt2GearSlotId, } from '../content/gear' @@ -144,6 +151,7 @@ type Iwt2GearNavEntry = | { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId } | { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean } | { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean } + | { kind: 'passiveInfusion', key: string, row: number, column: number, passiveId: Iwt2PassiveInfusionId, disabled: boolean } const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6 const IWT2_NAME_MAX_LENGTH = 18 @@ -2005,16 +2013,22 @@ export function Iwt2GearUpgradeScreen({ const selectedSlot = classProgress.slots[selectedSlotId] const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId] const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId) - const nextLevel = Math.min(5, selectedSlot.level + 1) + const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1) const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId) const selectedClassName = gearClassDisplayName(selectedClassId, save) const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level) - const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts) + const canUpgrade = selectedSlot.level < IWT2_MAX_GEAR_LEVEL && canAffordIwt2Costs(save, upgradeCosts) const infusionUnlocked = isIwt2InfusionUnlocked(classProgress) - const infusionAnchorSlot = selectedSlot.level >= 5 + const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress) + const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL ? selectedSlotId - : IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId + : IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) ?? selectedSlotId const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId) + const passiveInfusionChoices = useMemo>>(() => { + if (selectedClassId !== 'healer') return [] + return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle)) + .filter((choice): choice is Iwt2RoguelikeChoice => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id) + }, [save.character.healerStyle, selectedClassId]) const navEntries = useMemo(() => { const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }] IWT2_PARTY_ORDER.forEach((classId, index) => { @@ -2036,8 +2050,19 @@ export function Iwt2GearUpgradeScreen({ disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs), }) }) + passiveInfusionChoices.forEach((choice, index) => { + const selected = classProgress.passiveInfusionId === choice.id + entries.push({ + kind: 'passiveInfusion', + key: `passive:${choice.id}`, + row: infusionAbilities.length + index + 1, + column: 2, + passiveId: choice.id, + disabled: selected || !passiveInfusionUnlocked, + }) + }) return entries - }, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId]) + }, [canUpgrade, classProgress.infusionAbilityId, classProgress.passiveInfusionId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, passiveInfusionChoices, passiveInfusionUnlocked, save, selectedClassId]) const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0] @@ -2089,6 +2114,18 @@ export function Iwt2GearUpgradeScreen({ } catch (error) { setMessage(error instanceof Error ? error.message : 'Infusion failed.') } + return + } + if (entry.kind === 'passiveInfusion') { + if (entry.disabled) return + try { + const nextSave = setIwt2PassiveInfusion(save, entry.passiveId) + onSaveUpdated(nextSave) + const passiveName = passiveInfusionChoices.find((choice) => choice.id === entry.passiveId)?.name ?? 'Passive' + setMessage(`${passiveName} set as passive infusion.`) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Passive infusion failed.') + } } } @@ -2112,6 +2149,7 @@ export function Iwt2GearUpgradeScreen({ const selected = selectedClassId === classId const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId const classInfusion = save.gearProgress[classId].infusionAbilityId + const passiveInfusion = classId === 'healer' ? save.gearProgress.healer.passiveInfusionId : null const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level)) const className = gearClassDisplayName(classId, save) const classSubtitle = gearClassSubtitle(classId, save) @@ -2129,7 +2167,7 @@ export function Iwt2GearUpgradeScreen({
{className} {classSubtitle} - Top +{highest}{classInfusion ? ' | Slot 6 set' : ''} + Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}
) @@ -2172,7 +2210,7 @@ export function Iwt2GearUpgradeScreen({ onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')} type="button" > - Upgrade to +{Math.min(5, selectedSlot.level + 1)} + Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)} @@ -2188,8 +2226,8 @@ export function Iwt2GearUpgradeScreen({ {selectedBonus.text} - {selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`} - {selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`} + {selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`} + {selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}
@@ -2227,12 +2265,40 @@ export function Iwt2GearUpgradeScreen({
{ability.name} {ability.description} - {selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'} + {selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}
) })}
+ {selectedClassId === 'healer' && ( +
+ {passiveInfusionChoices.map((choice) => { + const selected = classProgress.passiveInfusionId === choice.id + const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id + const disabled = selected || !passiveInfusionUnlocked + return ( + + ) + })} +
+ )} @@ -2276,7 +2342,9 @@ function gearBonus(label: string, value: string): { label: string, text: string, } function infusionAnchorText(level: number): string { - return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.' + if (level >= IWT2_MAX_GEAR_LEVEL) return 'Active and passive infusion anchors available.' + if (level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) return 'Active infusion anchor available.' + return 'No infusion anchor.' } function formatPercent(value: number): string {