diff --git a/IWantToHeal-Thor-v1.0.56.apk b/IWantToHeal-Thor-v1.0.56.apk new file mode 100644 index 0000000..4e939e1 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.56.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 691acaa..67ae82f 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 74 - versionName "1.0.55" + versionCode 75 + versionName "1.0.56" 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/server/game-api.mjs b/server/game-api.mjs index 6adb49b..5f93c6a 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -2307,7 +2307,10 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3)) const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level' ? 'pvp-boss-quarter-level' - : 'default' + : runMetrics?.experienceMode === 'pvp-fight-twelfth-level' + ? 'pvp-fight-twelfth-level' + : 'default' + const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared) const resourceSpent = Number(runMetrics?.resourceSpent) const durationSeconds = Number(runMetrics?.durationSeconds) if (!Number.isInteger(dungeonId) || dungeonId < 1) { @@ -2322,6 +2325,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) { throw new Error('The roguelike boss total is invalid.') } + if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) { + throw new Error('The roguelike fight total is invalid.') + } if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) { throw new Error('The run resource total is invalid.') } @@ -2374,14 +2380,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { `).get(maxLevel).experienceRequired let newExperience = character.experience let newLevel = character.level - if (experienceMode === 'pvp-boss-quarter-level') { + if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') { const catchUpTargetLevel = database.prepare(` SELECT COALESCE(MAX(level), 0) AS level FROM characters WHERE account_id = ? AND id != ? `).get(accountId, characterId).level - for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { + const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared + for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) { const currentLevelFloor = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression @@ -2395,7 +2402,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) { WHERE level = ? `).get(newLevel + 1).experienceRequired const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) - const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25 + const rewardRate = experienceMode === 'pvp-boss-quarter-level' + ? (catchUpTargetLevel > newLevel ? 0.5 : 0.25) + : (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12) newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate)) newLevel = database.prepare(` SELECT MAX(level) AS level diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index 6a644ea..fa9bb17 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -57,24 +57,17 @@ type PvpEncounter = DungeonEncounter & { } type SlotKey = '1' | '2' | '3' | '4' | '5' | '6' +type DraftSlotKey = Exclude type AbilityLabelMode = 'ability' | 'slot' type SelfBuffId = - | `slot${SlotKey}-extra-target` - | `slot${SlotKey}-cost-down` - | `slot${SlotKey}-cooldown-down` - | 'fifth-cast-free' - | 'group-heal-boost' - | 'shield-boost' + | `slot${DraftSlotKey}-extra-target` + | `slot${DraftSlotKey}-cost-down` + | `slot${DraftSlotKey}-cooldown-down` type OpponentDebuffId = - | `opp-slot${SlotKey}-cost-up` - | `opp-slot${SlotKey}-cooldown-up` - | 'opp-takes-more-damage' - | 'opp-healing-reduced' - | 'opp-resource-regen-down' - | 'opp-cleanse-cooldown-up' - | 'opp-purge-random-buff' + | `opp-slot${DraftSlotKey}-cost-up` + | `opp-slot${DraftSlotKey}-cooldown-up` type Choice = { id: T @@ -184,7 +177,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode) } function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array> { - const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => { + return (['1', '2', '3', '4', '5'] as DraftSlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells, labelMode) return [ { @@ -204,16 +197,10 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr }, ] }) - return [ - ...slotChoices, - { id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 casts, the next cast is free.' }, - { id: 'group-heal-boost', name: 'Wide Radiance', description: 'Party healing is 25% stronger.' }, - { id: 'shield-boost', name: 'Dense Shields', description: 'Shield absorbs are 25% stronger.' }, - ] } function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array> { - const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => { + return (['1', '2', '3', '4', '5'] as DraftSlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells, labelMode) return [ { @@ -228,14 +215,6 @@ function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode }, ] }) - return [ - ...slotChoices, - { id: 'opp-takes-more-damage', name: 'Expose Weakness', description: 'Opponent takes 10% more damage.' }, - { id: 'opp-healing-reduced', name: 'Blunted Recovery', description: 'Opponent healing is 15% weaker.' }, - { id: 'opp-resource-regen-down', name: 'Mana Squeeze', description: 'Opponent resource regeneration is reduced by 25%.' }, - { id: 'opp-cleanse-cooldown-up', name: 'Lingering Toxins', description: 'Opponent cleanse cooldown is 25% longer.' }, - { id: 'opp-purge-random-buff', name: 'Strip Momentum', description: 'Remove 1 random buff from the opponent immediately.' }, - ] } function toCombatSpell(ability: Ability, key: string): Spell { @@ -263,17 +242,10 @@ function effectiveMaxHealth(member: PartyMember) { return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) } -function incomingDamageMultiplier(debuffs: OpponentDebuffId[]) { - return 1.1 ** buffStacks(debuffs, 'opp-takes-more-damage') -} - -function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) { - return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced') -} - function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) { const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1 - return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier) + void debuffs + return Math.round(amount * healingReduction * multiplier) } function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) { @@ -284,8 +256,7 @@ function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: Opponent const slot = spell.key as SlotKey const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId) const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId) - const cleansePenalty = spell.kind === 'cleanse' ? buffStacks(debuffs, 'opp-cleanse-cooldown-up') : 0 - return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty)) + return (0.75 ** downStacks) * (1.25 ** upStacks) } function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) { @@ -293,7 +264,8 @@ function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentD const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId) const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId) const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks)) - return freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0 ? 0 : adjustedCost + void freeCastReady + return adjustedCost } function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] { @@ -351,9 +323,6 @@ function starterSide(partyTemplate: PartyMember[], maxResource: number): SideSta } function scoreSelfBuff(buff: Choice, spells: Spell[]) { - if (buff.id === 'fifth-cast-free') return 8 - if (buff.id === 'group-heal-boost') return 8 - if (buff.id === 'shield-boost') return 6 const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined const spell = spells.find((candidate) => candidate.key === slot) if (!spell) return 5 @@ -367,11 +336,7 @@ function scoreSelfBuff(buff: Choice, spells: Spell[]) { } function scoreDebuff(debuff: Choice, opponentBuffCount: number) { - if (debuff.id === 'opp-takes-more-damage') return 9 - if (debuff.id === 'opp-healing-reduced') return 8 - if (debuff.id === 'opp-resource-regen-down') return 7 - if (debuff.id === 'opp-cleanse-cooldown-up') return 5 - if (debuff.id === 'opp-purge-random-buff') return opponentBuffCount > 0 ? 8 : 2 + void opponentBuffCount if (debuff.id.endsWith('cost-up')) return 7 return 6 } @@ -388,13 +353,6 @@ function selectCpuChoice( return ranked[0] } -function removeRandomBuff(side: SideState) { - if (side.buffs.length === 0) return side - const nextBuffs = [...side.buffs] - nextBuffs.splice(Math.floor(Math.random() * nextBuffs.length), 1) - return { ...side, buffs: nextBuffs } -} - function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) { return { id: nextLogId.current++, text, tone } } @@ -571,10 +529,11 @@ export function PvPRoguelikeScreen({ encounterPoolRef.current = encounterPool }, [encounterPool]) - const awardBossReward = useCallback((encounterIndexValue: number) => { + const awardEncounterReward = useCallback((encounterIndexValue: number) => { if (bossRewardClaimedRef.current.has(encounterIndexValue)) return bossRewardClaimedRef.current.add(encounterIndexValue) const rewardEncounter = encounters[encounterIndexValue] + const isBossReward = Boolean(rewardEncounter?.isBoss) completeRoguelike( rewardDungeon.id, rewardDifficulty.id, @@ -582,10 +541,11 @@ export function PvPRoguelikeScreen({ 0, Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)), { - bossesCleared: 1, - experienceMode: 'pvp-boss-quarter-level', - lootSourceEncounterId: rewardEncounter?.sourceEncounterId, - roguelikeStage: stage, + bossesCleared: isBossReward ? 1 : 0, + fightsCleared: 1, + experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level', + lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined, + roguelikeStage: isBossReward ? stage : undefined, }, ) .then((result) => { @@ -594,7 +554,7 @@ export function PvPRoguelikeScreen({ const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability])) result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability)) return { - bossesKilled: current.bossesKilled + 1, + bossesKilled: current.bossesKilled + (isBossReward ? 1 : 0), experienceGained: current.experienceGained + result.experienceGained, previousLevel: current.previousLevel ?? result.previousLevel, newLevel: result.newLevel, @@ -605,6 +565,9 @@ export function PvPRoguelikeScreen({ } }) onProfileUpdated(result.profile) + if (!isBossReward && result.experienceGained > 0) { + addLog(`+${result.experienceGained} XP awarded.`, 'loot') + } if (result.bonusItem) { addLog( `${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, @@ -949,7 +912,7 @@ export function PvPRoguelikeScreen({ if (member.health <= 0) return member if (spell.kind === 'group') { if (!groupTargets.has(member.id)) return member - const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost'))) + const groupPower = spell.power const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) const nextShield = hasSpellEffect('radiance_applies_shield') @@ -966,7 +929,7 @@ export function PvPRoguelikeScreen({ } if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member if (spell.kind === 'shield') { - const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost'))) + const shieldPower = spell.power return { ...member, shield: Math.max(member.shield, shieldPower), @@ -1000,16 +963,6 @@ export function PvPRoguelikeScreen({ hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, } }) - const freeCastStacks = buffStacks(buffs, 'fifth-cast-free') - const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady ? false : current.freeCastReady - const nextCastsTowardFree = freeCastStacks > 0 - ? current.freeCastReady - ? 0 - : current.castsTowardFree + 1 >= 5 - ? 0 - : current.castsTowardFree + 1 - : current.castsTowardFree - const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5 const nextCooldowns = { ...current.cooldowns, } @@ -1022,8 +975,8 @@ export function PvPRoguelikeScreen({ party: nextParty, resource: current.resource - effectiveCost, cooldowns: nextCooldowns, - castsTowardFree: nextCastsTowardFree, - freeCastReady: gainedFreeCast || nextFreeCastReady, + castsTowardFree: current.castsTowardFree, + freeCastReady: false, } setCurrent(nextState) return true @@ -1134,7 +1087,6 @@ export function PvPRoguelikeScreen({ const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut') const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction') const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison') - const damageMultiplier = incomingDamageMultiplier(side.debuffs) const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType) const tankPressure = tankPressureTargets(side.party) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) @@ -1150,7 +1102,6 @@ export function PvPRoguelikeScreen({ ? Math.max(1, (member.poisonStacks ?? 0) + 1) : member.poisonStacks ?? 0 if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3 - damage = Math.round(damage * damageMultiplier) if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) { damage = Math.round(damage * 0.8) } @@ -1189,7 +1140,7 @@ export function PvPRoguelikeScreen({ ...side, party: nextParty, resource: clamp( - side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')), + side.resource + 2.4, 0, maxResource, ), @@ -1221,8 +1172,8 @@ export function PvPRoguelikeScreen({ if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { playerClearedEncounterRef.current = encounterIndex setEncountersCleared((value) => value + 1) + awardEncounterReward(encounterIndex) if (encounter.isBoss) { - awardBossReward(encounterIndex) const nextCheckpoint = recordPvpRoguelikeCheckpoint( profile.character.id, contentType, @@ -1266,7 +1217,7 @@ export function PvPRoguelikeScreen({ } }, TICK_MS / speedMultiplier) return () => window.clearInterval(timer) - }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status]) + }, [addLog, advanceSide, awardEncounterReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status]) useEffect(() => { if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return @@ -1348,9 +1299,7 @@ export function PvPRoguelikeScreen({ ...playerRef.current, buffs: [...playerRef.current.buffs, submittedBuff.id], } - if (opponentChoice.debuffId === 'opp-purge-random-buff') { - nextPlayer = removeRandomBuff(nextPlayer) - } else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) { + if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) { nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] } } @@ -1457,17 +1406,9 @@ export function PvPRoguelikeScreen({ buffs: [...cpuRef.current.buffs, cpuBuff.id], } - if (chosenDebuff.id === 'opp-purge-random-buff') { - nextCpu = removeRandomBuff(nextCpu) - } else { - nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] } - } + nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] } - if (cpuDebuff.id === 'opp-purge-random-buff') { - nextPlayer = removeRandomBuff(nextPlayer) - } else { - nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] } - } + nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] } const clearedBoss = encounter.isBoss if (clearedBoss && cpuDefeatedRef.current) { diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 1368be0..bd2d829 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -37,7 +37,8 @@ export interface GameRepository { durationSeconds: number, options?: { bossesCleared?: number - experienceMode?: 'default' | 'pvp-boss-quarter-level' + experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' + fightsCleared?: number lootSourceEncounterId?: number roguelikeStage?: number }, @@ -503,6 +504,31 @@ function scaledPvpBossExperience( return { experience, level } } +function scaledPvpFightExperience( + startingExperience: number, + startingLevel: number, + fightsCleared: number, + maxLevel: number, + targetLevel = startingLevel, +) { + let experience = startingExperience + let level = startingLevel + const maxExperience = experienceForLevel(maxLevel) + for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) { + const currentLevelFloor = experienceForLevel(level) + const nextLevelExperience = level >= maxLevel + ? maxExperience + : experienceForLevel(level + 1) + const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) + const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12 + experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate)) + while (level < maxLevel && experienceForLevel(level + 1) <= experience) { + level += 1 + } + } + return { experience, level } +} + function talentEffectCapacity(level: number) { return Math.min(4, Math.max(0, Math.floor(level / 5))) } @@ -1142,7 +1168,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save)) - : null + : options?.experienceMode === 'pvp-fight-twelfth-level' + ? scaledPvpFightExperience( + previousExperience, + previousLevel, + Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)), + profile.maxLevel, + highestOtherClassLevel(save), + ) + : null const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)) const newExperience = scaledReward ? scaledReward.experience diff --git a/src/profile.ts b/src/profile.ts index 42597b3..5330225 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -349,7 +349,8 @@ export async function completeRoguelike( durationSeconds: number, options?: { bossesCleared?: number - experienceMode?: 'default' | 'pvp-boss-quarter-level' + experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' + fightsCleared?: number lootSourceEncounterId?: number roguelikeStage?: number },