Android build v1.1.23

This commit is contained in:
Warren H
2026-07-05 22:48:56 -04:00
parent 956c9e32f9
commit 9708ba9e40
106 changed files with 4294 additions and 458 deletions
+304 -26
View File
@@ -17,6 +17,7 @@ import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKillReward,
type Iwt2BossDropAward,
type Iwt2BossPetAward,
type Iwt2Save,
} from '../save/iwt2Repository'
@@ -25,7 +26,8 @@ import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_CLASS_METADATA } from '../content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import type { Iwt2Difficulty } from '../content/difficulties'
import { abilitiesForHealer, type Iwt2HealerAbility, type Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2RoguelikeContentType,
@@ -33,25 +35,44 @@ import type {
Iwt2RoguelikeSelfBuffId,
Iwt2RoguelikeVariant,
} from '../content/roguelike'
import {
createRoguelikePressureState,
roguelikeIncomingDamageScale,
} from '../sim/roguelikePressure'
import { applyIwt2PveGearStats, applyIwt2PveGearToHealerAbilities } from '../sim/equipmentStats'
import {
IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
} from '../content/roguelike'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'menu'
type OverlayAction = 'primary' | 'requeue' | 'menu'
type OverlayNavEntry = {
action: OverlayAction
row: number
}
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'menu', row: 1 },
]
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'requeue', row: 1 },
{ action: 'menu', row: 2 },
]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
type BossArenaScreenProps = {
bossId: Iwt2BossId
bossIds?: Iwt2BossId[]
difficulty?: Iwt2Difficulty
modeLabel?: string
save: Iwt2Save
onBack: () => void
onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
buffs: Iwt2RoguelikeSelfBuffId[]
@@ -63,18 +84,49 @@ type BossArenaScreenProps = {
}
}
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp'
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
const roguelikeStage = roguelikeRun?.stage
const roguelikeContentType = roguelikeRun?.contentType
const roguelikeBuffs = roguelikeRun?.buffs ?? EMPTY_ROGUELIKE_BUFFS
const roguelikeDamageScale = roguelikeRun
? roguelikeIncomingDamageScale(roguelikeRun.stage, roguelikeRun.contentType)
: 1
const experienceMultiplier = difficulty?.experienceMultiplier ?? 1
const difficultySlug = difficulty?.slug ?? 'initiate'
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined,
))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
pvpRoguelike
? createInitialIwt2ArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
createPressureState(roguelikeStage, roguelikeContentType),
)
: null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
@@ -121,8 +173,25 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
const pressureState = createPressureState(roguelikeStage, roguelikeContentType)
const next = createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
roguelikeBuffs,
pressureState,
pveGearActive ? save.gearProgress : undefined,
)
const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
pressureState,
)
: null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
@@ -131,9 +200,11 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
setArenaState(next)
setOpponentArenaState(nextOpponentState)
setAbilityCooldowns({})
setDropAwards([])
setPetAwards([])
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
@@ -141,12 +212,21 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
}, [])
const abilities = useMemo(
() => applyRoguelikeModifiers(
abilitiesForHealer(save.character.healerStyle),
roguelikeRun?.buffs ?? [],
roguelikeRun?.debuffs ?? [],
),
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
() => {
const baseAbilities = abilitiesForHealer(
save.character.healerStyle,
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
)
const gearAbilities = pveGearActive
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
: baseAbilities
return applyRoguelikeModifiers(
gearAbilities,
roguelikeRun?.buffs ?? [],
roguelikeRun?.debuffs ?? [],
)
},
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
@@ -168,8 +248,9 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
const moveOverlaySelection = useCallback((action: string) => {
setSelectedOverlayAction((current) => {
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
const entries = overlayNavEntriesFor(statusRef.current, pvpRoguelike)
const active = entries.find((entry) => entry.action === current) ?? entries[0]
const candidates = entries.filter((entry) => {
if (entry.action === current) return false
if (action === 'navigateUp') return entry.row < active.row
if (action === 'navigateDown') return entry.row > active.row
@@ -179,9 +260,13 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
return candidates[0]?.action ?? current
})
}, [])
}, [pvpRoguelike])
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
if (overlayAction === 'requeue') {
onPvpRequeue?.()
return
}
if (overlayAction === 'menu') {
onBack()
return
@@ -195,7 +280,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
return
}
resetArena()
}, [onBack, resetArena, roguelikeRun])
}, [onBack, onPvpRequeue, resetArena, roguelikeRun])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
@@ -273,21 +358,33 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
if (newlyDefeatedBosses.length > 0) {
const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
const newDropAwards: Iwt2BossDropAward[] = []
const newPetAwards: Iwt2BossPetAward[] = []
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
difficultySlug,
experienceMultiplier,
})
updatedSave = reward.save
newDropAwards.push(reward.dropAwarded)
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave
if (newDropAwards.length > 0) setDropAwards((current) => [...current, ...newDropAwards])
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
showOverlay('victory')
if (pvpRoguelike && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else {
showOverlay('victory')
}
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
}
@@ -305,7 +402,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [onSaveUpdated, pvpRoguelike, showOverlay])
}, [difficultySlug, experienceMultiplier, onSaveUpdated, pvpRoguelike, roguelikeRun, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
@@ -314,11 +411,14 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
const victoryExperience = Math.round(arenaState.bosses.length * 125 * experienceMultiplier)
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
const overlayPrimaryLabel = status === 'paused'
? 'Resume'
: status === 'victory' && roguelikeRun
? 'Choose Upgrade'
: pvpRoguelike && status !== 'playing'
? 'Rematch'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossTitle} Down`
@@ -463,9 +563,13 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+{arenaState.bosses.length * 125} XP</span>
<span>{arenaState.bosses.length} carves</span>
<span>+{victoryExperience} XP</span>
<span>Log +{arenaState.bosses.length}</span>
{dropAwards.map((award) => (
<span key={`${award.dropId}:${award.quantityAfter}`}>
{award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''}
</span>
))}
{petAwards.map((award) => (
<span key={`${award.petId}:${award.quantityAfter}`}>
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
@@ -478,14 +582,27 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
{overlayPrimaryLabel}
</button>
{pvpRoguelike && (
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'requeue' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'requeue' ? 'true' : undefined}
onClick={() => activateOverlayAction('requeue')}
onPointerDown={() => setSelectedOverlayAction('requeue')}
type="button"
>
Requeue
</button>
)}
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
Menu
@@ -518,7 +635,12 @@ function formatArenaTime(seconds: number): string {
}
function roguelikeBossHealthScale(stage: number): number {
return 0.5 + Math.max(0, stage - 1) * 0.1
return 1 + Math.max(0, stage - 1) * 0.1
}
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
return DEFAULT_OVERLAY_NAV_ENTRIES
}
function arenaHudSignature(state: Iwt2ArenaState): string {
@@ -566,12 +688,66 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
return changed ? next : cooldowns
}
function createArenaState(
bossId: Iwt2BossId,
bossIds: Iwt2BossId[] | undefined,
bossHealthScale: number,
partyDamageTakenScale: number,
buffs: Iwt2RoguelikeSelfBuffId[],
roguelikePressure: ReturnType<typeof createPressureState>,
gearProgress?: Iwt2Save['gearProgress'],
): Iwt2ArenaState {
const baseState = createInitialIwt2ArenaState(
bossId,
bossIds,
bossHealthScale,
partyDamageTakenScale,
roguelikePressure,
)
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
const shieldedHotHealingMultiplier = buffs.includes(IWT2_BARKSKIN_HOT_BONUS_BUFF_ID) ? 1.25 : undefined
if (shieldedDamageTakenMultiplier === undefined && shieldedHotHealingMultiplier === undefined) return state
return {
...state,
party: state.party.map((member) => ({
...member,
shieldedDamageTakenMultiplier,
shieldedHotHealingMultiplier,
})),
}
}
function createPressureState(
stage: number | undefined,
contentType: Iwt2RoguelikeContentType | undefined,
) {
return stage && contentType ? createRoguelikePressureState(stage, contentType) : undefined
}
function shieldedDamageTakenMultiplierForBuffs(buffs: Iwt2RoguelikeSelfBuffId[]) {
const multipliers: number[] = []
if (buffs.includes(IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.5)
if (buffs.includes(IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.7)
return multipliers.length > 0 ? Math.min(...multipliers) : undefined
}
function applyRoguelikeModifiers(
abilities: Iwt2HealerAbility[],
buffs: Iwt2RoguelikeSelfBuffId[],
debuffs: Iwt2RoguelikeOpponentDebuffId[],
): Iwt2HealerAbility[] {
if (buffs.length === 0 && debuffs.length === 0) return abilities
const abilityById = new Map(abilities.map((ability) => [ability.id, ability]))
const renewEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-renew'))
const sunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'))
const halfSunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'), 0.5)
const seedOfLifeEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-seed-of-life'))
const barkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'))
const halfBarkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'), 0.5)
const mendingRuneEffect = triggeredEffectFromAbility(abilityById.get('runesage-mending-rune'))
const aegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'))
const halfAegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'), 0.5)
return abilities.map((ability) => {
const slot = String(ability.slot)
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
@@ -579,16 +755,118 @@ function applyRoguelikeModifiers(
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
const triggeredEffects = iwt2TriggeredEffectsForAbility({
ability,
aegisScriptEffect,
barkskinEffect,
buffs,
halfAegisScriptEffect,
halfBarkskinEffect,
halfSunWardEffect,
mendingRuneEffect,
renewEffect,
seedOfLifeEffect,
sunWardEffect,
})
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0 && triggeredEffects.length === 0) return ability
return {
...ability,
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
triggeredEffects: triggeredEffects.length > 0 ? triggeredEffects : ability.triggeredEffects,
}
})
}
function iwt2TriggeredEffectsForAbility({
ability,
aegisScriptEffect,
barkskinEffect,
buffs,
halfAegisScriptEffect,
halfBarkskinEffect,
halfSunWardEffect,
mendingRuneEffect,
renewEffect,
seedOfLifeEffect,
sunWardEffect,
}: {
ability: Iwt2HealerAbility
aegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
barkskinEffect: Iwt2TriggeredAbilityEffect | undefined
buffs: Iwt2RoguelikeSelfBuffId[]
halfAegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
halfBarkskinEffect: Iwt2TriggeredAbilityEffect | undefined
halfSunWardEffect: Iwt2TriggeredAbilityEffect | undefined
mendingRuneEffect: Iwt2TriggeredAbilityEffect | undefined
renewEffect: Iwt2TriggeredAbilityEffect | undefined
seedOfLifeEffect: Iwt2TriggeredAbilityEffect | undefined
sunWardEffect: Iwt2TriggeredAbilityEffect | undefined
}): Iwt2TriggeredAbilityEffect[] {
const effects = [...ability.triggeredEffects ?? []]
const addEffect = (effect: Iwt2TriggeredAbilityEffect | undefined) => {
if (effect && !effects.some((existing) => existing.id === effect.id && existing.power === effect.power)) {
effects.push(effect)
}
}
if (ability.id === 'dawnweaver-mend') {
if (buffs.includes('dawnweaver-mend-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-mend-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'dawnweaver-renew') {
if (buffs.includes('dawnweaver-renew-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'dawnweaver-radiance') {
if (buffs.includes('dawnweaver-radiance-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-radiance-applies-sun-ward')) addEffect(halfSunWardEffect)
} else if (ability.id === 'dawnweaver-sun-ward') {
if (buffs.includes('dawnweaver-sun-ward-applies-renew')) addEffect(renewEffect)
} else if (ability.id === 'dawnweaver-purify') {
if (buffs.includes('dawnweaver-purify-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-purify-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'lifebinder-verdant-touch') {
if (buffs.includes('lifebinder-verdant-touch-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-verdant-touch-applies-barkskin')) addEffect(halfBarkskinEffect)
} else if (ability.id === 'lifebinder-seed-of-life') {
if (buffs.includes('lifebinder-seed-of-life-applies-barkskin')) addEffect(barkskinEffect)
} else if (ability.id === 'lifebinder-wild-growth') {
if (buffs.includes('lifebinder-wild-growth-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-wild-growth-applies-barkskin')) addEffect(halfBarkskinEffect)
} else if (ability.id === 'lifebinder-barkskin') {
if (buffs.includes('lifebinder-barkskin-applies-seed-of-life')) addEffect(seedOfLifeEffect)
} else if (ability.id === 'lifebinder-purging-sap') {
if (buffs.includes('lifebinder-purging-sap-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-purging-sap-applies-barkskin')) addEffect(barkskinEffect)
} else if (ability.id === 'runesage-etched-mend') {
if (buffs.includes('runesage-etched-mend-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-etched-mend-applies-aegis-script')) addEffect(halfAegisScriptEffect)
} else if (ability.id === 'runesage-mending-rune') {
if (buffs.includes('runesage-mending-rune-applies-aegis-script')) addEffect(aegisScriptEffect)
} else if (ability.id === 'runesage-concordance') {
if (buffs.includes('runesage-concordance-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-concordance-applies-aegis-script')) addEffect(halfAegisScriptEffect)
} else if (ability.id === 'runesage-aegis-script') {
if (buffs.includes('runesage-aegis-script-applies-mending-rune')) addEffect(mendingRuneEffect)
} else if (ability.id === 'runesage-unravel') {
if (buffs.includes('runesage-unravel-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-unravel-applies-aegis-script')) addEffect(aegisScriptEffect)
}
return effects
}
function triggeredEffectFromAbility(
ability: Iwt2HealerAbility | undefined,
strength = 1,
): Iwt2TriggeredAbilityEffect | undefined {
if (!ability) return undefined
return {
effectType: ability.effectType,
id: ability.id,
kind: ability.kind,
name: ability.name,
power: Math.max(1, Math.round(ability.power * strength)),
}
}
function countStacks(items: readonly string[], id: string) {
return items.filter((item) => item === id).length
}