diff --git a/IWantToHeal-Thor-v1.0.59.apk b/IWantToHeal-Thor-v1.0.59.apk new file mode 100644 index 0000000..7da1228 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.59.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 1d73607..7a4c0a9 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 77 - versionName "1.0.58" + versionCode 78 + versionName "1.0.59" 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/App.css b/src/App.css index c7a1cba..902660b 100644 --- a/src/App.css +++ b/src/App.css @@ -18,6 +18,14 @@ box-sizing: border-box; } +.sr-only { + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + width: 1px; +} + button { font: inherit; } @@ -778,6 +786,10 @@ textarea:focus-visible, min-height: 0; } +.dual-top-main.stadium-dual-top { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + .dual-top-main .dual-top-enemy, .dual-top-main .dual-top-party, .dual-top-main .dual-top-log { @@ -6480,7 +6492,7 @@ h2 { .stadium-shop-tabs button, .stadium-shop-grid button { - background: #252833; + background: #303545; border: 2px solid #0b0c0f; color: var(--ink); cursor: pointer; @@ -6513,29 +6525,30 @@ h2 { .stadium-shop-grid button { display: grid; - gap: 8px; + gap: 10px; margin: 0; - min-height: 110px; - padding: 12px; + min-height: 128px; + padding: 14px; text-align: left; } .stadium-shop-grid button:disabled { cursor: not-allowed; - opacity: 0.45; + opacity: 0.58; } .stadium-shop-grid strong { - color: #ffe8a5; - font-size: 10px; - line-height: 1.25; + color: #fff0b8; + font-size: 11px; + line-height: 1.35; } .stadium-shop-grid small, .stadium-shop-layout p { - color: #d3d9e6; - font-size: 13px; - line-height: 1.2; + color: #f0e8d2; + font-family: 'VT323', monospace; + font-size: 22px; + line-height: 1.05; } .dual-opponent-progress.stadium { diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx index 7a063fa..8c4c6ef 100644 --- a/src/components/PvpStadiumScreen.tsx +++ b/src/components/PvpStadiumScreen.tsx @@ -33,12 +33,22 @@ const ROUND_START_SECONDS = 3 const SHOP_SECONDS = 60 const WIN_ROUNDS = 3 const MAX_RESOURCE = 100 +const RESOURCE_REGEN_PER_TICK = 0.8 type SlotKey = '1' | '2' | '3' | '4' | '5' type StadiumBuffId = | `slot${SlotKey}-extra-target` | `slot${SlotKey}-cost-down` | `slot${SlotKey}-cooldown-down` + | 'slot1-applies-renew' + | 'slot1-applies-shield' + | 'slot2-applies-shield' + | 'slot2-double-duration' + | 'slot3-applies-shield' + | 'slot3-applies-renew' + | 'slot4-applies-renew' + | 'slot5-applies-renew' + | 'slot5-applies-shield' | 'fifth-cast-free' | 'group-heal-boost' | 'shield-boost' @@ -134,10 +144,19 @@ function slotLabel(slot: SlotKey, spells: Spell[]) { return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}` } +function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) { + return spells.find((candidate) => candidate.key === slot)?.name ?? fallback +} + function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] { + const directName = slotSpellName('1', spells, 'Mend') + const sustainName = slotSpellName('2', spells, 'Renew') + const groupName = slotSpellName('3', spells, 'Radiance') + const shieldName = slotSpellName('4', spells, 'Sun Ward') + const cleanseName = slotSpellName('5', spells, 'Purify') const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const label = slotLabel(slot, spells) - return [ + const baseBuffs: StadiumBuff[] = [ { id: `slot${slot}-extra-target` as StadiumBuffId, name: '+1 target', @@ -159,7 +178,83 @@ function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] { category: slot, cost: 1, }, - ] satisfies StadiumBuff[] + ] + const specialBuffs: Partial> = { + 1: [ + { + id: 'slot1-applies-renew', + name: `Applies ${sustainName}`, + description: `${directName} also applies ${sustainName} to the target.`, + category: slot, + cost: 2, + }, + { + id: 'slot1-applies-shield', + name: `Applies ${shieldName}`, + description: `${directName} also applies a ${shieldName} barrier to the target.`, + category: slot, + cost: 2, + }, + ], + 2: [ + { + id: 'slot2-applies-shield', + name: `Applies ${shieldName}`, + description: `${sustainName} also applies a ${shieldName} barrier to the target.`, + category: slot, + cost: 2, + }, + { + id: 'slot2-double-duration', + name: 'Double Duration', + description: `${sustainName} lasts twice as long or gains extra charges.`, + category: slot, + cost: 2, + }, + ], + 3: [ + { + id: 'slot3-applies-shield', + name: `Applies 50% ${shieldName}`, + description: `${groupName} applies a ${shieldName} barrier at 50% strength to affected targets.`, + category: slot, + cost: 2, + }, + { + id: 'slot3-applies-renew', + name: `Applies ${sustainName}`, + description: `${groupName} applies ${sustainName} to affected targets.`, + category: slot, + cost: 2, + }, + ], + 4: [ + { + id: 'slot4-applies-renew', + name: `Applies ${sustainName}`, + description: `${shieldName} also applies ${sustainName} to the target.`, + category: slot, + cost: 2, + }, + ], + 5: [ + { + id: 'slot5-applies-renew', + name: `Applies ${sustainName}`, + description: `${cleanseName} also applies ${sustainName} to the target.`, + category: slot, + cost: 2, + }, + { + id: 'slot5-applies-shield', + name: `Applies ${shieldName}`, + description: `${cleanseName} also applies a ${shieldName} barrier to the target.`, + category: slot, + cost: 2, + }, + ], + } + return [...baseBuffs, ...(specialBuffs[slot] ?? [])] }) return [ ...slotBuffs, @@ -211,9 +306,14 @@ function spellResourceCost(spell: Spell, buffs: StadiumBuffId[], freeCastReady: function toCombatSpell(ability: Ability, key: string): Spell { const kinds: Record = { direct_heal: 'direct', + direct_hot: 'direct', heal_over_time: 'hot', + bounce_heal: 'bounce_heal', party_heal: 'group', + party_hot: 'group', + party_absorb: 'group', absorb: 'shield', + damage_reduction: 'damage_reduction', cleanse: 'cleanse', } return { @@ -226,6 +326,7 @@ function toCombatSpell(ability: Ability, key: string): Spell { power: ability.power, glyph: ability.glyph, kind: kinds[ability.spellType] ?? 'direct', + effectType: ability.spellType, } } @@ -241,6 +342,8 @@ function resetParty(partyTemplate: PartyMember[]) { poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, + damageReductionTicks: undefined, + bounceHeals: undefined, })) } @@ -339,6 +442,7 @@ export function PvpStadiumScreen({ const submittedShopRef = useRef(false) const awardedXpRef = useRef(new Set()) const queuedMatchRef = useRef(false) + const roundResolvedRef = useRef(false) const loggedOpponentRoundRef = useRef('') const { bindings, @@ -376,6 +480,7 @@ export function PvpStadiumScreen({ const beginRoundCountdown = useCallback(() => { clearRoundCountdown() + roundResolvedRef.current = false setRoundCountdown(ROUND_START_SECONDS) setStatus('round-countdown') const startedAt = Date.now() @@ -443,6 +548,7 @@ export function PvpStadiumScreen({ queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() + roundResolvedRef.current = false setPlayerSide(basePlayer) setCpuSide(baseOpponent) setRoundIndex(1) @@ -477,6 +583,7 @@ export function PvpStadiumScreen({ queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() + roundResolvedRef.current = false setPlayerSide(basePlayer) setCpuSide(baseCpu) setRoundIndex(1) @@ -588,18 +695,34 @@ export function PvpStadiumScreen({ const extraTarget = (blockedIds: string[]) => livingTargets .filter((member) => !blockedIds.includes(member.id)) .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] - const directTargets = new Set([targetId]) - const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : []) - const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) + const directTargets = new Set(spell.kind === 'direct' || spell.kind === 'cleanse' ? [targetId] : []) + const hotTargets = new Set(spell.kind === 'hot' || spell.kind === 'bounce_heal' ? [targetId] : []) + const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) + const damageReductionTargets = new Set(spell.kind === 'damage_reduction' ? [targetId] : []) const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId) const groupTargets = new Set( spell.kind === 'group' ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) : [], ) + const renewDuration = buffStacks(current.buffs, 'slot2-double-duration') > 0 && spell.key === '2' ? 10 : 5 + const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield') + const shieldPower = (sourcePower: number, strength = 1) => Math.round( + sourcePower + * strength + * (1.25 ** buffStacks(current.buffs, 'shield-boost')) + * dampenMultiplier, + ) + if (spell.effectType === 'direct_hot') hotTargets.add(targetId) + if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-renew') > 0) hotTargets.add(targetId) + if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-shield') > 0) shieldTargets.add(targetId) + if (spell.key === '2' && buffStacks(current.buffs, 'slot2-applies-shield') > 0) shieldTargets.add(targetId) + if (spell.key === '4' && buffStacks(current.buffs, 'slot4-applies-renew') > 0) hotTargets.add(targetId) + if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-renew') > 0) hotTargets.add(targetId) + if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-shield') > 0) shieldTargets.add(targetId) for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break - if (spell.kind === 'hot') { + if (spell.kind === 'hot' || spell.kind === 'bounce_heal') { const extra = extraTarget([...hotTargets]) if (extra) hotTargets.add(extra.id) continue @@ -609,25 +732,54 @@ export function PvpStadiumScreen({ if (extra) shieldTargets.add(extra.id) continue } + if (spell.kind === 'damage_reduction') { + const extra = extraTarget([...damageReductionTargets]) + if (extra) damageReductionTargets.add(extra.id) + continue + } const extra = extraTarget([...directTargets]) if (extra) directTargets.add(extra.id) } + if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(id)) const nextParty = current.party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { if (!groupTargets.has(member.id)) return member - const power = Math.round(spell.power * (1.25 ** buffStacks(current.buffs, 'group-heal-boost')) * dampenMultiplier) - const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) - addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) - return { ...member, health: nextHealth } - } - 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(current.buffs, 'shield-boost')) * dampenMultiplier) + const isGroupAbsorb = spell.effectType === 'party_absorb' + const isGroupHot = spell.effectType === 'party_hot' + const boost = isGroupAbsorb ? buffStacks(current.buffs, 'shield-boost') : buffStacks(current.buffs, 'group-heal-boost') + const power = Math.round(spell.power * (1.25 ** boost) * dampenMultiplier) + const nextHealth = isGroupAbsorb || isGroupHot ? member.health : clamp(member.health + power, 0, effectiveMaxHealth(member)) + if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) + const appliesShield = isGroupAbsorb || buffStacks(current.buffs, 'slot3-applies-shield') > 0 + const appliesHot = isGroupHot || buffStacks(current.buffs, 'slot3-applies-renew') > 0 return { ...member, - shield: Math.max(member.shield, shieldPower), - hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, + health: nextHealth, + shield: appliesShield + ? Math.max(member.shield, isGroupAbsorb ? power : shieldPower(shieldEffect?.power ?? spell.power, 0.5)) + : member.shield, + hotTicks: appliesHot ? Math.max(member.hotTicks, 5) : member.hotTicks, + } + } + if ( + !directTargets.has(member.id) + && !hotTargets.has(member.id) + && !shieldTargets.has(member.id) + && !damageReductionTargets.has(member.id) + ) return member + if (spell.kind === 'shield') { + return { + ...member, + shield: Math.max(member.shield, shieldPower(spell.power)), + hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, + } + } + if (spell.kind === 'damage_reduction') { + return { + ...member, + damageReductionTicks: Math.max(member.damageReductionTicks ?? 0, 12), + hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, } } if (spell.kind === 'cleanse') { @@ -642,6 +794,10 @@ export function PvpStadiumScreen({ poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, + shield: shieldTargets.has(member.id) + ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power)) + : member.shield, + hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks, } } const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0 @@ -650,7 +806,10 @@ export function PvpStadiumScreen({ return { ...member, health: nextHealth, - hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, + shield: shieldTargets.has(member.id) + ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power)) + : member.shield, + hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, renewDuration) : member.hotTicks, } }) const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0 @@ -669,7 +828,7 @@ export function PvpStadiumScreen({ } setCurrent(nextState) return true - }, [addFloatingHeal]) + }, [addFloatingHeal, starterSpells]) const castPlayerSpell = useCallback((spell: Spell) => { if (status !== 'playing' || !playerAlive) return @@ -695,13 +854,14 @@ export function PvpStadiumScreen({ const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0) const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold) const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold) + const spellBySlot = (slot: SlotKey) => starterSpells.find((candidate) => candidate.key === slot) const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ - { spell: cleanseTarget ? starterSpells.find((candidate) => candidate.kind === 'cleanse') : undefined, targetId: cleanseTarget?.id ?? null }, - { spell: averageHealth < behavior.groupHealThreshold ? starterSpells.find((candidate) => candidate.kind === 'group') : undefined, targetId: lowest?.id ?? null }, - { spell: shieldTarget ? starterSpells.find((candidate) => candidate.kind === 'shield') : undefined, targetId: shieldTarget?.id ?? null }, - { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? starterSpells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: lowest.id }, - { spell: renewTarget ? starterSpells.find((candidate) => candidate.kind === 'hot') : undefined, targetId: renewTarget?.id ?? null }, - { spell: tank ? starterSpells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: tank?.id ?? null }, + { spell: cleanseTarget ? spellBySlot('5') : undefined, targetId: cleanseTarget?.id ?? null }, + { spell: averageHealth < behavior.groupHealThreshold ? spellBySlot('3') : undefined, targetId: lowest?.id ?? null }, + { spell: shieldTarget ? spellBySlot('4') : undefined, targetId: shieldTarget?.id ?? null }, + { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? spellBySlot('1') : undefined, targetId: lowest.id }, + { spell: renewTarget ? spellBySlot('2') : undefined, targetId: renewTarget?.id ?? null }, + { spell: tank ? spellBySlot('1') : undefined, targetId: tank?.id ?? null }, ] for (const action of ordered) { if (!action.spell || !action.targetId) continue @@ -730,14 +890,18 @@ export function PvpStadiumScreen({ let damage = tankIds.has(member.id) ? 8 : 0 if (pulse) damage += 9 if (spike && member.id === spikeTarget.id) damage += 22 + const mitigatedDamage = member.damageReductionTicks && member.damageReductionTicks > 0 + ? Math.ceil(damage * 0.5) + : damage const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0 - const absorbed = Math.min(member.shield, damage) - const nextHealth = clamp(member.health - damage + absorbed + hotHealing, 0, effectiveMaxHealth(member)) + const absorbed = Math.min(member.shield, mitigatedDamage) + const nextHealth = clamp(member.health - mitigatedDamage + absorbed + hotHealing, 0, effectiveMaxHealth(member)) return { ...member, health: nextHealth, - shield: Math.max(0, member.shield - damage), + shield: Math.max(0, member.shield - mitigatedDamage), hotTicks: Math.max(0, member.hotTicks - 1), + damageReductionTicks: member.damageReductionTicks ? Math.max(0, member.damageReductionTicks - 1) : undefined, debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff, debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined, } @@ -745,7 +909,7 @@ export function PvpStadiumScreen({ return { ...side, party: nextParty, - resource: clamp(side.resource + 2.4, 0, MAX_RESOURCE), + resource: clamp(side.resource + RESOURCE_REGEN_PER_TICK, 0, MAX_RESOURCE), cooldowns: Object.fromEntries( Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), ), @@ -788,6 +952,8 @@ export function PvpStadiumScreen({ const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => { if (status !== 'playing') return + if (roundResolvedRef.current) return + roundResolvedRef.current = true const key = `round-${roundIndex}-${outcome}` if (outcome === 'win' || outcome === 'tie') { awardXp(key, 'pvp-stadium-round-win-quarter-level') @@ -856,6 +1022,8 @@ export function PvpStadiumScreen({ const nextCpu = starterSide(cpuPartyTemplate, nextRound, cpuRef.current.buffs, roundWins.opponent) playerRef.current = nextPlayer cpuRef.current = nextCpu + roundResolvedRef.current = false + loggedOpponentRoundRef.current = '' setRoundIndex(nextRound) setPlayerSide(nextPlayer) setCpuSide(nextCpu) @@ -863,8 +1031,9 @@ export function PvpStadiumScreen({ setElapsedTicks(0) setShopReady(false) setShopPoints(0) + addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system') beginRoundCountdown() - }, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId]) + }, [addLog, beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId]) const finishShop = useCallback(() => { if (shopReady || status !== 'shop') return @@ -1124,7 +1293,104 @@ export function PvpStadiumScreen({ )} - {status !== 'queueing' && ( + {dualScreenEnabled && status !== 'queueing' && ( +
+
+
+

Stadium

+

Round {roundIndex} / Best of 5

+
+ {roundWins.player} - {roundWins.opponent} +
+

Dampening {playerSide.dampeningPercent}%

+ Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10 +
+
+ +
+ Iron Arbiter + No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s +
+ +
+
+ {playerSide.party.map((member, index) => { + const action = `targetParty${index + 1}` as InputAction + const targetBinding = directPartyTargeting ? bindings[lastDevice][action] : null + return ( + + ) + })} +
+
+ +
+ {starterSpells.map((spell, slotIndex) => { + const remaining = playerSide.cooldowns[spell.id] ?? 0 + const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady) + const percent = remaining > 0 + ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100) + : 0 + return ( + + ) + })} +
+ {gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE} +
+ +
+
+
+
+ )} + + {!dualScreenEnabled && status !== 'queueing' && (