import { getActionAiAvoidance, getActionAttack, getEnabledActionAttack, type ActionAiAvoidanceConfig, type ActionAttackKind, } from './actionEncounterConfig' import { ACTION_CONTROL_RULES, ACTION_MOVEMENT_RULES, createSpellCooldowns, createSpellResourceState, addShield, applyHealOverTimeTimers, applyShieldedDamage, getActiveHealOverTimeFrames, getActionSpellDefinition, getActionSpellReach, getActionSpellTarget, getActionTalentAdjustedAmount, getHealOverTimeEffectId, RANGED_ATTACK_RANGE, recordSpellCast, ARENA_PRIEST_SPELL_BAR, DUNGEON_PRIEST_SPELL_BAR, SPELL_GLOBAL_COOLDOWN_SECONDS, SPELLS, spendActionSpellMana, tickHealOverTimes, tickSpellCooldowns as tickSharedSpellCooldowns, tickSpellResource, type CastState, type HealOverTimeEffect, type PartyRole, type SpellDefinition, type SpellResourceState, type SpellSlot, type ActionSpellReachResult, } from '../actionCombatCore' import { getArenaAbility, getArenaClassVitals, type ArenaClassId } from '../actionClassKits' import type { TalentModifiers } from '../claudeCraftTalents' import { createEmptyTalentModifiers } from '../actionCombatProfile' import { type BossInput, type BossPhase, type Point, type ArcTelegraph, type RaidFrame, type Telegraph, } from '../actionCombatTypes' import { getEnemyBodyDamage, getEnemyChargeDamage, getEnemyChargeSeconds, getEnemyChargeSpeed, getEnemyRecoverSeconds, getEnemyTankFocusSeconds, getEnemyTankSwingDamage, getEnemyTankSwingSeconds, getEnemyTrackSeconds, getEnemyWindupSeconds, } from './actionEnemyRuntime' import { getActionDifficultyTier, type ActionDifficulty, type ActionDungeonId, type ActionRunMode } from '../actionMode' import type { PlayerClass } from '../claudeCraftTypes' export type { ActionDungeonId, ActionRunMode } from '../actionMode' export { ARENA_PRIEST_SPELL_BAR, DUNGEON_PRIEST_SPELL_BAR, SPELLS } export type { CastState, PartyRole, SpellDefinition, SpellSlot } export type EnemyKind = | 'bulldrome' | 'bullfango' | 'yian-kut-ku' | 'bird' | 'cyber-dragon' | 'claudecraft-mob' | 'morthen-the-gravecaller' | 'vael-the-mistcaller' | 'korzul-the-gravewyrm' export type EncounterStep = 'trash' | 'boss' type BirdFlightStep = 'ground' | 'toLeft' | 'across' | 'toTank' export type FighterState = { x: number y: number radius: number hp: number maxHp: number } export type TargetableState = FighterState & { id: string name: string role: PartyRole shield: number stunTimer: number invulnerableTimer: number healOverTimes?: HealOverTimeEffect[] renewTimer: number renewTickTimer: number burnTimer: number burnTickTimer: number bleedTimer: number bleedTickTimer: number } export type PartyMemberState = TargetableState & { aiSlot: number } export type EnemyState = FighterState & { id: string name: string kind: EnemyKind phase: BossPhase phaseTimer: number chargeVector: Point targetId: string chargeCount: number tankSwingTimer: number flightStep: BirdFlightStep flightY: number orbitAngle: number mechanicTimers: Partial> arcTelegraph: ArcTelegraph telegraph: Telegraph slamTelegraph: { active: boolean radius: number } } export type BulldromeState = { dungeonId: ActionDungeonId difficulty: ActionDifficulty runMode: ActionRunMode encounterStep: EncounterStep arena: { width: number height: number padding: number } player: TargetableState & { classId: PlayerClass currentCast: CastState | null facingX: number facingY: number globalCooldown: number spellCooldowns: Record talentModifiers: TalentModifiers } & SpellResourceState party: PartyMemberState[] targetId: string boss: EnemyState adds: EnemyState[] fireballs: FireballState[] fireSpots: FireSpotState[] spinningBlades: SpinningBladeState[] nextHazardId: number damageDoneByUnit: Record bossKills: number dungeonWaveIndex: number dungeonWaveTotal: number respawnTimer: number healEvents: HealTextEvent[] noticeEvents: NoticeTextEvent[] elapsed: number message: string result: 'playing' | 'win' | 'loss' lastHit: 'player' | 'party' | 'boss' | 'heal' | null } export type FireballState = { id: string x: number y: number radius: number vx: number vy: number bouncesRemaining: number } export type FireSpotState = { id: string x: number y: number radius: number remaining: number } export type SpinningBladeState = { id: string sourceKind: EnemyKind x: number y: number radius: number speed: number remaining: number targetId: string rotation: number } export type EnemyFrame = { id: string name: string kind: EnemyKind hp: number maxHp: number phase: BossPhase } export type HealTextEvent = { id: string targetId: string amount: number } export type NoticeTextEvent = { id: string targetId: string text: string } type CombatSpellTarget = TargetableState | EnemyState const BOSS_SLAM_CAST_SECONDS = 1.25 const BOSS_SLAM_RADIUS = 142 const DUNGEON_ARENA_HEIGHT = 648 const DUNGEON_ARENA_PADDING = 42 const DUNGEON_ARENA_WIDTH = 1152 const YIAN_FIREBALL_WINDUP_SECONDS = 1 const FIREBALL_SPEED = 190 const FIREBALL_DAMAGE = 16 const FIRE_DOT_SECONDS = 5 const FIRE_DOT_TICK_SECONDS = 1 const FIRE_DOT_DAMAGE = 5 const FIRE_SPOT_SECONDS = 20 const MAX_FIRE_SPOTS = 8 const CYBER_BLADE_SPAWN_SECONDS = 10 const CYBER_BLADE_SECONDS = 15 const CYBER_BLADE_SPEED = 96 const CYBER_BLADE_RADIUS = 24 const CYBER_BLEED_SECONDS = 8 const CYBER_BLEED_TICK_SECONDS = 1 const CYBER_BLEED_DAMAGE = 3 const CYBER_CIRCLE_SECONDS = 1.35 const CYBER_CIRCLE_RADIUS = 124 const CYBER_CHARGE_SECONDS = 0.72 const CYBER_CHARGE_STUN_SECONDS = 1 const AI_MOVE_SPEED = 132 const TANK_DPS = 4 const MELEE_DPS = 10.5 const RANGED_DPS = 8.75 function getInitialArena(dungeonId: ActionDungeonId) { void dungeonId return { width: DUNGEON_ARENA_WIDTH, height: DUNGEON_ARENA_HEIGHT, padding: DUNGEON_ARENA_PADDING, } } function getInitialPartyStart(dungeonId: ActionDungeonId) { void dungeonId const centerX = DUNGEON_ARENA_WIDTH / 2 const centerY = DUNGEON_ARENA_HEIGHT / 2 return { player: { x: centerX, y: centerY + 134 }, tank: { x: centerX, y: centerY - 92 }, melee: { x: centerX, y: centerY - 50 }, ranged1: { x: centerX - 120, y: centerY + 106 }, ranged2: { x: centerX + 120, y: centerY + 106 }, } } function toArenaClassId(classId: PlayerClass): ArenaClassId { if (classId === 'hunter') return 'ranger' if (classId === 'warrior' || classId === 'mage' || classId === 'rogue' || classId === 'paladin' || classId === 'shaman' || classId === 'druid' || classId === 'priest') return classId return 'priest' } export function createBulldromeState( difficulty: ActionDifficulty = 'ilvl-1', dungeonId: ActionDungeonId = 'bulldrome', runMode: ActionRunMode = 'hunt', playerClassId: PlayerClass = 'priest', playerTalentModifiers?: TalentModifiers, ): BulldromeState { const dungeonWaveTotal = 0 const arena = getInitialArena(dungeonId) const partyStart = getInitialPartyStart(dungeonId) const playerVitals = getArenaClassVitals(toArenaClassId(playerClassId)) const state: BulldromeState = { dungeonId, difficulty, runMode, encounterStep: 'boss', arena, player: { id: 'player', name: 'You', classId: playerClassId, role: 'healer', x: partyStart.player.x, y: partyStart.player.y, radius: 15, hp: playerVitals.maxHp, maxHp: playerVitals.maxHp, shield: 0, stunTimer: 0, invulnerableTimer: 0, healOverTimes: [], renewTimer: 0, renewTickTimer: 0, burnTimer: 0, burnTickTimer: 0, bleedTimer: 0, bleedTickTimer: 0, currentCast: null, facingX: 0, facingY: -1, globalCooldown: 0, spellCooldowns: createSpellCooldowns(), talentModifiers: playerTalentModifiers ?? createEmptyTalentModifiers(), ...createSpellResourceState(playerClassId), }, party: [ createPartyMember('tank', 'Brakka', 'tank', getArenaClassVitals('warrior_protection').maxHp, partyStart.tank.x, partyStart.tank.y, 0), createPartyMember('melee-1', 'Kael', 'melee', getArenaClassVitals('rogue_combat').maxHp, partyStart.melee.x, partyStart.melee.y, 1), createPartyMember('ranged-1', 'Sera', 'ranged', getArenaClassVitals('mage_fire').maxHp, partyStart.ranged1.x, partyStart.ranged1.y, 3), createPartyMember('ranged-2', 'Nix', 'ranged', getArenaClassVitals('hunter_marksmanship').maxHp, partyStart.ranged2.x, partyStart.ranged2.y, 4), ], targetId: 'tank', boss: createInitialEnemy(dungeonId), adds: [], fireballs: [], fireSpots: [], spinningBlades: [], nextHazardId: 0, damageDoneByUnit: createDamageDoneTracker(), bossKills: 0, dungeonWaveIndex: 0, dungeonWaveTotal, respawnTimer: 0, healEvents: [], noticeEvents: [], elapsed: 0, message: getDungeonStartMessage(dungeonId), result: 'playing', lastHit: null, } applyDifficultyScaling(state) return state } export function updateBulldromeState( state: BulldromeState, input: BossInput, deltaSeconds: number, ): BulldromeState { if (input.reset) return createBulldromeState(state.difficulty, state.dungeonId, state.runMode, state.player.classId, state.player.talentModifiers) const next = cloneState(state) next.elapsed += deltaSeconds next.lastHit = null next.healEvents = [] next.noticeEvents = [] if (input.targetId) setTarget(next, input.targetId) if (input.targetDelta !== 0) cycleTarget(next, input.targetDelta) if (next.result !== 'playing') return next tickTargetable(next, next.player, deltaSeconds) tickPlayerGlobalCooldown(next, deltaSeconds) tickSpellCooldowns(next.player.spellCooldowns, deltaSeconds) tickSpellResource(next.player, deltaSeconds) tickCurrentCast(next, deltaSeconds) for (const member of next.party) tickTargetable(next, member, deltaSeconds) movePlayer(next, input, deltaSeconds) if (next.respawnTimer > 0) { next.respawnTimer = Math.max(0, next.respawnTimer - deltaSeconds) if (next.respawnTimer <= 0) respawnBossEncounter(next) else next.message = `Marathon break. Next boss in ${Math.ceil(next.respawnTimer)}.` startPlayerSpell(next, input.castSpell) resolveResult(next) return next } updatePartyAi(next, deltaSeconds) resolvePartyDamage(next, deltaSeconds) updateEnemies(next, deltaSeconds) updateFireHazards(next, deltaSeconds) updateSpinningBlades(next, deltaSeconds) resolveContact(next) startPlayerSpell(next, input.castSpell) resolveResult(next) return next } export function getRaidFrames(state: BulldromeState): RaidFrame[] { return [state.player, ...state.party].map((unit) => ({ id: unit.id, name: unit.name, role: unit.role, hp: unit.hp, maxHp: unit.maxHp, healOverTimes: getActiveHealOverTimeFrames(unit), renewTimer: unit.renewTimer, burnTimer: unit.burnTimer, bleedTimer: unit.bleedTimer, shield: unit.shield, selected: unit.id === state.targetId, damageDone: state.damageDoneByUnit[unit.id] ?? 0, })) } export function getEnemyFrames(state: BulldromeState): EnemyFrame[] { return getLivingEnemies(state).map((enemy) => ({ id: enemy.id, name: enemy.name, kind: enemy.kind, hp: enemy.hp, maxHp: enemy.maxHp, phase: enemy.phase, })) } export function getEncounterTitle(state: BulldromeState) { if (state.dungeonId === 'cyber-dragon') return 'Cyber Dragon' if (state.dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku' return 'Bulldrome' } export function getEncounterHp(state: BulldromeState) { const enemies = getAllEnemies(state) const hp = enemies.reduce((sum, enemy) => sum + Math.max(0, enemy.hp), 0) const maxHp = enemies.reduce((sum, enemy) => sum + enemy.maxHp, 0) return { hp, maxHp } } export function getTargetableUnits(state: BulldromeState): TargetableState[] { return [state.player, ...state.party] } export function getAllEnemies(state: BulldromeState): EnemyState[] { return [state.boss, ...state.adds] } export function getLivingEnemies(state: BulldromeState): EnemyState[] { return getAllEnemies(state).filter((enemy) => enemy.hp > 0) } export function distance(a: Point, b: Point) { return Math.hypot(a.x - b.x, a.y - b.y) } function createPartyMember( id: string, name: string, role: Exclude, maxHp: number, x: number, y: number, aiSlot: number, ): PartyMemberState { return { id, name, role, x, y, radius: role === 'tank' ? 17 : 14, hp: maxHp, maxHp, shield: 0, stunTimer: 0, invulnerableTimer: 0, healOverTimes: [], renewTimer: 0, renewTickTimer: 0, burnTimer: 0, burnTickTimer: 0, bleedTimer: 0, bleedTickTimer: 0, aiSlot, } } function createBulldrome(): EnemyState { return createEnemy('bulldrome', 'Bulldrome', 'bulldrome', 1320, 34, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0) } function createYianKutKu(): EnemyState { return createEnemy('yian-kut-ku', 'Yian Kut-Ku', 'yian-kut-ku', 1540, 32, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0) } function createCyberDragon(): EnemyState { return createEnemy('cyber-dragon', 'Cyber Dragon', 'cyber-dragon', 1880, 36, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0) } function createInitialEnemy(dungeonId: ActionDungeonId): EnemyState { if (dungeonId === 'cyber-dragon') return createCyberDragon() if (dungeonId === 'yian-kut-ku') return createYianKutKu() return createBulldrome() } function getDungeonStartMessage(dungeonId: ActionDungeonId) { if (dungeonId === 'cyber-dragon') return 'Cyber Dragon boots its blade array.' if (dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku watches the roost.' return 'Bulldrome blocks the hunting grounds.' } function createEnemy( id: string, name: string, kind: EnemyKind, maxHp: number, radius: number, x: number, y: number, timerOffset: number, ): EnemyState { const trackSeconds = getTrackSeconds(kind) return { id, name, kind, x, y, radius, hp: maxHp, maxHp, phase: 'tracking', phaseTimer: trackSeconds + timerOffset, chargeVector: { x: 0, y: 1 }, targetId: 'tank', chargeCount: 0, tankSwingTimer: getTankSwingSeconds(kind), flightStep: 'ground', flightY: y, orbitAngle: 0, mechanicTimers: { spinningBlade: getAttackFrequency(kind, 'spinningBlade', CYBER_BLADE_SPAWN_SECONDS), }, arcTelegraph: { active: false, center: { x, y }, radius: 0, startAngle: 0, endAngle: 0, width: 0, }, telegraph: { active: false, start: { x, y }, end: { x, y: 470 }, width: kind === 'bulldrome' ? 86 : 48, }, slamTelegraph: { active: false, radius: BOSS_SLAM_RADIUS, }, } } function tickSpellCooldowns(cooldowns: Record, deltaSeconds: number) { tickSharedSpellCooldowns(cooldowns, deltaSeconds) } function tickPlayerGlobalCooldown(state: BulldromeState, deltaSeconds: number) { state.player.globalCooldown = Math.max(0, state.player.globalCooldown - deltaSeconds) } function triggerPlayerGlobalCooldown(state: BulldromeState) { state.player.globalCooldown = SPELL_GLOBAL_COOLDOWN_SECONDS } function tickCurrentCast(state: BulldromeState, deltaSeconds: number) { const cast = state.player.currentCast if (!cast) return cast.remaining = Math.max(0, cast.remaining - deltaSeconds) state.message = `Casting ${getDungeonSpell(state, cast.spell).name} on ${findTarget(state, cast.targetId)?.name ?? 'target'}.` if (cast.remaining > 0) return const completed = { ...cast } state.player.currentCast = null completePlayerSpell(state, completed.spell, completed.targetId) } function tickTargetable(state: BulldromeState, unit: TargetableState, deltaSeconds: number) { unit.stunTimer = Math.max(0, unit.stunTimer - deltaSeconds) unit.invulnerableTimer = Math.max(0, unit.invulnerableTimer - deltaSeconds) if (unit.burnTimer > 0) { unit.burnTimer = Math.max(0, unit.burnTimer - deltaSeconds) unit.burnTickTimer -= deltaSeconds if (unit.burnTickTimer <= 0) { applyDamage(unit, FIRE_DOT_DAMAGE) unit.burnTickTimer += FIRE_DOT_TICK_SECONDS } } if (unit.bleedTimer > 0) { unit.bleedTimer = Math.max(0, unit.bleedTimer - deltaSeconds) unit.bleedTickTimer -= deltaSeconds if (unit.bleedTickTimer <= 0) { applyDamage(unit, CYBER_BLEED_DAMAGE) unit.bleedTickTimer += CYBER_BLEED_TICK_SECONDS } } if (unit.healOverTimes?.length || unit.renewTimer > 0) { tickHealOverTimes(unit, deltaSeconds, (effect) => { healTarget(state, unit, effect.tickHeal) addHealEvent(state, unit, 0) }) } } function movePlayer(state: BulldromeState, input: BossInput, deltaSeconds: number) { if (state.player.stunTimer > 0) { state.message = 'Stunned. Heal after the stars clear.' return } const length = Math.hypot(input.xAxis, input.yAxis) if (length <= 0.01) return state.player.facingX = input.xAxis / length state.player.facingY = input.yAxis / length state.player.x += state.player.facingX * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds state.player.y += state.player.facingY * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds clampToArena(state, state.player) } function updatePartyAi(state: BulldromeState, deltaSeconds: number) { for (const member of state.party) { if (member.hp <= 0 || member.stunTimer > 0) continue const hazardAvoidance = getDamagingEffectAvoidance(state, member) if (hazardAvoidance) { moveToward(state, member, hazardAvoidance.point, hazardAvoidance.moveSpeed * deltaSeconds) continue } const desired = getAiDesiredPoint(state, member) moveToward(state, member, desired, AI_MOVE_SPEED * deltaSeconds) } } function getAiDesiredPoint(state: BulldromeState, member: PartyMemberState): Point { const target = getAiTargetForMember(state, member) if (!target) return { x: member.x, y: member.y } if (member.role === 'ranged') { const enemyDistance = distance(member, target) if (enemyDistance > RANGED_ATTACK_RANGE - 18) { const dx = member.x - target.x const dy = member.y - target.y const length = Math.max(1, Math.hypot(dx, dy)) return clampPointToArena(state, { x: target.x + (dx / length) * (RANGED_ATTACK_RANGE - 36), y: target.y + (dy / length) * (RANGED_ATTACK_RANGE - 36), }, member.radius) } const home = member.aiSlot === 3 ? { x: 320, y: 376 } : { x: 640, y: 376 } return distance(home, target) <= RANGED_ATTACK_RANGE ? home : { x: member.x, y: member.y } } const bossDistance = distance(member, target) if (member.role === 'tank') { if (bossDistance >= 46 && bossDistance <= 86) return { x: member.x, y: member.y } return offsetFromEnemy(state, target, Math.PI / 2, target.radius + 28) } if (bossDistance >= 54 && bossDistance <= 98) return { x: member.x, y: member.y } const side = member.aiSlot === 1 ? 2.45 : 0.7 return offsetFromEnemy(state, target, side, target.radius + 42) } function offsetFromEnemy(state: BulldromeState, enemy: FighterState, angle: number, range: number): Point { const point = { x: enemy.x + Math.cos(angle) * range, y: enemy.y + Math.sin(angle) * range, } return clampPointToArena(state, point, 14) } function moveToward(state: BulldromeState, unit: TargetableState, point: Point, maxDistance: number) { const dx = point.x - unit.x const dy = point.y - unit.y const length = Math.hypot(dx, dy) if (length <= 2) return const distanceThisFrame = Math.min(length, maxDistance) unit.x += (dx / length) * distanceThisFrame unit.y += (dy / length) * distanceThisFrame clampToArena(state, unit) } function updateEnemies(state: BulldromeState, deltaSeconds: number) { for (const enemy of getLivingEnemies(state)) { updateEnemy(state, enemy, deltaSeconds) } } function updateEnemy(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { enemy.phaseTimer -= deltaSeconds if (enemy.kind === 'yian-kut-ku') { updateYianKutKu(state, enemy, deltaSeconds) return } if (enemy.kind === 'bird') { updateBird(state, enemy, deltaSeconds) return } if (enemy.kind === 'cyber-dragon') { updateCyberDragon(state, enemy, deltaSeconds) return } if (enemy.phase === 'tracking') { enemy.targetId = getTankTargetId(state, enemy) const target = findTarget(state, enemy.targetId) ?? state.player driftTowardTarget(state, enemy, target, deltaSeconds) enemy.telegraph.active = false state.message = `${enemy.name} tracks ${target.name}.` if (enemy.phaseTimer <= 0) { if (getEnabledActionAttack(enemy.kind, 'charge')) { enemy.targetId = selectChargeTarget(state, enemy) startWindup(state, enemy) } else { startTankFocus(enemy) } } return } if (enemy.phase === 'mauling') { enemy.telegraph.active = false enemy.slamTelegraph.active = false enemy.targetId = 'tank' const tank = findTarget(state, 'tank') ?? state.player driftTowardTarget(state, enemy, tank, deltaSeconds) enemy.tankSwingTimer -= deltaSeconds state.message = `${enemy.name} attacks ${tank.name}.` if (enemy.tankSwingTimer <= 0) { applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) tank.invulnerableTimer = 0.12 state.lastHit = tank.id === 'player' ? 'player' : 'party' state.message = `${enemy.name} hit ${tank.name}.` enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) } if (enemy.phaseTimer <= 0) { const slam = getEnabledActionAttack(enemy.kind, 'groundSlam') const charge = getEnabledActionAttack(enemy.kind, 'charge') if ( slam && enemy.chargeCount > 0 && enemy.chargeCount % Math.max(1, Math.round(slam.everyNthCharge ?? 3)) === 0 ) { startSlam(enemy) } else if (charge) { enemy.targetId = selectChargeTarget(state, enemy) startWindup(state, enemy) } else { enemy.phaseTimer = getTankFocusSeconds(enemy.kind) } } return } if (enemy.phase === 'windup') { enemy.telegraph.active = true const target = findTarget(state, enemy.targetId) state.message = `${enemy.name} charges ${target?.name ?? 'the party'}.` if (enemy.phaseTimer <= 0) { enemy.phase = 'charging' enemy.phaseTimer = getChargeSeconds(enemy.kind) enemy.telegraph.active = false state.message = `${enemy.name} charges.` } return } if (enemy.phase === 'charging') { enemy.telegraph.active = true enemy.x += enemy.chargeVector.x * getChargeSpeed(enemy.kind) * deltaSeconds enemy.y += enemy.chargeVector.y * getChargeSpeed(enemy.kind) * deltaSeconds clampToArena(state, enemy) if (enemy.phaseTimer <= 0 || hitArenaWall(state, enemy)) { enemy.phase = 'recovering' enemy.phaseTimer = getRecoverSeconds(enemy.kind) state.message = `${enemy.name} is recovering.` } return } if (enemy.phase === 'slamWindup') { enemy.telegraph.active = false enemy.slamTelegraph.active = true state.message = 'Ground slam. Get away from Bulldrome.' if (enemy.phaseTimer <= 0) { resolveGroundSlam(state, enemy) enemy.slamTelegraph.active = false enemy.targetId = selectChargeTarget(state, enemy) startWindup(state, enemy) } return } if (enemy.phase === 'recovering') { enemy.telegraph.active = false enemy.slamTelegraph.active = false state.message = `${enemy.name} is recovering.` if (enemy.phaseTimer <= 0) { startTankFocus(enemy) } } } function updateYianKutKu(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { const fireballAttack = getEnabledActionAttack(enemy.kind, 'fireballVolley') if (enemy.phase === 'tracking') { enemy.targetId = getTankTargetId(state, enemy) const tank = findTarget(state, enemy.targetId) ?? state.player driftTowardTarget(state, enemy, tank, deltaSeconds) state.message = 'Yian Kut-Ku watches the party.' if (enemy.phaseTimer <= 0) { if (!fireballAttack) { startTankFocus(enemy) return } state.fireballs = [] enemy.phase = 'windup' enemy.phaseTimer = fireballAttack.windupSeconds ?? YIAN_FIREBALL_WINDUP_SECONDS state.message = 'Yian Kut-Ku inhales. Fireballs incoming.' } return } if (enemy.phase === 'windup') { state.message = 'Fireballs: northeast and northwest.' if (enemy.phaseTimer <= 0) { spawnYianFireballs(state, enemy) enemy.phase = 'recovering' enemy.phaseTimer = getRecoverSeconds(enemy.kind) state.message = 'Yian Kut-Ku spews bouncing fireballs.' } return } if (enemy.phase === 'mauling') { enemy.targetId = 'tank' const tank = findTarget(state, 'tank') ?? state.player driftTowardTarget(state, enemy, tank, deltaSeconds) enemy.tankSwingTimer -= deltaSeconds state.message = `Yian Kut-Ku pecks ${tank.name}.` if (enemy.tankSwingTimer <= 0) { applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) tank.invulnerableTimer = 0.12 state.lastHit = tank.id === 'player' ? 'player' : 'party' enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) } if (enemy.phaseTimer <= 0) { enemy.phase = 'tracking' enemy.phaseTimer = getTrackSeconds(enemy.kind) } return } if (enemy.phase === 'recovering') { state.message = 'Yian Kut-Ku recovers from the fireball spray.' if (enemy.phaseTimer <= 0) { enemy.phase = 'mauling' enemy.phaseTimer = getTankFocusSeconds(enemy.kind) enemy.tankSwingTimer = 0.35 } } } function spawnYianFireballs(state: BulldromeState, enemy: EnemyState) { const fireballAttack = getEnabledActionAttack(enemy.kind, 'fireballVolley') if (!fireballAttack) return const directions = [ { x: 0.72, y: -0.7 }, { x: -0.72, y: -0.7 }, ] for (const direction of directions) { state.fireballs.push({ id: `fireball-${state.nextHazardId}`, x: enemy.x, y: enemy.y, radius: 13, vx: direction.x * (fireballAttack.speed ?? FIREBALL_SPEED), vy: direction.y * (fireballAttack.speed ?? FIREBALL_SPEED), bouncesRemaining: 9999, }) state.nextHazardId += 1 } } function updateBird(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { enemy.telegraph.active = false enemy.slamTelegraph.active = false const diveAttack = getEnabledActionAttack(enemy.kind, 'birdDive') if (enemy.phase === 'mauling' || enemy.phase === 'tracking') { enemy.flightStep = 'ground' enemy.targetId = 'tank' const tank = findTarget(state, 'tank') ?? state.player driftTowardTarget(state, enemy, tank, deltaSeconds) enemy.tankSwingTimer -= deltaSeconds state.message = `${enemy.name} claws ${tank.name}.` if (enemy.tankSwingTimer <= 0) { applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) tank.invulnerableTimer = 0.12 state.lastHit = tank.id === 'player' ? 'player' : 'party' enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) } if (enemy.phaseTimer <= 0) { if (diveAttack) startBirdFlight(state, enemy) else enemy.phaseTimer = getTankFocusSeconds(enemy.kind) } return } if (enemy.phase === 'windup') { enemy.flightStep = 'toLeft' moveEnemyTowardPoint(state, enemy, { x: state.arena.padding + enemy.radius, y: enemy.flightY, }, getChargeSpeed(enemy.kind) * deltaSeconds) state.message = `${enemy.name} lifts off.` if (enemy.x <= state.arena.padding + enemy.radius + 3) { enemy.phase = 'charging' enemy.flightStep = 'across' enemy.chargeVector = { x: 1, y: 0 } enemy.phaseTimer = 12 enemy.telegraph = { active: true, start: { x: state.arena.padding, y: enemy.y }, end: { x: state.arena.width - state.arena.padding, y: enemy.y }, width: 42, } } return } if (enemy.phase === 'charging') { enemy.flightStep = 'across' enemy.telegraph.active = true enemy.x += getChargeSpeed(enemy.kind) * deltaSeconds enemy.y = enemy.flightY clampToArena(state, enemy) state.message = `${enemy.name} dives across the arena.` if (enemy.x >= state.arena.width - state.arena.padding - enemy.radius - 3) { enemy.phase = 'recovering' enemy.flightStep = 'toTank' enemy.telegraph.active = false } return } if (enemy.phase === 'recovering') { enemy.flightStep = 'toTank' const tank = findTarget(state, 'tank') ?? state.player moveEnemyTowardPoint(state, enemy, tank, getChargeSpeed(enemy.kind) * deltaSeconds) state.message = `${enemy.name} returns to ${tank.name}.` if (distance(enemy, tank) <= enemy.radius + tank.radius + 18) { enemy.phase = 'mauling' enemy.flightStep = 'ground' enemy.phaseTimer = getTankFocusSeconds(enemy.kind) enemy.tankSwingTimer = 0.35 } } } function updateCyberDragon(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { updateSpinningBladeSpawner(state, enemy, deltaSeconds) const circularCharge = getEnabledActionAttack(enemy.kind, 'circularCharge') enemy.slamTelegraph.active = false if (enemy.phase === 'tracking' || enemy.phase === 'mauling') { enemy.telegraph.active = false enemy.arcTelegraph.active = false enemy.targetId = getTankTargetId(state, enemy) const tank = findTarget(state, enemy.targetId) ?? state.player driftTowardTarget(state, enemy, tank, deltaSeconds) enemy.tankSwingTimer -= deltaSeconds state.message = `Cyber Dragon claws ${tank.name}.` if (enemy.tankSwingTimer <= 0) { applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) tank.invulnerableTimer = 0.12 state.lastHit = tank.id === 'player' ? 'player' : 'party' enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) } if (enemy.phaseTimer <= 0) { if (circularCharge) startCircularCharge(state, enemy) else enemy.phaseTimer = getTankFocusSeconds(enemy.kind) } return } if (enemy.phase === 'circling') { enemy.telegraph.active = false enemy.orbitAngle += Math.PI * 1.7 * deltaSeconds const center = getArenaCenter(state) const radius = circularCharge?.radius ?? CYBER_CIRCLE_RADIUS syncCircularChargeTelegraph(enemy, center, radius) enemy.x = center.x + Math.cos(enemy.orbitAngle) * radius enemy.y = center.y + Math.sin(enemy.orbitAngle) * radius clampToArena(state, enemy) state.message = 'Cyber Dragon circles the room.' if (enemy.phaseTimer <= 0) startCyberChargeWindup(state, enemy) return } if (enemy.phase === 'windup') { enemy.arcTelegraph.active = false enemy.telegraph.active = true state.message = 'Cyber Dragon locks a charge lane.' if (enemy.phaseTimer <= 0) { enemy.phase = 'charging' enemy.phaseTimer = CYBER_CHARGE_SECONDS state.message = 'Cyber Dragon charges.' } return } if (enemy.phase === 'charging') { enemy.arcTelegraph.active = false enemy.telegraph.active = true enemy.x += enemy.chargeVector.x * getChargeSpeed(enemy.kind) * deltaSeconds enemy.y += enemy.chargeVector.y * getChargeSpeed(enemy.kind) * deltaSeconds clampToArena(state, enemy) if (enemy.phaseTimer <= 0 || hitArenaWall(state, enemy)) { enemy.phase = 'recovering' enemy.phaseTimer = getRecoverSeconds(enemy.kind) enemy.telegraph.active = false state.message = 'Cyber Dragon vents heat.' } return } if (enemy.phase === 'recovering') { enemy.telegraph.active = false enemy.arcTelegraph.active = false state.message = 'Cyber Dragon vents heat.' if (enemy.phaseTimer <= 0) { enemy.phase = 'mauling' enemy.phaseTimer = getTankFocusSeconds(enemy.kind) enemy.tankSwingTimer = 0.35 } } } function updateSpinningBladeSpawner(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { const attack = getEnabledActionAttack(enemy.kind, 'spinningBlade') if (!attack || enemy.hp <= 0) return const timer = (enemy.mechanicTimers.spinningBlade ?? attack.frequencySeconds) - deltaSeconds if (timer > 0) { enemy.mechanicTimers.spinningBlade = timer return } spawnSpinningBlade(state, enemy, attack) enemy.mechanicTimers.spinningBlade = attack.frequencySeconds } function spawnSpinningBlade(state: BulldromeState, enemy: EnemyState, attack: NonNullable>) { const target = chooseRandomLivingTarget(state) if (!target) return state.spinningBlades.push({ id: `spinning-blade-${state.nextHazardId}`, sourceKind: enemy.kind, x: enemy.x, y: enemy.y, radius: attack.radius ?? CYBER_BLADE_RADIUS, speed: attack.speed ?? CYBER_BLADE_SPEED, remaining: attack.recoverSeconds ?? attack.durationSeconds ?? CYBER_BLADE_SECONDS, targetId: target.id, rotation: 0, }) state.nextHazardId += 1 state.message = `Spinning blade targets ${target.name}.` } function updateSpinningBlades(state: BulldromeState, deltaSeconds: number) { const nextBlades: SpinningBladeState[] = [] for (const blade of state.spinningBlades) { const attack = getEnabledActionAttack(blade.sourceKind, 'spinningBlade') if (!attack) continue const next = { ...blade, remaining: blade.remaining - deltaSeconds, rotation: blade.rotation + deltaSeconds * 9, } if (next.remaining <= 0) continue let target = findTarget(state, next.targetId) if (!target || target.hp <= 0) { target = chooseRandomLivingTarget(state) if (target) next.targetId = target.id } if (target) { const dx = target.x - next.x const dy = target.y - next.y const length = Math.hypot(dx, dy) if (length > 1) { const move = Math.min(length, next.speed * deltaSeconds) next.x += (dx / length) * move next.y += (dy / length) * move } } for (const unit of getTargetableUnits(state)) { if (unit.hp <= 0 || distance(unit, next) > unit.radius + next.radius) continue applyDamage(unit, scaleDamageForDifficulty(state, attack.damage) * deltaSeconds) applyBleed(unit) state.lastHit = unit.id === 'player' ? 'player' : 'party' state.message = `${unit.name} is clipped by a spinning blade.` } nextBlades.push(next) } state.spinningBlades = nextBlades } function applyBleed(unit: TargetableState) { unit.bleedTimer = CYBER_BLEED_SECONDS unit.bleedTickTimer = Math.max(0.15, Math.min(unit.bleedTickTimer || CYBER_BLEED_TICK_SECONDS, CYBER_BLEED_TICK_SECONDS)) } function chooseRandomLivingTarget(state: BulldromeState) { const living = getTargetableUnits(state).filter((unit) => unit.hp > 0) if (living.length <= 0) return undefined return living[Math.floor(Math.random() * living.length)] } function startCircularCharge(state: BulldromeState, enemy: EnemyState) { const attack = getEnabledActionAttack(enemy.kind, 'circularCharge') const center = getArenaCenter(state) enemy.phase = 'circling' enemy.phaseTimer = attack?.windupSeconds ?? CYBER_CIRCLE_SECONDS enemy.orbitAngle = Math.atan2(enemy.y - center.y, enemy.x - center.x) enemy.telegraph.active = false syncCircularChargeTelegraph(enemy, center, attack?.radius ?? CYBER_CIRCLE_RADIUS) state.message = 'Cyber Dragon starts a circular charge.' } function syncCircularChargeTelegraph(enemy: EnemyState, center: Point, radius: number) { enemy.arcTelegraph = { active: true, center, radius, startAngle: enemy.orbitAngle, endAngle: enemy.orbitAngle + Math.PI, width: 72, } } function startCyberChargeWindup(state: BulldromeState, enemy: EnemyState) { const target = selectNearestTarget(state, enemy) ?? state.player const dx = target.x - enemy.x const dy = target.y - enemy.y const length = Math.max(1, Math.hypot(dx, dy)) const chargeVector = { x: dx / length, y: dy / length } const end = projectToArenaWall(state, enemy, chargeVector) enemy.phase = 'windup' enemy.phaseTimer = 0.42 enemy.targetId = target.id enemy.chargeVector = chargeVector enemy.telegraph = { active: true, start: { x: enemy.x, y: enemy.y }, end, width: 72, } enemy.chargeCount += 1 } function selectNearestTarget(state: BulldromeState, enemy: EnemyState) { return getTargetableUnits(state) .filter((unit) => unit.hp > 0) .sort((a, b) => distance(a, enemy) - distance(b, enemy))[0] ?? null } function getArenaCenter(state: BulldromeState): Point { return { x: state.arena.width / 2, y: state.arena.height / 2 } } function startBirdFlight(state: BulldromeState, enemy: EnemyState) { enemy.phase = 'windup' enemy.flightStep = 'toLeft' enemy.telegraph.active = true enemy.telegraph = { active: true, start: { x: state.arena.padding, y: enemy.flightY }, end: { x: state.arena.width - state.arena.padding, y: enemy.flightY }, width: 42, } state.message = `${enemy.name} starts a dive path.` } function startTankFocus(enemy: EnemyState) { enemy.phase = 'mauling' enemy.phaseTimer = getTankFocusSeconds(enemy.kind) enemy.targetId = 'tank' enemy.tankSwingTimer = 0.35 enemy.telegraph.active = false enemy.slamTelegraph.active = false } function startSlam(enemy: EnemyState) { const slam = getEnabledActionAttack(enemy.kind, 'groundSlam') enemy.phase = 'slamWindup' enemy.phaseTimer = slam?.windupSeconds ?? BOSS_SLAM_CAST_SECONDS enemy.slamTelegraph.radius = slam?.radius ?? BOSS_SLAM_RADIUS enemy.slamTelegraph.active = true enemy.telegraph.active = false } function resolveGroundSlam(state: BulldromeState, enemy: EnemyState) { const slam = getEnabledActionAttack(enemy.kind, 'groundSlam') if (!slam) return let hitCount = 0 for (const unit of getTargetableUnits(state)) { if (unit.hp <= 0 || distance(unit, enemy) > (slam.radius ?? BOSS_SLAM_RADIUS)) continue applyDamage(unit, scaleDamageForDifficulty(state, slam.damage)) unit.stunTimer = 0.35 unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds hitCount += 1 } state.lastHit = hitCount > 0 ? 'party' : null state.message = hitCount > 0 ? `Ground slam hit ${hitCount}.` : 'Ground slam missed.' } function resolvePartyDamage(state: BulldromeState, deltaSeconds: number) { if (state.result !== 'playing') return for (const member of state.party) { if (member.hp <= 0 || member.stunTimer > 0) continue const target = getAiTargetForMember(state, member) if (!target) continue const enemyDistance = distance(member, target) let damage = 0 if (member.role === 'tank' && enemyDistance <= target.radius + 58) damage = TANK_DPS * deltaSeconds if (member.role === 'melee' && enemyDistance <= target.radius + 72) damage = MELEE_DPS * deltaSeconds if (member.role === 'ranged' && enemyDistance <= RANGED_ATTACK_RANGE) damage = RANGED_DPS * deltaSeconds if (damage <= 0) continue damageEnemy(state, target, damage, member.id) if (target.hp <= 0) { target.phase = 'defeated' target.telegraph.active = false target.slamTelegraph.active = false state.message = `${target.name} defeated.` } } } function updateFireHazards(state: BulldromeState, deltaSeconds: number) { state.fireSpots = state.fireSpots .map((spot) => ({ ...spot, remaining: spot.remaining - deltaSeconds })) .filter((spot) => spot.remaining > 0) const nextFireballs: FireballState[] = [] for (const fireball of state.fireballs) { const next = { ...fireball } next.x += next.vx * deltaSeconds next.y += next.vy * deltaSeconds let bounced = false const minX = state.arena.padding + next.radius const maxX = state.arena.width - state.arena.padding - next.radius const minY = state.arena.padding + next.radius const maxY = state.arena.height - state.arena.padding - next.radius if (next.x <= minX || next.x >= maxX) { next.x = Math.max(minX, Math.min(maxX, next.x)) next.vx *= -1 bounced = true } if (next.y <= minY || next.y >= maxY) { next.y = Math.max(minY, Math.min(maxY, next.y)) next.vy *= -1 bounced = true } if (bounced) { addFireSpot(state, next.x, next.y) } for (const unit of getTargetableUnits(state)) { if (unit.hp <= 0 || unit.invulnerableTimer > 0 || distance(unit, next) > unit.radius + next.radius) continue applyDamage(unit, scaleDamageForDifficulty(state, getAttackDamage('yian-kut-ku', 'fireballVolley', FIREBALL_DAMAGE))) igniteUnit(unit) addFireSpot(state, next.x, next.y) bounceFireballOffUnit(next, unit) unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds state.lastHit = unit.id === 'player' ? 'player' : 'party' state.message = `${unit.name} was hit by a fireball.` } nextFireballs.push(next) } state.fireballs = nextFireballs for (const spot of state.fireSpots) { for (const unit of getTargetableUnits(state)) { if (unit.hp <= 0 || unit.burnTimer > 0 || distance(unit, spot) > unit.radius + spot.radius) continue igniteUnit(unit) state.lastHit = unit.id === 'player' ? 'player' : 'party' state.message = `${unit.name} is standing in fire.` } } } function bounceFireballOffUnit(fireball: FireballState, unit: TargetableState) { const dx = fireball.x - unit.x const dy = fireball.y - unit.y const length = Math.max(1, Math.hypot(dx, dy)) const nx = dx / length const ny = dy / length const dot = fireball.vx * nx + fireball.vy * ny fireball.vx -= 2 * dot * nx fireball.vy -= 2 * dot * ny fireball.x = unit.x + nx * (unit.radius + fireball.radius + 2) fireball.y = unit.y + ny * (unit.radius + fireball.radius + 2) } function addFireSpot(state: BulldromeState, x: number, y: number) { state.fireSpots.push({ id: `firespot-${state.nextHazardId}`, x, y, radius: 34, remaining: FIRE_SPOT_SECONDS, }) state.nextHazardId += 1 if (state.fireSpots.length > MAX_FIRE_SPOTS) { state.fireSpots = state.fireSpots.slice(-MAX_FIRE_SPOTS) } } function igniteUnit(unit: TargetableState) { unit.burnTimer = FIRE_DOT_SECONDS unit.burnTickTimer = FIRE_DOT_TICK_SECONDS } function getAiTargetForMember(state: BulldromeState, member: PartyMemberState): EnemyState | null { const enemies = getLivingEnemies(state) if (enemies.length === 0) return null if (member.role === 'tank') return getPrimaryTankEnemy(state) void member return state.boss.hp > 0 ? state.boss : enemies[0] } function getPrimaryTankEnemy(state: BulldromeState): EnemyState | null { const enemies = getLivingEnemies(state) return state.boss.hp > 0 ? state.boss : enemies[0] ?? null } function getTankTargetId(state: BulldromeState, enemy: EnemyState) { const tank = findTarget(state, 'tank') return tank && tank.hp > 0 ? tank.id : selectChargeTarget(state, enemy) } function driftTowardTarget( state: BulldromeState, enemy: EnemyState, target: TargetableState, deltaSeconds: number, ) { const dx = target.x - enemy.x const dy = target.y - enemy.y const length = Math.hypot(dx, dy) if (length <= 1) return enemy.chargeVector = { x: dx / length, y: dy / length } const driftSpeed = (enemy.kind === 'bulldrome' ? 82 : enemy.kind === 'cyber-dragon' ? 74 : enemy.kind === 'yian-kut-ku' ? 72 : 62) * deltaSeconds enemy.x += (dx / length) * driftSpeed enemy.y += (dy / length) * driftSpeed clampToArena(state, enemy) } function moveEnemyTowardPoint(state: BulldromeState, enemy: EnemyState, point: Point, maxDistance: number) { const dx = point.x - enemy.x const dy = point.y - enemy.y const length = Math.hypot(dx, dy) if (length <= 2) return const distanceThisFrame = Math.min(length, maxDistance) enemy.x += (dx / length) * distanceThisFrame enemy.y += (dy / length) * distanceThisFrame clampToArena(state, enemy) } function startWindup(state: BulldromeState, enemy: EnemyState) { const target = findTarget(state, enemy.targetId) ?? state.player const dx = target.x - enemy.x const dy = target.y - enemy.y const length = Math.max(1, Math.hypot(dx, dy)) const chargeVector = { x: dx / length, y: dy / length } const end = projectToArenaWall(state, enemy, chargeVector) enemy.phase = 'windup' enemy.phaseTimer = getWindupSeconds(enemy.kind) enemy.chargeVector = chargeVector enemy.telegraph = { active: true, start: { x: enemy.x, y: enemy.y }, end, width: enemy.kind === 'bulldrome' ? 86 : 48, } enemy.chargeCount += 1 } function selectChargeTarget(state: BulldromeState, enemy: EnemyState) { const living = getTargetableUnits(state).filter((unit) => unit.hp > 0) const preferred = enemy.kind === 'bulldrome' || enemy.kind === 'cyber-dragon' ? ['player', 'tank', 'melee-1', 'ranged-1', 'ranged-2'] : ['tank', 'melee-1', 'player', 'ranged-1', 'ranged-2'] const offset = enemy.chargeCount % preferred.length const ordered = preferred.slice(offset).concat(preferred.slice(0, offset)) return ordered.find((id) => living.some((unit) => unit.id === id)) ?? 'player' } function resolveContact(state: BulldromeState) { for (const enemy of getLivingEnemies(state)) { for (const unit of getTargetableUnits(state)) { const overlap = unit.hp > 0 && distance(unit, enemy) < unit.radius + enemy.radius if (!overlap || unit.invulnerableTimer > 0) continue const isCharge = enemy.phase === 'charging' || enemy.phase === 'circling' applyDamage(unit, scaleDamageForDifficulty(state, isCharge ? getChargeDamage(enemy.kind) : getBodyDamage(enemy.kind))) unit.stunTimer = isCharge && enemy.kind === 'cyber-dragon' ? CYBER_CHARGE_STUN_SECONDS : isCharge ? ACTION_CONTROL_RULES.stunSeconds : 0.25 unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds state.lastHit = unit.id === 'player' ? 'player' : 'party' state.message = isCharge ? `${unit.name} ate ${enemy.name}'s charge.` : `${unit.name} was trampled.` const dx = unit.x - enemy.x const dy = unit.y - enemy.y const length = Math.max(1, Math.hypot(dx, dy)) unit.x += (dx / length) * 34 unit.y += (dy / length) * 34 clampToArena(state, unit) } } } function getDungeonSpell(state: BulldromeState, spell: SpellSlot): SpellDefinition { return getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers) } function startPlayerSpell(state: BulldromeState, spell: SpellSlot | null) { if (!spell || state.player.currentCast || state.player.globalCooldown > 0 || state.player.spellCooldowns[spell] > 0 || state.player.stunTimer > 0) return const target = findCombatTarget(state, state.targetId) if (!target || target.hp <= 0) { state.message = 'No living target selected.' return } const damageSpell = isDungeonDamageSpell(state, spell) const enemyTarget = isEnemyTarget(state, target) if (damageSpell && !enemyTarget) { showNotice(state, state.player.id, 'Enemy target') state.message = `${getDungeonSpell(state, spell).name} requires an enemy target.` return } if (!damageSpell && enemyTarget) { showNotice(state, state.player.id, 'Ally target') state.message = `${getDungeonSpell(state, spell).name} requires a friendly target.` return } const reach = getDungeonSpellReach(state, target, spell, { includeTargetRadius: damageSpell }) if (!reach.ok) { setDungeonSpellReachFailure(state, reach) return } const spend = spendActionSpellMana(state.player, state.player.classId, spell, { talentMods: state.player.talentModifiers }) if (!spend.ok) { showNotice(state, state.player.id, 'Mana') state.message = `Need ${spend.cost} mana.` return } const definition = getDungeonSpell(state, spell) triggerPlayerGlobalCooldown(state) if (definition.castTime > 0) { state.player.currentCast = { spell, targetId: target.id, remaining: definition.castTime, total: definition.castTime, } state.message = `Casting ${definition.name} on ${target.name}.` return } completePlayerSpell(state, spell, target.id) } function completePlayerSpell(state: BulldromeState, spell: SpellSlot, targetId: string) { const target = findCombatTarget(state, targetId) if (!target || target.hp <= 0) { state.message = 'Spell fizzled. Target is down.' return } const damageSpell = isDungeonDamageSpell(state, spell) const enemyTarget = isEnemyTarget(state, target) if (damageSpell && !enemyTarget) { state.message = `${getDungeonSpell(state, spell).name} requires an enemy target.` return } if (!damageSpell && enemyTarget) { state.message = `${getDungeonSpell(state, spell).name} requires a friendly target.` return } const reach = getDungeonSpellReach(state, target, spell, { includeTargetRadius: damageSpell }) if (!reach.ok) { setDungeonSpellReachFailure(state, reach) return } state.player.spellCooldowns[spell] = getDungeonSpell(state, spell).cooldown recordSpellCast(state.player) if (damageSpell) { completePlayerDamageSpell(state, spell, target as EnemyState) return } const friendlyTarget = target as TargetableState const ability = getArenaAbility(getDungeonSpell(state, spell).abilityId) if (ability.shield || ability.kind === 'buff') { const shield = getActionTalentAdjustedAmount(state.player.classId, spell, ability.shield ?? (ability.id === 'blessing_of_might' ? 80 : 90), 'shield', state.player.talentModifiers) addShield(friendlyTarget, shield, { cap: Math.max(shield, friendlyTarget.maxHp) }) addHealEvent(state, friendlyTarget, 0) state.lastHit = 'heal' state.message = `${ability.name} protected ${friendlyTarget.name}.` return } const healOverTimeId = getHealOverTimeEffectId(ability.id) if (healOverTimeId) { applyHealOverTimeTimers(friendlyTarget, healOverTimeId) healTarget(state, friendlyTarget, getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 28, 'heal', state.player.talentModifiers)) state.lastHit = 'heal' state.message = `${ability.name} on ${friendlyTarget.name}.` return } healTarget(state, friendlyTarget, getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 120, 'heal', state.player.talentModifiers)) addHealEvent(state, friendlyTarget, 0) state.lastHit = 'heal' state.message = `${ability.name} healed ${friendlyTarget.name}.` } function completePlayerDamageSpell(state: BulldromeState, spell: SpellSlot, target: EnemyState) { const damage = getDungeonDamageSpellAmount(state, spell) damageEnemy(state, target, damage, state.player.id) state.lastHit = 'boss' const spellDef = getDungeonSpell(state, spell) state.message = `${spellDef.name} hit ${target.name}.` if (spellDef.abilityId !== 'smite') return const ally = getTargetableUnits(state) .filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && isInHealRange(state, unit, spell)) .sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0] if (!ally) return healTarget(state, ally, damage * 0.5) addHealEvent(state, ally, 0) state.lastHit = 'heal' state.message = `Smite hit ${target.name} and healed ${ally.name}.` } function getDungeonDamageSpellAmount(state: BulldromeState, spell: SpellSlot) { const ability = getArenaAbility(getDungeonSpell(state, spell).abilityId) const dotPulse = ability.dot ? ability.dot.damage / Math.max(1, ability.dot.seconds / 3) : 0 return getActionTalentAdjustedAmount(state.player.classId, spell, (ability.damage ?? 0) + dotPulse, 'damage', state.player.talentModifiers) } function isDungeonDamageSpell(state: BulldromeState, spell: SpellSlot) { return getActionSpellTarget(state.player.classId, spell) === 'Enemy target' } function healTarget(state: BulldromeState, target: TargetableState, amount: number) { const before = target.hp target.hp = Math.min(target.maxHp, target.hp + amount) const healed = target.hp - before if (healed <= 0) return addHealEvent(state, target, healed) } function addHealEvent(state: BulldromeState, target: TargetableState, amount: number) { if (state.healEvents.some((event) => event.targetId === target.id)) return state.healEvents.push({ id: `${state.elapsed.toFixed(3)}-${target.id}-${state.healEvents.length}`, targetId: target.id, amount, }) } function isInHealRange(state: BulldromeState, target: TargetableState, spell: SpellSlot) { return getDungeonSpellReach(state, target, spell).ok } function getDungeonSpellReach( state: BulldromeState, target: CombatSpellTarget, spell: SpellSlot, options?: { includeTargetRadius?: boolean }, ) { return getActionSpellReach({ caster: state.player, target, classId: state.player.classId, spell, talentMods: state.player.talentModifiers, includeTargetRadius: options?.includeTargetRadius, hasLineOfSight: () => hasDungeonLineOfSight(), }) } function hasDungeonLineOfSight() { return true } function setDungeonSpellReachFailure(state: BulldromeState, reach: ActionSpellReachResult) { if (reach.ok) return const blocked = reach.reason === 'lineOfSight' showNotice(state, state.player.id, blocked ? 'Blocked' : 'Range') state.message = blocked ? 'Line of sight blocked.' : 'Not in range.' } function showNotice(state: BulldromeState, targetId: string, text: string) { state.noticeEvents.push({ id: `${state.elapsed.toFixed(3)}-${targetId}-${state.noticeEvents.length}-${text}`, targetId, text, }) } function createDamageDoneTracker(): Record { return { player: 0, tank: 0, 'melee-1': 0, 'ranged-1': 0, 'ranged-2': 0, } } function damageEnemy(state: BulldromeState, target: EnemyState, amount: number, sourceId: string) { const before = target.hp target.hp = Math.max(0, target.hp - amount) const dealt = Math.max(0, before - target.hp) if (dealt > 0) state.damageDoneByUnit[sourceId] = (state.damageDoneByUnit[sourceId] ?? 0) + dealt } function applyDamage(unit: TargetableState, amount: number) { applyShieldedDamage(unit, amount) } function getDamagingEffectAvoidance(state: BulldromeState, member: PartyMemberState): { point: Point, moveSpeed: number } | null { let escapeX = 0 let escapeY = 0 let moveSpeed = AI_MOVE_SPEED let avoidDistance = 1 const fireAreaAvoidance = getActionAiAvoidance('yian-kut-ku', 'fireballVolley', 'persistentArea') for (const spot of state.fireSpots) { if (!fireAreaAvoidance) continue const avoidRadius = member.radius + spot.radius + fireAreaAvoidance.buffer addRepulsionFromPoint(member, spot, fireAreaAvoidance, (avoidRadius - distance(member, spot)) / avoidRadius) } const fireballAvoidance = getActionAiAvoidance('yian-kut-ku', 'fireballVolley', 'projectile') for (const fireball of state.fireballs) { if (!fireballAvoidance) continue const avoidRadius = member.radius + fireball.radius + fireballAvoidance.buffer addRepulsionFromPoint(member, fireball, fireballAvoidance, (avoidRadius - distance(member, fireball)) / avoidRadius) } for (const blade of state.spinningBlades) { const bladeAvoidance = getActionAiAvoidance(blade.sourceKind, 'spinningBlade', 'persistentArea') if (!bladeAvoidance) continue const avoidRadius = member.radius + blade.radius + bladeAvoidance.buffer addRepulsionFromPoint(member, blade, bladeAvoidance, (avoidRadius - distance(member, blade)) / avoidRadius) } for (const enemy of getLivingEnemies(state)) { const slamAvoidance = getActionAiAvoidance(enemy.kind, 'groundSlam', 'circleTelegraph') if (enemy.slamTelegraph.active) { if (slamAvoidance) { const avoidRadius = enemy.slamTelegraph.radius + member.radius + slamAvoidance.buffer addRepulsionFromPoint(member, enemy, slamAvoidance, (avoidRadius - distance(member, enemy)) / avoidRadius) } } const lineAttackKind: ActionAttackKind = enemy.kind === 'bird' ? 'birdDive' : enemy.kind === 'cyber-dragon' ? 'circularCharge' : 'charge' const lineAvoidance = getActionAiAvoidance(enemy.kind, lineAttackKind, 'lineTelegraph') if ( lineAvoidance && enemy.telegraph.active && enemy.phase === 'windup' && isPointNearSegment( member, enemy.telegraph.start, enemy.telegraph.end, enemy.telegraph.width * (lineAvoidance.lineWidthMultiplier ?? 0.42) + lineAvoidance.buffer, ) ) { const perpendicular = { x: -enemy.chargeVector.y, y: enemy.chargeVector.x } const side = member.aiSlot % 2 === 0 ? 1 : -1 escapeX += perpendicular.x * side escapeY += perpendicular.y * side moveSpeed = Math.max(moveSpeed, lineAvoidance.moveSpeed) avoidDistance = Math.max(avoidDistance, lineAvoidance.avoidDistance) } } const escapeLength = Math.hypot(escapeX, escapeY) if (escapeLength <= 0.01) return null return { point: clampPointToArena(state, { x: member.x + (escapeX / escapeLength) * avoidDistance, y: member.y + (escapeY / escapeLength) * avoidDistance, }, member.radius), moveSpeed, } function addRepulsionFromPoint( unit: TargetableState, hazard: Point, avoidance: ActionAiAvoidanceConfig, weight: number, ) { if (weight <= 0) return moveSpeed = Math.max(moveSpeed, avoidance.moveSpeed) avoidDistance = Math.max(avoidDistance, avoidance.avoidDistance) const dx = unit.x - hazard.x const dy = unit.y - hazard.y const length = Math.hypot(dx, dy) if (length <= 0.01) { const angle = member.aiSlot * 1.7 escapeX += Math.cos(angle) * weight escapeY += Math.sin(angle) * weight return } escapeX += (dx / length) * weight escapeY += (dy / length) * weight } } function isPointNearSegment(point: Point, start: Point, end: Point, width: number) { const dx = end.x - start.x const dy = end.y - start.y const lengthSquared = dx * dx + dy * dy if (lengthSquared <= 0) return distance(point, start) <= width const t = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared)) const projected = { x: start.x + dx * t, y: start.y + dy * t, } return distance(point, projected) <= width } function resolveResult(state: BulldromeState) { if (state.respawnTimer > 0) return const livingEnemies = getLivingEnemies(state) if (livingEnemies.length <= 0) { if (state.runMode === 'marathon') { state.bossKills += 1 state.respawnTimer = 5 state.fireballs = [] state.fireSpots = [] state.spinningBlades = [] state.damageDoneByUnit = createDamageDoneTracker() state.message = `Boss ${state.bossKills} down. Next boss in 5.` return } state.result = 'win' state.message = state.dungeonId === 'cyber-dragon' ? 'Cyber Dragon disabled. Hunt complete.' : state.dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku defeated. Hunt complete.' : 'Bulldrome broken. Hunt complete.' } if (state.player.hp <= 0) { for (const enemy of getLivingEnemies(state)) { enemy.phase = 'victory' enemy.telegraph.active = false enemy.slamTelegraph.active = false } state.result = 'loss' state.message = 'You were carted. Reset and keep yourself alive.' } } function respawnBossEncounter(state: BulldromeState) { state.encounterStep = 'boss' if (state.dungeonId === 'cyber-dragon') { state.boss = createCyberDragon() state.adds = [] scaleEnemyForDifficulty(state.difficulty, state.boss) state.message = 'Marathon continues. Cyber Dragon reboots.' } else if (state.dungeonId === 'yian-kut-ku') { state.boss = createYianKutKu() state.adds = [] scaleEnemyForDifficulty(state.difficulty, state.boss) state.message = 'Marathon continues. Yian Kut-Ku returns.' } else { state.boss = createBulldrome() state.adds = [] scaleEnemyForDifficulty(state.difficulty, state.boss) state.message = 'Marathon continues. Bulldrome returns.' } state.fireballs = [] state.fireSpots = [] state.spinningBlades = [] state.respawnTimer = 0 } function findTarget(state: BulldromeState, id: string) { return getTargetableUnits(state).find((unit) => unit.id === id) } function findEnemyTarget(state: BulldromeState, id: string) { return getAllEnemies(state).find((enemy) => enemy.id === id) } function findCombatTarget(state: BulldromeState, id: string): CombatSpellTarget | undefined { return findTarget(state, id) ?? findEnemyTarget(state, id) } function isEnemyTarget(state: BulldromeState, target: CombatSpellTarget): target is EnemyState { return getAllEnemies(state).some((enemy) => enemy.id === target.id) } function setTarget(state: BulldromeState, id: string) { const target = findCombatTarget(state, id) if (target) state.targetId = target.id } function cycleTarget(state: BulldromeState, delta: -1 | 1) { const frames = getRaidFrames(state) const currentIndex = Math.max(0, frames.findIndex((frame) => frame.id === state.targetId)) const nextIndex = (currentIndex + delta + frames.length) % frames.length state.targetId = frames[nextIndex].id } function clampToArena(state: BulldromeState, fighter: FighterState) { fighter.x = Math.max( state.arena.padding + fighter.radius, Math.min(state.arena.width - state.arena.padding - fighter.radius, fighter.x), ) fighter.y = Math.max( state.arena.padding + fighter.radius, Math.min(state.arena.height - state.arena.padding - fighter.radius, fighter.y), ) } function clampPointToArena(state: BulldromeState, point: Point, radius: number): Point { const clamped = { x: Math.max( state.arena.padding + radius, Math.min(state.arena.width - state.arena.padding - radius, point.x), ), y: Math.max( state.arena.padding + radius, Math.min(state.arena.height - state.arena.padding - radius, point.y), ), } return clamped } function hitArenaWall(state: BulldromeState, fighter: FighterState) { return ( fighter.x <= state.arena.padding + fighter.radius + 1 || fighter.x >= state.arena.width - state.arena.padding - fighter.radius - 1 || fighter.y <= state.arena.padding + fighter.radius + 1 || fighter.y >= state.arena.height - state.arena.padding - fighter.radius - 1 ) } function projectToArenaWall(state: BulldromeState, start: Point, vector: Point): Point { const minX = state.arena.padding const maxX = state.arena.width - state.arena.padding const minY = state.arena.padding const maxY = state.arena.height - state.arena.padding const candidates: number[] = [] if (vector.x > 0) candidates.push((maxX - start.x) / vector.x) if (vector.x < 0) candidates.push((minX - start.x) / vector.x) if (vector.y > 0) candidates.push((maxY - start.y) / vector.y) if (vector.y < 0) candidates.push((minY - start.y) / vector.y) const distanceToWall = Math.min(...candidates.filter((candidate) => candidate > 0)) return { x: start.x + vector.x * distanceToWall, y: start.y + vector.y * distanceToWall, } } function getWindupSeconds(kind: EnemyKind) { return getEnemyWindupSeconds(kind) } function getChargeSeconds(kind: EnemyKind) { return getEnemyChargeSeconds(kind) } function getChargeSpeed(kind: EnemyKind) { return getEnemyChargeSpeed(kind) } function getRecoverSeconds(kind: EnemyKind) { return getEnemyRecoverSeconds(kind) } function getTankFocusSeconds(kind: EnemyKind) { return getEnemyTankFocusSeconds(kind) } function getTankSwingSeconds(kind: EnemyKind) { return getEnemyTankSwingSeconds(kind) } function getTankSwingDamage(kind: EnemyKind) { return getEnemyTankSwingDamage(kind) } function getChargeDamage(kind: EnemyKind) { return getEnemyChargeDamage(kind) } function getBodyDamage(kind: EnemyKind) { return getEnemyBodyDamage(kind) } function getTrackSeconds(kind: EnemyKind) { return getEnemyTrackSeconds(kind) } function getAttackFrequency(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) { return getActionAttack(kind, attackKind)?.frequencySeconds ?? fallback } function getAttackDamage(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) { const attack = getActionAttack(kind, attackKind) if (!attack) return fallback return attack.enabled ? attack.damage : 0 } function applyDifficultyScaling(state: BulldromeState) { scaleCurrentEnemiesForDifficulty(state) } function scaleCurrentEnemiesForDifficulty(state: BulldromeState) { for (const enemy of getAllEnemies(state)) { scaleEnemyForDifficulty(state.difficulty, enemy) } } function scaleEnemyForDifficulty(difficulty: ActionDifficulty, enemy: EnemyState) { const multiplier = getActionDifficultyTier(difficulty).healthMultiplier enemy.maxHp = Math.ceil(enemy.maxHp * multiplier) enemy.hp = enemy.maxHp } function scaleDamageForDifficulty(state: BulldromeState, damage: number) { return Math.ceil(damage * getActionDifficultyTier(state.difficulty).damageMultiplier) } function cloneEnemy(enemy: EnemyState): EnemyState { return { ...enemy, chargeVector: { ...enemy.chargeVector }, mechanicTimers: { ...enemy.mechanicTimers }, arcTelegraph: { active: enemy.arcTelegraph.active, center: { ...enemy.arcTelegraph.center }, radius: enemy.arcTelegraph.radius, startAngle: enemy.arcTelegraph.startAngle, endAngle: enemy.arcTelegraph.endAngle, width: enemy.arcTelegraph.width, }, slamTelegraph: { ...enemy.slamTelegraph }, telegraph: { active: enemy.telegraph.active, start: { ...enemy.telegraph.start }, end: { ...enemy.telegraph.end }, width: enemy.telegraph.width, }, } } function cloneState(state: BulldromeState): BulldromeState { return { dungeonId: state.dungeonId, difficulty: state.difficulty, runMode: state.runMode, encounterStep: state.encounterStep, arena: { ...state.arena }, player: { ...state.player, currentCast: state.player.currentCast ? { ...state.player.currentCast } : null, healOverTimes: state.player.healOverTimes?.map((effect) => ({ ...effect })), spellCooldowns: { ...state.player.spellCooldowns }, }, party: state.party.map((member) => ({ ...member, healOverTimes: member.healOverTimes?.map((effect) => ({ ...effect })), })), targetId: state.targetId, boss: cloneEnemy(state.boss), adds: state.adds.map(cloneEnemy), fireballs: state.fireballs.map((fireball) => ({ ...fireball })), fireSpots: state.fireSpots.map((spot) => ({ ...spot })), spinningBlades: state.spinningBlades.map((blade) => ({ ...blade })), nextHazardId: state.nextHazardId, damageDoneByUnit: { ...state.damageDoneByUnit }, bossKills: state.bossKills, dungeonWaveIndex: state.dungeonWaveIndex, dungeonWaveTotal: state.dungeonWaveTotal, respawnTimer: state.respawnTimer, healEvents: state.healEvents.map((event) => ({ ...event })), noticeEvents: state.noticeEvents.map((event) => ({ ...event })), elapsed: state.elapsed, message: state.message, result: state.result, lastHit: state.lastHit, } }