Android build v1.1.36

This commit is contained in:
Warren H
2026-07-08 23:33:57 -04:00
parent 802dadc7f3
commit 1fa1c8c070
10 changed files with 195 additions and 34 deletions
Binary file not shown.
+2 -2
View File
@@ -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 116 versionCode 117
versionName "1.1.35" versionName "1.1.36"
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.
+12 -4
View File
@@ -76,9 +76,11 @@ type Iwt2Screen =
const IWT2_MENU_COLUMNS = 2 const IWT2_MENU_COLUMNS = 2
const IWT2_ROGUELIKE_CHOICE_COUNT = 3 const IWT2_ROGUELIKE_CHOICE_COUNT = 3
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65 const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
type Iwt2RoguelikeRunState = { type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[] bossIds: Iwt2BossId[]
bossesDefeated: number
buffs: Iwt2RoguelikeSelfBuffId[] buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[] debuffs: Iwt2RoguelikeOpponentDebuffId[]
@@ -256,10 +258,13 @@ export function IWantToHeal2App({
buffs: roguelikeRun.buffs, buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType, contentType: roguelikeRun.contentType,
debuffs: roguelikeRun.debuffs, debuffs: roguelikeRun.debuffs,
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
bossesDefeated: roguelikeRun.bossesDefeated,
onVictory: () => { onVictory: () => {
setRoguelikeRun((current) => current setRoguelikeRun((current) => current
? { ? {
...current, ...current,
bossesDefeated: current.bossesDefeated + current.bossIds.length,
...buildRoguelikeChoices(save, current.variant), ...buildRoguelikeChoices(save, current.variant),
} }
: current) : current)
@@ -534,7 +539,8 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType, contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState { ): Iwt2RoguelikeRunState {
return { return {
bossIds: createRoguelikeBossPair(variant, contentType, 1), bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
bossesDefeated: 0,
buffs: [], buffs: [],
contentType, contentType,
debuffs: [], debuffs: [],
@@ -602,7 +608,7 @@ function applyRoguelikeChoice(
return { return {
...run, ...run,
...nextBase, ...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), ...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1, stage: run.stage + 1,
} }
@@ -612,6 +618,7 @@ function createRoguelikeBossPair(
variant: Iwt2RoguelikeVariant, variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType, contentType: Iwt2RoguelikeContentType,
stage: number, stage: number,
bossesDefeated: number,
): Iwt2BossId[] { ): Iwt2BossId[] {
const weightedProgressionEnabled = variant === 'pve' const weightedProgressionEnabled = variant === 'pve'
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED ? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
@@ -619,11 +626,12 @@ function createRoguelikeBossPair(
? 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) const bossPool = roguelikeBossPoolFor(variant, contentType)
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
if (weightedProgressionEnabled) { 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( function roguelikeBossPoolFor(
+23 -4
View File
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
import type { Iwt2PlayerClassId } from './classes' import type { Iwt2PlayerClassId } from './classes'
import { iwt2BossCoinRewardFor } from './bossRewards' import { iwt2BossCoinRewardFor } from './bossRewards'
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities' import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities' export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities' export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet' 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<Iwt2RoguelikeSelfBuffId, 'revive-party-members'>
export type Iwt2GearStatId = export type Iwt2GearStatId =
| 'maxHealth' | 'maxHealth'
| 'moveSpeed' | 'moveSpeed'
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
export type Iwt2ClassGearProgress = { export type Iwt2ClassGearProgress = {
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress> slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
infusionAbilityId: Iwt2InfusionAbilityId | null infusionAbilityId: Iwt2InfusionAbilityId | null
passiveInfusionId: Iwt2PassiveInfusionId | null
} }
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress> export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
@@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = {
} }
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet'] 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<Iwt2GearSlotId, string> = { export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
weapon: 'Weapon', weapon: 'Weapon',
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
} }
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean { 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( export function iwt2GearUpgradeCosts(
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
slotId: Iwt2GearSlotId, slotId: Iwt2GearSlotId,
currentLevel: Iwt2GearLevel, currentLevel: Iwt2GearLevel,
): Iwt2GearUpgradeCost[] { ): Iwt2GearUpgradeCost[] {
if (currentLevel >= 5) return [] if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId] const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0> const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
const slug = upgradeDifficultySlug(nextLevel) const slug = upgradeDifficultySlug(nextLevel)
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
{ itemId: primary.id, itemName: primary.name, quantity: 4 }, { itemId: primary.id, itemName: primary.name, quantity: 4 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 }, { itemId: secondary.id, itemName: secondary.name, quantity: 3 },
] ]
return [ if (nextLevel === 5) return [
{ itemId: primary.id, itemName: primary.name, quantity: 5 }, { itemId: primary.id, itemName: primary.name, quantity: 5 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 }, { 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( export function iwt2InfusionCosts(
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
feet: { level: 0 }, feet: { level: 0 },
}, },
infusionAbilityId: null, infusionAbilityId: null,
passiveInfusionId: null,
} }
} }
@@ -193,6 +211,7 @@ function slotRecipe(
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string { function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
if (level <= 2) return 'initiate' if (level <= 2) return 'initiate'
if (level >= 6) return 'veteran'
if (level === 3) return 'veteran' if (level === 3) return 'veteran'
if (level === 4) return 'champion' if (level === 4) return 'champion'
return 'mythic' return 'mythic'
@@ -30,6 +30,7 @@ export function createIwt2PvpNormalizedGearProgress(
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]), IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
), ),
infusionAbilityId: null, infusionAbilityId: null,
passiveInfusionId: null,
}, },
]), ]),
) as Iwt2GearProgress ) as Iwt2GearProgress
@@ -19,6 +19,7 @@ type TierWeight = {
type Iwt2RoguelikeBossPoolOptions = { type Iwt2RoguelikeBossPoolOptions = {
bossPool?: readonly Iwt2BossId[] bossPool?: readonly Iwt2BossId[]
count?: number
} }
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = { const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
@@ -67,16 +68,17 @@ export function createIwt2WeightedRoguelikeBossPair(
const choices: Iwt2BossId[] = [] const choices: Iwt2BossId[] = []
const maxThreat = maxThreatForStage(stage) const maxThreat = maxThreatForStage(stage)
const bossPool = normalizeBossPool(options.bossPool) 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) 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 === count
? choices ? choices
: createUniformIwt2RoguelikeBossPair(random, { bossPool }) : createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
} }
export function createUniformIwt2RoguelikeBossPair( export function createUniformIwt2RoguelikeBossPair(
@@ -84,8 +86,9 @@ export function createUniformIwt2RoguelikeBossPair(
options: Iwt2RoguelikeBossPoolOptions = {}, options: Iwt2RoguelikeBossPoolOptions = {},
): Iwt2BossId[] { ): Iwt2BossId[] {
const pool = normalizeBossPool(options.bossPool) const pool = normalizeBossPool(options.bossPool)
const count = normalizeBossCount(options.count)
const choices: Iwt2BossId[] = [] const choices: Iwt2BossId[] = []
while (pool.length > 0 && choices.length < 2) { while (pool.length > 0 && choices.length < count) {
const index = randomIndex(pool.length, random) const index = randomIndex(pool.length, random)
const [choice] = pool.splice(index, 1) const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice) if (choice) choices.push(choice)
@@ -192,7 +195,7 @@ function maxThreatForStage(stage: number): number {
if (safeStage <= 2) return 3 if (safeStage <= 2) return 3
if (safeStage === 3) return 4 if (safeStage === 3) return 4
if (safeStage <= 5) return 5 if (safeStage <= 5) return 5
return 6 return 8
} }
function threatForBoss(bossId: Iwt2BossId): number { 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)) 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 { function safeRandom(random: () => number): number {
const value = random() const value = random()
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0 return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
+7 -2
View File
@@ -13,6 +13,11 @@ type PhaserArenaProps = {
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) { export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
const hostRef = useRef<HTMLDivElement | null>(null) const hostRef = useRef<HTMLDivElement | null>(null)
const onStepRef = useRef(onStep)
useEffect(() => {
onStepRef.current = onStep
}, [onStep])
useEffect(() => { useEffect(() => {
if (!hostRef.current) return undefined if (!hostRef.current) return undefined
@@ -21,7 +26,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
getMovement: () => movementRef.current, getMovement: () => movementRef.current,
getSelectedPartyId: () => selectedPartyIdRef.current, getSelectedPartyId: () => selectedPartyIdRef.current,
getState: () => stateRef.current, getState: () => stateRef.current,
step: onStep, step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
}) })
const game = new Phaser.Game({ const game = new Phaser.Game({
type: Phaser.AUTO, type: Phaser.AUTO,
@@ -40,7 +45,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
return () => { return () => {
game.destroy(true) game.destroy(true)
} }
}, [movementRef, onStep, selectedPartyIdRef, stateRef]) }, [movementRef, selectedPartyIdRef, stateRef])
return <div className="iwt2-phaser-host" ref={hostRef} /> return <div className="iwt2-phaser-host" ref={hostRef} />
} }
+41 -4
View File
@@ -10,13 +10,17 @@ import {
import type { Iwt2BossId } from '../content/bosses' import type { Iwt2BossId } from '../content/bosses'
import { import {
createDefaultIwt2GearProgress, createDefaultIwt2GearProgress,
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
IWT2_GEAR_SLOTS, IWT2_GEAR_SLOTS,
IWT2_MAX_GEAR_LEVEL,
iwt2GearUpgradeCosts, iwt2GearUpgradeCosts,
iwt2InfusionCosts, iwt2InfusionCosts,
isIwt2InfusionUnlocked, isIwt2InfusionUnlocked,
isIwt2PassiveInfusionUnlocked,
type Iwt2GearLevel, type Iwt2GearLevel,
type Iwt2GearProgress, type Iwt2GearProgress,
type Iwt2GearSlotId, type Iwt2GearSlotId,
type Iwt2PassiveInfusionId,
} from '../content/gear' } from '../content/gear'
import { import {
IWT2_INFUSION_ABILITIES, IWT2_INFUSION_ABILITIES,
@@ -263,7 +267,7 @@ export function upgradeIwt2GearSlot(
): Iwt2Save { ): Iwt2Save {
const classProgress = save.gearProgress[classId] const classProgress = save.gearProgress[classId]
const slot = classProgress.slots[slotId] 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 costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
const inventory = spendInventoryCosts(save.inventory, costs) const inventory = spendInventoryCosts(save.inventory, costs)
return { return {
@@ -294,8 +298,10 @@ export function setIwt2InfusionAbility(
const classProgress = save.gearProgress[classId] const classProgress = save.gearProgress[classId]
const ability = IWT2_INFUSION_ABILITIES[abilityId] const ability = IWT2_INFUSION_ABILITIES[abilityId]
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.') 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 (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.') 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 if (classProgress.infusionAbilityId === abilityId) return save
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId)) const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
return { 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 { export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity) return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
} }
@@ -450,6 +477,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
const classProgress = rawClassProgress as { const classProgress = rawClassProgress as {
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>> slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
infusionAbilityId?: unknown infusionAbilityId?: unknown
passiveInfusionId?: unknown
} }
for (const slotId of IWT2_GEAR_SLOTS) { for (const slotId of IWT2_GEAR_SLOTS) {
next[classId].slots[slotId] = { next[classId].slots[slotId] = {
@@ -461,6 +489,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
&& isIwt2InfusionUnlocked(next[classId]) && isIwt2InfusionUnlocked(next[classId])
? infusionAbilityId as Iwt2InfusionAbilityId ? infusionAbilityId as Iwt2InfusionAbilityId
: null : null
next[classId].passiveInfusionId = classId === 'healer'
&& isIwt2PassiveInfusionUnlocked(next)
? asPassiveInfusionId(classProgress.passiveInfusionId)
: null
} }
return next return next
} }
@@ -470,10 +502,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
} }
function asGearLevel(value: unknown): Iwt2GearLevel { 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 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[] { function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
const byId = new Map<string, Iwt2InventoryItem>() const byId = new Map<string, Iwt2InventoryItem>()
+18 -2
View File
@@ -82,9 +82,11 @@ type BossArenaScreenProps = {
onPvpRequeue?: () => void onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: { roguelikeRun?: {
bossesDefeated: number
buffs: Iwt2RoguelikeSelfBuffId[] buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[] debuffs: Iwt2RoguelikeOpponentDebuffId[]
greenCoinThreshold: number
onVictory: () => void onVictory: () => void
stage: number stage: number
variant: Iwt2RoguelikeVariant variant: Iwt2RoguelikeVariant
@@ -247,9 +249,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const gearAbilities = activeGearProgress const gearAbilities = activeGearProgress
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress) ? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
: baseAbilities : baseAbilities
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
? [activeGearProgress.healer.passiveInfusionId]
: []
return applyRoguelikeModifiers( return applyRoguelikeModifiers(
gearAbilities, gearAbilities,
roguelikeRun?.buffs ?? [], [...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
roguelikeRun?.debuffs ?? [], roguelikeRun?.debuffs ?? [],
) )
}, },
@@ -402,7 +407,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
for (const defeatedBoss of newlyDefeatedBosses) { for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId) nextRecordedIds.add(defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, { const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
difficultySlug, difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
experienceMultiplier, experienceMultiplier,
}) })
updatedSave = reward.save updatedSave = reward.save
@@ -670,6 +675,17 @@ function roguelikeBossHealthScale(stage: number): number {
return 1 + Math.max(0, stage - 1) * 0.1 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[] { function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
return DEFAULT_OVERLAY_NAV_ENTRIES return DEFAULT_OVERLAY_NAV_ENTRIES
+79 -11
View File
@@ -12,6 +12,7 @@ import {
createDefaultIwt2Save, createDefaultIwt2Save,
loadIwt2OnlineSave, loadIwt2OnlineSave,
setIwt2InfusionAbility, setIwt2InfusionAbility,
setIwt2PassiveInfusion,
updateIwt2CharacterSettings, updateIwt2CharacterSettings,
upgradeIwt2GearSlot, upgradeIwt2GearSlot,
canAffordIwt2Costs, canAffordIwt2Costs,
@@ -40,6 +41,8 @@ import {
IWT2_HEALER_ORDER, IWT2_HEALER_ORDER,
} from '../content/healerAbilities' } from '../content/healerAbilities'
import { import {
buildIwt2SelfBuffChoices,
IWT2_REVIVE_PARTY_CHOICE,
type Iwt2RoguelikeChoice, type Iwt2RoguelikeChoice,
type Iwt2RoguelikeContentType, type Iwt2RoguelikeContentType,
type Iwt2RoguelikeOpponentDebuffId, type Iwt2RoguelikeOpponentDebuffId,
@@ -51,9 +54,13 @@ import {
IWT2_GEAR_SLOT_RECIPES, IWT2_GEAR_SLOT_RECIPES,
IWT2_GEAR_SLOTS, IWT2_GEAR_SLOTS,
IWT2_GEAR_STAT_LABELS, IWT2_GEAR_STAT_LABELS,
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
IWT2_MAX_GEAR_LEVEL,
iwt2GearUpgradeCosts, iwt2GearUpgradeCosts,
iwt2InfusionCosts, iwt2InfusionCosts,
isIwt2InfusionUnlocked, isIwt2InfusionUnlocked,
isIwt2PassiveInfusionUnlocked,
type Iwt2PassiveInfusionId,
type Iwt2GearStatId, type Iwt2GearStatId,
type Iwt2GearSlotId, type Iwt2GearSlotId,
} from '../content/gear' } from '../content/gear'
@@ -144,6 +151,7 @@ type Iwt2GearNavEntry =
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId } | { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean } | { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, 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_HUNTER_PROFILE_DROP_COLUMNS = 6
const IWT2_NAME_MAX_LENGTH = 18 const IWT2_NAME_MAX_LENGTH = 18
@@ -2005,16 +2013,22 @@ export function Iwt2GearUpgradeScreen({
const selectedSlot = classProgress.slots[selectedSlotId] const selectedSlot = classProgress.slots[selectedSlotId]
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId] const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId) 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 nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
const selectedClassName = gearClassDisplayName(selectedClassId, save) const selectedClassName = gearClassDisplayName(selectedClassId, save)
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level) 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 infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
const infusionAnchorSlot = selectedSlot.level >= 5 const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
? selectedSlotId ? 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 infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
const passiveInfusionChoices = useMemo<Array<Iwt2RoguelikeChoice<Iwt2PassiveInfusionId>>>(() => {
if (selectedClassId !== 'healer') return []
return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle))
.filter((choice): choice is Iwt2RoguelikeChoice<Iwt2PassiveInfusionId> => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id)
}, [save.character.healerStyle, selectedClassId])
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => { const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }] const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
IWT2_PARTY_ORDER.forEach((classId, index) => { IWT2_PARTY_ORDER.forEach((classId, index) => {
@@ -2036,8 +2050,19 @@ export function Iwt2GearUpgradeScreen({
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs), 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 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] const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
@@ -2089,6 +2114,18 @@ export function Iwt2GearUpgradeScreen({
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : 'Infusion failed.') 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 selected = selectedClassId === classId
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
const classInfusion = save.gearProgress[classId].infusionAbilityId 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 highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
const className = gearClassDisplayName(classId, save) const className = gearClassDisplayName(classId, save)
const classSubtitle = gearClassSubtitle(classId, save) const classSubtitle = gearClassSubtitle(classId, save)
@@ -2129,7 +2167,7 @@ export function Iwt2GearUpgradeScreen({
<div> <div>
<strong>{className}</strong> <strong>{className}</strong>
<small>{classSubtitle}</small> <small>{classSubtitle}</small>
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small> <small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
</div> </div>
</button> </button>
) )
@@ -2172,7 +2210,7 @@ export function Iwt2GearUpgradeScreen({
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')} onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
type="button" type="button"
> >
Upgrade to +{Math.min(5, selectedSlot.level + 1)} Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
</button> </button>
</section> </section>
@@ -2188,8 +2226,8 @@ export function Iwt2GearUpgradeScreen({
<small>{selectedBonus.text}</small> <small>{selectedBonus.text}</small>
</span> </span>
<span> <span>
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong> <strong>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small> <small>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
</span> </span>
</div> </div>
<div className="iwt2-gear-cost-list"> <div className="iwt2-gear-cost-list">
@@ -2227,12 +2265,40 @@ export function Iwt2GearUpgradeScreen({
<div> <div>
<strong>{ability.name}</strong> <strong>{ability.name}</strong>
<small>{ability.description}</small> <small>{ability.description}</small>
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small> <small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}</small>
</div> </div>
</button> </button>
) )
})} })}
</div> </div>
{selectedClassId === 'healer' && (
<div className="iwt2-infusion-list">
{passiveInfusionChoices.map((choice) => {
const selected = classProgress.passiveInfusionId === choice.id
const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id
const disabled = selected || !passiveInfusionUnlocked
return (
<button
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={focused ? 'true' : undefined}
disabled={disabled}
key={choice.id}
onClick={() => activateEntry({ kind: 'passiveInfusion', key: `passive:${choice.id}`, row: 0, column: 2, passiveId: choice.id, disabled })}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `passive:${choice.id}`)}
type="button"
>
<span>P</span>
<div>
<strong>{choice.name}</strong>
<small>{choice.description}</small>
<small>{selected ? 'Selected passive' : passiveInfusionUnlocked ? 'Passive infusion' : `Unlock: any gear slot to +${IWT2_MAX_GEAR_LEVEL}`}</small>
</div>
</button>
)
})}
</div>
)}
<footer className="iwt2-gear-message">{message}</footer> <footer className="iwt2-gear-message">{message}</footer>
</section> </section>
</div> </div>
@@ -2276,7 +2342,9 @@ function gearBonus(label: string, value: string): { label: string, text: string,
} }
function infusionAnchorText(level: number): 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 { function formatPercent(value: number): string {