import { create } from "zustand";
import {
  advanceBossMechanics,
  createBossMotionState,
  createBossState,
  handleBossDispel,
  upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { bossDeathDespawnSeconds } from "./bossDeath";
import { normalizeEncounterBossIds } from "./bossSelection";
import { clampToArena, clampToHockeyArena, clampToHockeyEnemyHalf, clampToHockeyHealerHalf, constrainBossMotion } from "./arena";
import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data";
import { distance } from "./geometry";
import {
  createClassAbilityLoadout,
  createClassInventory,
  HEALER_ABILITIES,
  HEALER_CLASSES,
  resolveSlottedAbility,
} from "./healers";
import {
  advanceHealingEffects,
  chainHealIndexes,
  equalizeHealthPercentages,
  flourishParty,
  healingEffect,
  removeHealingEffect,
  resolveReactiveHealAfterDamage,
  setHealingEffect,
  triggerHealingEffects,
} from "./healerEffects";
import {
  createHealerMechanicState,
  expireHealerMechanics,
  healMostInjured,
  healTargetAndBeacon,
  placeOrRewindTimeAnchor,
  resolveTimeLoop,
  startTimeLoop,
} from "./healerMechanics";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
import {
  RUN_BUFFS,
  bossHealthMultiplier,
  compileRunModifiers,
  increaseRunBuffRank,
  runAbilityCastTime,
  runAbilityCooldown,
  runAbilityManaCost,
  selectRunBuffDraft,
  selectRandomBossPair,
  selectRogueTrialsBosses,
  selectUnseenBosses,
  ROGUE_TRIALS_TRIO_ROUND,
  type CompiledRunModifiers,
} from "./roguelike";
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
import { aiCombatModifiers, applyGearHealth, createBaseEncounterGearModifiers, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
import { passiveInfusionUnlocked } from "./progression/infusions";
import { DIFFICULTY_BY_SLUG, normalizeDifficultySlug, type DifficultySlug } from "./progression/loot";
import {
  HOCKEY_DEFAULT_PLAYER_POSITION,
  advanceHockeyHealing,
  createHockeyHealingState,
  setHockeyAim,
  type HockeyHealingState,
} from "./hockeyHealing";
import {
  HOCKEY_PVP_GOAL_DAMAGE,
  advanceHockeyPvpCpuGoalie,
  advanceHockeyPvpPuck,
  createHockeyPvpState,
  hockeyPvpHealingEffectiveness,
  hockeyPvpBossAt,
  mirrorHockeyPvpPuck,
  type HockeyPvpMatchConfig,
  type HockeyPvpRemoteSnapshot,
  type HockeyPvpState,
} from "./hockeyHealingPvp";
import type {
  ActiveCast,
  AbilityLoadout,
  AbilitySlotId,
  BarrierState,
  BossMotionState,
  BossState,
  BossId,
  BottomTab,
  GamePhase,
  GameplayActivity,
  HealerClassId,
  HealerMechanicState,
  InventoryItem,
  MemberId,
  PartyMember,
  RunBuffId,
  RunBuffRanks,
  RunMode,
  ScenePulse,
  WorldPosition,
} from "./types";
import { defaultGameplayActivity, isPvpRunMode } from "./runModes";
import {
  BLOCKBREAKER_BREACH_DAMAGE,
  advanceBlockbreaker,
  createBlockbreakerSeed,
  createBlockbreakerState,
  setBlockbreakerAim,
  type BlockbreakerState,
} from "./blockbreaker";
import {
  advanceAetherAssault,
  createAetherAssaultSeed,
  createAetherAssaultState,
  type AetherAssaultState,
} from "./aetherAssault";
import {
  assignRosterToCombatSlots,
  createRpgRoguelikeRun,
  reduceRpgRoguelikeRun,
  selectCurrentBossId,
  type ChallengeMetrics,
  type RpgRoguelikeAction,
  type RpgRoguelikeRunState,
} from "./rpgRoguelike";
import {
  extractRpgPlayerVital,
  extractRpgPartyVitals,
  projectRpgCombat,
  spellRankCooldownMultiplier,
  spellRankPowerMultiplier,
  type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter";
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";

export interface CombatLogEntry {
  id: number;
  time: number;
  message: string;
  tone: "good" | "danger" | "neutral";
}

export interface AdditionalBossState {
  instanceId: string;
  boss: BossState;
  motion: BossMotionState;
}

export interface HockeyPvpOpponentState {
  party: PartyMember[];
  partyPositions: Record<MemberId, WorldPosition>;
  boss: BossState;
  bossMotion: BossMotionState;
  partyCombat: PartyCombatState;
}

export interface RpgSpellResources {
  verdancy: number;
  tidalSurge: number;
  conviction: number;
  chronoshards: number;
}

export interface GameState {
  bossId: BossId;
  bossInstanceId: string;
  paused: boolean;
  pauseSelection: "resume" | "exit";
  healerClassId: HealerClassId;
  abilityLoadout: AbilityLoadout;
  playerName: string;
  phase: GamePhase;
  runMode: RunMode;
  activityMode: GameplayActivity;
  /** Authoritative, serializable state for the modular RPG Roguelike mode. */
  rpgRun: RpgRoguelikeRunState | null;
  /** Semantic focus target shared by both displays; never depends on DOM focus. */
  rpgFocusId: string | null;
  rpgDamageProfiles: RpgPartyDamageProfiles | null;
  rpgSpellResources: RpgSpellResources;
  round: number;
  seenBossIds: BossId[];
  endlessMode: boolean;
  endlessBossKills: number;
  endlessSpawnSequence: number;
  endlessChoiceSelection: "continue" | "quit";
  hockey: HockeyHealingState;
  blockbreaker: BlockbreakerState;
  aetherAssault: AetherAssaultState;
  hockeyPvp: HockeyPvpState;
  hockeyPvpOpponent: HockeyPvpOpponentState;
  runBuffRanks: RunBuffRanks;
  draftBuffIds: RunBuffId[];
  selectedRunBuffId: RunBuffId | null;
  runBuffInputUnlockAt: number;
  passiveRunBuffId: RunBuffId | null;
  runModifiers: CompiledRunModifiers;
  healingMultiplier: number;
  difficultySlug: DifficultySlug;
  difficultyDamageMultiplier: number;
  gearProgress: GearProgress;
  gearModifiers: EncounterGearModifiers;
  time: number;
  party: PartyMember[];
  boss: BossState;
  additionalBosses: AdditionalBossState[];
  partyPositions: Record<MemberId, WorldPosition>;
  bossMotion: BossMotionState;
  partyCombat: PartyCombatState;
  partyDamageEvents: PartyDamageEvent[];
  mana: number;
  maxMana: number;
  selectedMemberId: MemberId;
  cooldowns: Record<AbilitySlotId, number>;
  globalCooldownUntil: number;
  activeTab: BottomTab;
  selectedItemId: string;
  inventory: InventoryItem[];
  combatLog: CombatLogEntry[];
  scenePulse: ScenePulse;
  playerPosition: [number, number];
  activeCast: ActiveCast | null;
  barrier: BarrierState;
  healerMechanic: HealerMechanicState;
  configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => void;
  startEncounter: () => void;
  restart: () => void;
  tick: (delta: number) => void;
  castAbility: (abilitySlotId: AbilitySlotId) => boolean;
  selectMember: (memberId: MemberId) => void;
  cycleMember: (direction: 1 | -1) => void;
  setActiveTab: (tab: BottomTab) => void;
  selectItem: (itemId: string) => void;
  setPlayerPosition: (position: [number, number]) => void;
  setHockeyAimDirection: (direction: [number, number]) => void;
  applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void;
  setPaused: (paused: boolean) => void;
  togglePause: () => void;
  setPauseSelection: (selection: "resume" | "exit") => void;
  setSelectedRunBuff: (buffId: RunBuffId) => void;
  chooseRunBuff: (buffId: RunBuffId) => boolean;
  continueRoguelikeRound: () => boolean;
  startRogueTrialsEndless: () => boolean;
  setEndlessChoiceSelection: (selection: "continue" | "quit") => void;
  setAbilityLoadout: (loadout: AbilityLoadout) => void;
  dispatchRpgAction: (action: RpgRoguelikeAction) => boolean;
  setRpgFocusId: (focusId: string) => void;
  cycleRpgFocus: (direction: 1 | -1) => void;
  moveRpgFocus: (direction: RpgFocusDirection) => void;
}

const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
  ability1: 0,
  ability2: 0,
  ability3: 0,
  ability4: 0,
  ability5: 0,
  ability6: 0,
});

export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;

export const BARRIER_RADIUS = 4;
export const BARRIER_DAMAGE_REDUCTION = 0.3;

const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
  const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
  return normalizeEncounterBossIds(requested);
};

type EncounterLayout = "standard" | "hockey";

function createEncounterMotion(
  bossId: BossId,
  index: number,
  count: number,
  startsAt = 0,
  layout: EncounterLayout = "standard",
): BossMotionState {
  const motion = cloneMotion(createBossMotionState(bossId));
  const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0;
  motion.formationOffsetX = offset;
  motion.position[0] += offset;
  motion.chargeStart[0] += offset;
  motion.chargeEnd[0] += offset;
  motion.pounceCenter[0] += offset;
  if (layout === "hockey") {
    const anchor: WorldPosition = index === 0 ? [-2.8, -10] : index === 1 ? [2.8, -6.5] : [0, -8];
    const dx = anchor[0] - motion.position[0];
    const dz = anchor[1] - motion.position[1];
    motion.position = [...anchor];
    motion.chargeStart = [motion.chargeStart[0] + dx, motion.chargeStart[1] + dz];
    motion.chargeEnd = [motion.chargeEnd[0] + dx, motion.chargeEnd[1] + dz];
    motion.pounceCenter = [motion.pounceCenter[0] + dx, motion.pounceCenter[1] + dz];
  }
  const stagger = index * 2.4;
  if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += startsAt + stagger;
  return constrainBossMotion(motion);
}

function createEncounterBoss(
  bossId: BossId,
  index: number,
  count: number,
  healthMultiplier: number,
  startsAt = 0,
  layout: EncounterLayout = "standard",
): AdditionalBossState {
  const boss = createBossState(bossId);
  boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
  boss.hp = boss.maxHp;
  const stagger = index * 0.8;
  if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += startsAt + stagger;
  return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt, layout) };
}

const freshPartyPositions = (bossIds: readonly BossId[], layout: EncounterLayout = "standard"): Record<MemberId, WorldPosition> => {
  if (layout === "hockey") {
    return {
      aelia: [...HOCKEY_DEFAULT_PLAYER_POSITION],
      brann: [-1.2, -2.8],
      nia: [-4.2, -1.6],
      orin: [4.2, -1.6],
      vale: [1.1, -8.4],
    };
  }
  const bossPosition = createBossMotionState(bossIds[0]).position;
  if (bossIds.length > 1) bossPosition[0] = 0;
  const formation = combatFormation(bossPosition);
  return {
    aelia: clampToArena([0, 4.5]),
    brann: clampToArena(formation.brann),
    nia: clampToArena(formation.nia),
    orin: clampToArena(formation.orin),
    vale: clampToArena(formation.vale),
  };
};

export function damageMember(member: PartyMember, amount: number): PartyMember {
  const absorbed = Math.min(member.absorb, amount);
  return {
    ...member,
    absorb: Math.max(0, member.absorb - absorbed),
    hp: Math.max(0, member.hp - (amount - absorbed)),
  };
}

export function healMember(member: PartyMember, amount: number): PartyMember {
  if (member.hp <= 0) return member;
  return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
}

function effectiveHealingMultiplier(state: Pick<GameState, "runMode" | "healingMultiplier" | "endlessBossKills" | "hockeyPvp">): number {
  if (state.runMode !== "hockey-healing-pvp") return state.healingMultiplier;
  return state.healingMultiplier * hockeyPvpHealingEffectiveness(
    state.endlessBossKills,
    state.hockeyPvp.opponentBossKills,
  );
}

export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
  return barrier.kind === "barrier" && healerFieldContains(position, barrier, time);
}

export function healerFieldContains(position: WorldPosition, barrier: BarrierState, time: number) {
  return barrier.kind !== null && barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
}

function lowestHealthIndexes(
  party: readonly PartyMember[],
  excludedIndex: number,
  count: number,
  predicate: (member: PartyMember) => boolean = () => true,
): number[] {
  return party
    .map((member, index) => ({ member, index }))
    .filter(({ member, index }) => index !== excludedIndex && member.hp > 0 && predicate(member))
    .sort((left, right) => (left.member.hp / left.member.maxHp) - (right.member.hp / right.member.maxHp) || left.index - right.index)
    .slice(0, count)
    .map(({ index }) => index);
}

function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers, power = 1) {
  if (party[index].hp <= 0) return;
  party[index] = setHealingEffect(party[index], {
    id: "renew",
    expiresAt: time + 8 + modifiers.renewDurationBonus,
    nextTickAt: time + 1,
    tickInterval: 1,
    healingPerTick: 7 * modifiers.renewHealingMultiplier * power,
    stacks: 1,
  });
}

function applyRejuvenationAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers, power = 1) {
  if (party[index].hp <= 0) return;
  party[index] = setHealingEffect(party[index], {
    id: "rejuvenation",
    expiresAt: time + 8 + modifiers.renewDurationBonus,
    nextTickAt: time + 1,
    tickInterval: 1,
    healingPerTick: 6 * modifiers.renewHealingMultiplier * power,
    stacks: 1,
  });
}

function applyRiptideAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers, power = 1) {
  if (party[index].hp <= 0) return;
  party[index] = setHealingEffect(party[index], {
    id: "riptide",
    expiresAt: time + 6 + modifiers.renewDurationBonus,
    nextTickAt: time + 1,
    tickInterval: 1,
    healingPerTick: 5 * modifiers.renewHealingMultiplier * power,
    stacks: 1,
  });
}

function applyLifebloomAt(
  party: PartyMember[],
  index: number,
  time: number,
  modifiers: CompiledRunModifiers,
  stacksToAdd = 1,
  power = 1,
) {
  if (party[index].hp <= 0) return 0;
  const current = healingEffect(party[index], "lifebloom");
  const stacks = Math.min(3, (current?.stacks ?? 0) + stacksToAdd);
  party[index] = setHealingEffect(party[index], {
    id: "lifebloom",
    expiresAt: time + 7,
    nextTickAt: current?.nextTickAt && current.expiresAt > time ? current.nextTickAt : time + 1,
    tickInterval: 1,
    healingPerTick: 2 * modifiers.shieldAbsorbMultiplier * power,
    stacks,
    expirationHealingPerStack: 10 * modifiers.shieldAbsorbMultiplier * power,
  });
  return stacks;
}

function applyEarthShieldAt(
  party: PartyMember[],
  index: number,
  time: number,
  modifiers: CompiledRunModifiers,
  charges = 6,
  power = 1,
) {
  if (party[index].hp <= 0) return;
  party[index] = {
    ...party[index],
    reactiveHeal: {
      id: "earth-shield",
      charges,
      expiresAt: time + 30,
      nextTriggerAt: time,
      healingPerTrigger: 9 * modifiers.shieldAbsorbMultiplier * power,
    },
  };
}

function addHealerAbsorb(
  party: PartyMember[],
  index: number,
  baseAmount: number,
  healingPower: number,
  modifiers: CompiledRunModifiers,
) {
  if (party[index].hp <= 0 || baseAmount <= 0) return 0;
  const amount = baseAmount * healingPower * modifiers.shieldAbsorbMultiplier;
  party[index] = {
    ...party[index],
    absorb: Math.min(party[index].maxHp, party[index].absorb + amount),
  };
  return amount;
}

function damageMemberAt(
  member: PartyMember,
  amount: number,
  position: WorldPosition,
  barrier: BarrierState,
  time: number,
  partyCombat?: PartyCombatState,
  tankPosition?: WorldPosition,
  incomingDamageMultiplier = 1,
  gearModifiers?: EncounterGearModifiers,
  kind: "direct" | "hazard" = "direct",
  shieldDamageTakenMultiplier = 1,
  onReactiveHeal?: () => void,
) {
  amount *= incomingDamageMultiplier;
  if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
  const protectedByTank = partyCombat && tankPosition
    ? tankAuraProtects(position, tankPosition, partyCombat.tankAura, time)
    : false;
  const reduction = Math.max(
    barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
    protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
  );
  const lifebloom = healingEffect(member, "lifebloom");
  const warded = member.absorb > 0
    || Boolean(member.reactiveHeal && member.reactiveHeal.expiresAt > time)
    || Boolean(lifebloom && lifebloom.expiresAt > time);
  const shieldMultiplier = warded ? shieldDamageTakenMultiplier : 1;
  const damaged = damageMember(member, amount * (1 - reduction) * shieldMultiplier);
  const reactive = resolveReactiveHealAfterDamage(member, damaged, time, gearModifiers?.aelia.healingPower ?? 1);
  if (reactive.triggered) onReactiveHeal?.();
  return reactive.member;
}

function addLog(
  log: CombatLogEntry[],
  time: number,
  message: string,
  tone: CombatLogEntry["tone"] = "neutral",
): CombatLogEntry[] {
  const next = [{ id: Date.now() + Math.random(), time, message, tone }, ...log];
  return next.slice(0, 12);
}


function initialState(
  healerClassId: HealerClassId = "priest",
  playerName = "Aelia",
  inventory: InventoryItem[] = createClassInventory(healerClassId),
  requestedBossIds: BossId | readonly BossId[] = "bulldrome",
  runMode: RunMode = "encounter",
  round = 1,
  runBuffRanks: RunBuffRanks = {},
  gearProgress: GearProgress = createDefaultGearProgress(),
  requestedDifficultySlug: DifficultySlug = "initiate",
  seenBossIds: readonly BossId[] = [],
  hockeyPvpMatch?: HockeyPvpMatchConfig,
  requestedAbilityLoadout?: AbilityLoadout,
) {
  const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug);
  const difficulty = DIFFICULTY_BY_SLUG[difficultySlug];
  const bossIds = normalizeBossIds(requestedBossIds);
  const activityMode = defaultGameplayActivity(runMode);
  const hockeyLayout = activityMode !== "boss";
  const layout: EncounterLayout = hockeyLayout ? "hockey" : "standard";
  const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
    bossId,
    index,
    bossIds.length,
    bossHealthMultiplier(round) * difficulty.healthMultiplier,
    0,
    layout,
  ));
  const primary = encounterBosses[0];
  const normalizedExternalGear = isPvpRunMode(runMode) || runMode === "rpg-roguelike";
  const gearModifiers = normalizedExternalGear
    ? createBaseEncounterGearModifiers()
    : createEncounterGearModifiers(gearProgress, healerClassId);
  const passiveInfusionId = !normalizedExternalGear && passiveInfusionUnlocked(gearProgress)
    ? gearProgress[healerClassId].passiveInfusionId
    : null;
  const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId);
  const draftBuffIds = runMode === "roguelike" || runMode === "rogue-trials"
    ? selectRunBuffDraft(runBuffRanks, passiveInfusionId)
    : [];
  const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
  const opponentParty = applyGearHealth(
    freshParty(healerClassId, hockeyPvpMatch?.opponentName ?? "CPU Willow"),
    gearModifiers,
  );
  const opponentBoss = createEncounterBoss(
    primary.boss.id,
    0,
    1,
    difficulty.healthMultiplier,
    0,
    "hockey",
  );
  const maxMana = 100;
  return {
    bossId: primary.boss.id,
    bossInstanceId: primary.instanceId,
    paused: false,
    pauseSelection: "resume" as const,
    healerClassId,
    abilityLoadout: { ...(requestedAbilityLoadout ?? createClassAbilityLoadout(healerClassId)) },
    playerName,
    phase: "briefing" as GamePhase,
    runMode,
    activityMode,
    rpgRun: null as RpgRoguelikeRunState | null,
    rpgFocusId: null as string | null,
    rpgDamageProfiles: null as RpgPartyDamageProfiles | null,
    rpgSpellResources: { verdancy: 0, tidalSurge: 0, conviction: 0, chronoshards: 0 } as RpgSpellResources,
    round,
    seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
    endlessMode: hockeyLayout,
    endlessBossKills: 0,
    endlessSpawnSequence: hockeyLayout ? encounterBosses.length : 0,
    endlessChoiceSelection: "continue" as const,
    hockey: createHockeyHealingState(activityMode === "hockey-healing"),
    blockbreaker: createBlockbreakerState(
      activityMode === "blockbreaker",
      activityMode === "blockbreaker" ? createBlockbreakerSeed() : 1,
    ),
    aetherAssault: createAetherAssaultState(
      activityMode === "aether-assault",
      activityMode === "aether-assault" ? createAetherAssaultSeed() : 1,
    ),
    hockeyPvp: createHockeyPvpState(activityMode === "hockey-healing-pvp" ? hockeyPvpMatch : undefined),
    hockeyPvpOpponent: {
      party: opponentParty,
      partyPositions: freshPartyPositions([primary.boss.id], "hockey"),
      boss: opponentBoss.boss,
      bossMotion: opponentBoss.motion,
      partyCombat: createPartyCombatState(opponentParty),
    } as HockeyPvpOpponentState,
    runBuffRanks: { ...runBuffRanks },
    draftBuffIds,
    selectedRunBuffId: draftBuffIds[0] ?? null,
    runBuffInputUnlockAt: 0,
    passiveRunBuffId: passiveInfusionId,
    runModifiers,
    healingMultiplier: gearModifiers.aelia.healingPower,
    difficultySlug,
    difficultyDamageMultiplier: difficulty.damageMultiplier,
    gearProgress,
    gearModifiers,
    time: 0,
    party,
    boss: primary.boss,
    additionalBosses: encounterBosses.slice(1),
    partyPositions: freshPartyPositions(bossIds, layout),
    bossMotion: primary.motion,
    partyCombat: createPartyCombatState(party),
    partyDamageEvents: [] as PartyDamageEvent[],
    mana: maxMana,
    maxMana,
    selectedMemberId: "brann" as MemberId,
    cooldowns: emptyCooldowns(),
    globalCooldownUntil: 0,
    activeTab: "combat" as BottomTab,
    selectedItemId: inventory[0]?.id ?? "",
    inventory: structuredClone(inventory),
    combatLog: [] as CombatLogEntry[],
    scenePulse: { id: 0, kind: "direct-heal" as const },
    playerPosition: (hockeyLayout ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5]) as [number, number],
    activeCast: null as ActiveCast | null,
    barrier: { kind: null, center: hockeyLayout ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState,
    healerMechanic: createHealerMechanicState(healerClassId),
  };
}

function rpgActivityForRun(run: RpgRoguelikeRunState): GameplayActivity {
  if (run.phase !== "challenge-active" && run.phase !== "challenge-briefing") return "boss";
  const challengeId = run.currentChallenge?.objective.challengeId;
  if (challengeId === "blockbreaker") return "blockbreaker";
  if (challengeId === "hockey") return "hockey-healing";
  return "aether-assault";
}

function gamePhaseForRpgRun(run: RpgRoguelikeRunState): GamePhase {
  if (run.phase === "challenge-active" || run.phase === "boss-combat" || run.phase === "boss-cleared") return "combat";
  if (run.phase === "reward" || run.phase === "shop") return "intermission";
  if (run.phase === "victory") return "victory";
  if (run.phase === "defeat") return "defeat";
  return "briefing";
}

function rpgRunSeed(): number {
  // One non-deterministic seed is captured at run creation. Every subsequent
  // offer, route, reward, and shop draw advances the serialized RNG state.
  return Date.now() ^ Math.floor(Math.random() * 0x7fffffff);
}

function rpgChallengeMetrics(
  activityMode: GameplayActivity,
  blockbreaker: BlockbreakerState,
  endlessBossKills: number,
  aetherAssault: AetherAssaultState,
): ChallengeMetrics {
  return {
    bricksBroken: activityMode === "blockbreaker" ? blockbreaker.bricksBroken : 0,
    bossKills: activityMode === "hockey-healing" ? endlessBossKills : 0,
    kills: activityMode === "aether-assault" ? aetherAssault.kills : 0,
  };
}

function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Partial<GameState> {
  const activityMode = rpgActivityForRun(run);
  const challenge = run.phase === "challenge-active";
  const currentBossId = selectCurrentBossId(run) ?? state.boss.id;
  const challengePartner = run.bossRoute[(run.bossIndex + 1) % run.bossRoute.length] ?? currentBossId;
  const bossIds = challenge && challengePartner !== currentBossId
    ? [currentBossId, challengePartner]
    : [currentBossId];
  const layout: EncounterLayout = challenge ? "hockey" : "standard";
  const modeDifficulty = rpgEncounterDifficulty(
    challenge ? "hallway-challenge" : "boss-room",
    run.bossIndex,
  );
  const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
  const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
  const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
    bossId,
    index,
    bossIds.length,
    healthMultiplier,
    0,
    layout,
  ));
  const primary = encounterBosses[0];
  const projection = projectRpgCombat(run, {
    healerClassId: state.healerClassId,
    playerName: state.playerName,
  });
  const healerRatio = Math.max(0, Math.min(1, run.playerHp / 100));
  const party = projection.party.map((member) => member.id === "aelia"
    ? { ...member, hp: member.maxHp * healerRatio }
    : member);
  const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0;
  const maxMana = 100;

  return {
    rpgRun: run,
    rpgFocusId: normalizeRpgFocusId(run, state.rpgFocusId),
    rpgDamageProfiles: projection.damageProfiles,
    rpgSpellResources: { verdancy: 0, tidalSurge: 0, conviction: 0, chronoshards: 0 },
    abilityLoadout: { ...run.abilityLoadout },
    activityMode,
    phase: "combat",
    round: run.bossIndex + 1,
    endlessMode: challenge,
    endlessBossKills: 0,
    endlessSpawnSequence: encounterBosses.length,
    bossId: primary.boss.id,
    bossInstanceId: `rpg-${challenge ? "challenge" : "boss"}-${run.bossIndex}-${primary.boss.id}`,
    boss: primary.boss,
    bossMotion: primary.motion,
    additionalBosses: encounterBosses.slice(1).map((entry, index) => ({
      ...entry,
      instanceId: `rpg-${challenge ? "challenge" : "boss"}-${run.bossIndex}-${index + 1}-${entry.boss.id}`,
    })),
    seenBossIds: [...new Set([...state.seenBossIds, ...bossIds])],
    party,
    gearModifiers: projection.gearModifiers,
    healingMultiplier: projection.gearModifiers.aelia.healingPower,
    difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
    partyCombat: createPartyCombatState(party),
    partyDamageEvents: [],
    partyPositions: freshPartyPositions(bossIds, layout),
    playerPosition: challenge ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5],
    hockey: createHockeyHealingState(activityMode === "hockey-healing"),
    blockbreaker: createBlockbreakerState(activityMode === "blockbreaker", deterministicSeed || 1),
    aetherAssault: createAetherAssaultState(activityMode === "aether-assault", deterministicSeed || 1),
    mana: maxMana,
    maxMana,
    cooldowns: emptyCooldowns(),
    globalCooldownUntil: 0,
    activeCast: null,
    activeTab: "combat",
    selectedMemberId: party.find((member) => member.id === "brann" && member.hp > 0)?.id
      ?? party.find((member) => member.id !== "aelia" && member.hp > 0)?.id
      ?? party.find((member) => member.hp > 0)?.id
      ?? "aelia",
    barrier: {
      kind: null,
      center: challenge ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5],
      expiresAt: 0,
      nextHealAt: 0,
    },
    healerMechanic: createHealerMechanicState(state.healerClassId),
    time: 0,
    combatLog: [{
      id: Date.now(),
      time: 0,
      message: challenge
        ? `${run.currentChallenge?.objective.name ?? "Hallway challenge"} begins.`
        : `${primary.boss.name} guards room ${run.bossIndex + 1}.`,
      tone: challenge ? "neutral" : "danger",
    }],
  };
}

export const useGameStore = create<GameState>((set, get) => ({
  ...initialState(),

  configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate", hockeyPvpMatch) => {
    const base = initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug, [], hockeyPvpMatch);
    if (runMode !== "rpg-roguelike") {
      set(base);
      return;
    }
    const rpgRun = createRpgRoguelikeRun({ seed: rpgRunSeed() });
    set({
      ...base,
      rpgRun,
      rpgFocusId: normalizeRpgFocusId(rpgRun, null),
      rpgDamageProfiles: null,
      abilityLoadout: {},
      phase: "briefing",
      activityMode: "boss",
      endlessMode: false,
    });
  },

  startEncounter: () => {
    const current = get();
    if (current.runMode === "rpg-roguelike" && current.rpgRun) {
      if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
      else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
      else if (current.rpgRun.phase === "boss-cleared") current.dispatchRpgAction({ type: "reward-open" });
      return;
    }
    const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds, hockeyPvp } = get();
    const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
    set({
      ...initialState(
        healerClassId,
        playerName,
        inventory,
        bossIds,
        runMode,
        round,
        runBuffRanks,
        gearProgress,
        difficultySlug,
        seenBossIds,
        runMode === "hockey-healing-pvp"
          ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
          : undefined,
        abilityLoadout,
      ),
      phase: "combat",
      activeTab: "combat",
      combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
    });
  },

  restart: () => {
    const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug, hockeyPvp } = get();
    if (runMode === "rpg-roguelike") {
      const base = initialState(
        healerClassId,
        playerName,
        inventory,
        boss.id,
        runMode,
        1,
        {},
        gearProgress,
        difficultySlug,
        [],
        undefined,
        {},
      );
      const rpgRun = createRpgRoguelikeRun({ seed: rpgRunSeed() });
      set({
        ...base,
        rpgRun,
        rpgFocusId: normalizeRpgFocusId(rpgRun, null),
        abilityLoadout: {},
      });
      return;
    }
    const bossIds = runMode === "rogue-trials" || runMode === "hockey-healing" || runMode === "blockbreaker" || runMode === "aether-assault"
      ? selectRandomBossPair()
      : runMode === "hockey-healing-pvp"
        ? [hockeyPvpBossAt(hockeyPvp.seed, 0)]
      : [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
    set(initialState(
      healerClassId,
      playerName,
      inventory,
      bossIds,
      runMode,
      1,
      {},
      gearProgress,
      difficultySlug,
      [],
      runMode === "hockey-healing-pvp"
        ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
        : undefined,
      abilityLoadout,
    ));
  },

  selectMember: (selectedMemberId) => set({ selectedMemberId }),

  cycleMember: (direction) => {
    const { party, selectedMemberId } = get();
    const living = party.filter((member) => member.hp > 0);
    if (!living.length) return;
    const currentIndex = living.findIndex((member) => member.id === selectedMemberId);
    const nextIndex = (Math.max(0, currentIndex) + direction + living.length) % living.length;
    set({ selectedMemberId: living[nextIndex].id });
  },

  setActiveTab: (activeTab) => set({ activeTab }),
  selectItem: (selectedItemId) => set({ selectedItemId }),
  setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
  togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
  setPauseSelection: (pauseSelection) => set({ pauseSelection }),
  setSelectedRunBuff: (selectedRunBuffId) => set((state) =>
    state.phase === "intermission"
      && !isRunBuffInputLocked(state)
      && state.draftBuffIds.includes(selectedRunBuffId)
      ? { selectedRunBuffId }
      : state
  ),
  chooseRunBuff: (buffId) => {
    const state = get();
    if (state.phase !== "intermission" || isRunBuffInputLocked(state) || !state.draftBuffIds.includes(buffId)) return false;
    const runBuffRanks = increaseRunBuffRank(state.runBuffRanks, buffId);
    const round = state.round + 1;
    const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
    const bossIds = state.runMode === "rogue-trials"
      ? selectRogueTrialsBosses(round, state.seenBossIds)
      : selectRandomBossPair(previousBossIds);
    const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilitySlotId].name;
    set({
      ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds),
      phase: "combat",
      activeTab: "combat",
      combatLog: [{
        id: Date.now(),
        time: 0,
        message: `${abilityName}: ${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
        tone: "good",
      }],
    });
    return true;
  },
  continueRoguelikeRound: () => {
    const state = get();
    if (state.phase !== "intermission" || isRunBuffInputLocked(state) || state.draftBuffIds.length > 0) return false;
    const round = state.round + 1;
    const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
    const bossIds = state.runMode === "rogue-trials"
      ? selectRogueTrialsBosses(round, state.seenBossIds)
      : selectRandomBossPair(previousBossIds);
    set({
      ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, state.runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds),
      phase: "combat",
      activeTab: "combat",
      combatLog: [{
        id: Date.now(),
        time: 0,
        message: `All blessings mastered. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
        tone: "good",
      }],
    });
    return true;
  },
  startRogueTrialsEndless: () => {
    const state = get();
    if (state.runMode !== "rogue-trials"
      || state.round !== ROGUE_TRIALS_TRIO_ROUND
      || state.phase !== "victory"
      || state.endlessMode) return false;
    const bossIds = selectRogueTrialsBosses(ROGUE_TRIALS_TRIO_ROUND, []);
    const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
    const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
    const encounterBosses = bossIds.map((bossId, index) => {
      const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier, state.time);
      return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
    });
    const primary = encounterBosses[0];
    set({
      bossId: primary.boss.id,
      bossInstanceId: primary.instanceId,
      boss: primary.boss,
      bossMotion: primary.motion,
      additionalBosses: encounterBosses.slice(1),
      phase: "combat",
      endlessMode: true,
      endlessBossKills: 0,
      endlessSpawnSequence: encounterBosses.length,
      partyCombat: createPartyCombatState(state.party),
      partyDamageEvents: [],
      partyPositions: freshPartyPositions(bossIds),
      playerPosition: [0, 4.5],
      activeCast: null,
      activeTab: "combat",
      combatLog: [{
        id: Date.now(),
        time: state.time,
        message: `${encounterBosses.map((entry) => entry.boss.name).join(", ")} enter the endless trial.`,
        tone: "danger",
      }],
    });
    return true;
  },
  setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }),
  setAbilityLoadout: (abilityLoadout) => set({ abilityLoadout: { ...abilityLoadout }, cooldowns: emptyCooldowns(), activeCast: null }),
  dispatchRpgAction: (action) => {
    const state = get();
    if (state.runMode !== "rpg-roguelike" || !state.rpgRun) return false;
    const actionRun = action.type === "reward-open" && state.rpgRun.phase === "boss-cleared"
      ? reduceRpgRoguelikeRun(state.rpgRun, {
          type: "party-vitals",
          vitals: extractRpgPartyVitals(state.party, assignRosterToCombatSlots(state.rpgRun.roster)),
          playerHp: extractRpgPlayerVital(state.party),
        })
      : state.rpgRun;
    const nextRun = reduceRpgRoguelikeRun(actionRun, action);
    if (nextRun === state.rpgRun) return false;

    const enteringCombat = state.rpgRun.phase !== nextRun.phase
      && (nextRun.phase === "challenge-active" || nextRun.phase === "boss-combat");
    if (enteringCombat) {
      set(createRpgCombatState(state, nextRun));
      return true;
    }
    if (nextRun.phase === "challenge-active" || nextRun.phase === "boss-combat") {
      set({ rpgRun: nextRun });
      return true;
    }

    set({
      rpgRun: nextRun,
      rpgFocusId: normalizeRpgFocusId(nextRun, state.rpgFocusId),
      abilityLoadout: { ...nextRun.abilityLoadout },
      activityMode: rpgActivityForRun(nextRun),
      phase: gamePhaseForRpgRun(nextRun),
      endlessMode: false,
      activeCast: null,
      activeTab: "combat",
    });
    return true;
  },
  setRpgFocusId: (requestedFocusId) => set((state) => {
    if (!state.rpgRun) return state;
    const rpgFocusId = normalizeRpgFocusId(state.rpgRun, requestedFocusId);
    return rpgFocusId === state.rpgFocusId ? state : { rpgFocusId };
  }),
  cycleRpgFocus: (direction) => set((state) => {
    if (!state.rpgRun) return state;
    const items = rpgFocusItems(state.rpgRun);
    if (!items.length) return state;
    const currentIndex = items.findIndex((item) => item.id === state.rpgFocusId);
    const startIndex = currentIndex >= 0 ? currentIndex : 0;
    const rpgFocusId = items[(startIndex + direction + items.length) % items.length].id;
    return rpgFocusId === state.rpgFocusId ? state : { rpgFocusId };
  }),
  moveRpgFocus: (direction) => set((state) => {
    if (!state.rpgRun) return state;
    const rpgFocusId = moveRpgFocus(state.rpgRun, state.rpgFocusId, direction);
    return rpgFocusId === state.rpgFocusId ? state : { rpgFocusId };
  }),
  setPlayerPosition: (playerPosition) => set((state) => {
    if (state.runMode === "rpg-roguelike" && state.activityMode === "boss" && state.rpgRun) {
      const portals = state.rpgRun.phase === "boss-cleared" ? NORTH_OPEN_BOSS_ARENA_PORTALS : CLOSED_BOSS_ARENA_PORTALS;
      playerPosition = clampToBossArenaWithPortals(playerPosition, portals);
      if (state.rpgRun.phase === "boss-cleared" && detectBossArenaExit(playerPosition, portals) === "north") {
        const syncedRun = reduceRpgRoguelikeRun(state.rpgRun, {
          type: "party-vitals",
          vitals: extractRpgPartyVitals(state.party, assignRosterToCombatSlots(state.rpgRun.roster)),
          playerHp: extractRpgPlayerVital(state.party),
        });
        const rpgRun = reduceRpgRoguelikeRun(syncedRun, { type: "reward-open" });
        return {
          playerPosition,
          partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
          rpgRun,
          rpgFocusId: normalizeRpgFocusId(rpgRun, null),
          phase: gamePhaseForRpgRun(rpgRun),
          activeCast: null,
        };
      }
    } else {
      playerPosition = state.activityMode === "aether-assault"
        ? clampToHockeyArena(playerPosition, 0.65)
        : state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker"
          ? clampToHockeyHealerHalf(playerPosition, 0.65)
          : clampToArena(playerPosition);
    }
    const current = state.playerPosition;
    const partyCurrent = state.partyPositions.aelia;
    if (current[0] === playerPosition[0]
      && current[1] === playerPosition[1]
      && partyCurrent[0] === playerPosition[0]
      && partyCurrent[1] === playerPosition[1]) {
      return state;
    }
    return {
      playerPosition,
      partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
    };
  }),
  setHockeyAimDirection: (direction) => set((state) => {
    if (state.activityMode === "hockey-healing-pvp") {
      if (state.hockeyPvp.aimDirection[0] === direction[0] && state.hockeyPvp.aimDirection[1] === direction[1]) return state;
      return { hockeyPvp: { ...state.hockeyPvp, aimDirection: [...direction] } };
    }
    if (state.activityMode === "blockbreaker") {
      const blockbreaker = setBlockbreakerAim(state.blockbreaker, direction);
      return blockbreaker === state.blockbreaker ? state : { blockbreaker };
    }
    if (state.activityMode !== "hockey-healing") return state;
    const hockey = setHockeyAim(state.hockey, direction);
    return hockey === state.hockey ? state : { hockey };
  }),
  applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
    if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
    const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
      ? mirrorHockeyPvpPuck(hostPuck)
      : undefined;
    const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
    const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
    const newGoals = Math.max(0, nextLocalGoals - previousLocalGoals);
    let reactiveTriggers = 0;
    const party = newGoals > 0
      ? state.party.map((member) => {
          const damaged = damageMember(member, HOCKEY_PVP_GOAL_DAMAGE * newGoals);
          const reactive = resolveReactiveHealAfterDamage(member, damaged, state.time, state.gearModifiers.aelia.healingPower);
          if (reactive.triggered) reactiveTriggers += 1;
          return reactive.member;
        })
      : state.party;
    const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
    const opponentWiped = isPartyWiped(opponentParty);
    const localWiped = isPartyWiped(party);
    const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
    return {
      party,
      healerMechanic: state.healerClassId === "shaman" && reactiveTriggers > 0
        ? { ...state.healerMechanic, resource: Math.min(state.healerMechanic.maxResource, state.healerMechanic.resource + reactiveTriggers) }
        : state.healerMechanic,
      phase,
      hockeyPvp: {
        ...state.hockeyPvp,
        ...(authoritativePuck ?? {}),
        opponentBossKills: snapshot.bossKills,
        opponentPlayerPosition: [...snapshot.playerPosition],
        opponentAimDirection: [...snapshot.aimDirection],
        networkSequence: Math.max(state.hockeyPvp.networkSequence, snapshot.sequence),
        appliedGoalSequence: authoritativePuck?.goalSequence ?? state.hockeyPvp.appliedGoalSequence,
        status: localWiped ? "lost" : opponentWiped ? "won" : state.hockeyPvp.status,
      },
      hockeyPvpOpponent: {
        ...state.hockeyPvpOpponent,
        party: opponentParty,
        partyPositions: structuredClone(snapshot.partyPositions),
        boss: {
          ...state.hockeyPvpOpponent.boss,
          ...snapshot.boss,
          nextMeleeAt: state.hockeyPvpOpponent.boss.nextMeleeAt,
        },
        bossMotion: {
          ...state.hockeyPvpOpponent.bossMotion,
          bossId: snapshot.boss.id,
          position: [...snapshot.bossPosition],
          mode: snapshot.bossMode,
        },
      },
    };
  }),

  castAbility: (abilitySlotId) => {
    const state = get();
    if (state.phase !== "combat") return false;
    if (state.rpgRun && state.rpgRun.phase !== "challenge-active" && state.rpgRun.phase !== "boss-combat") return false;
    if (state.paused) return false;
    if (state.activeCast) return false;
    const healer = state.party.find((member) => member.id === "aelia");
    if (!healer || healer.hp <= 0) return false;

    const ability = resolveSlottedAbility(state.abilityLoadout, abilitySlotId);
    if (!ability) return false;
    const spellPower = state.rpgRun
      ? spellRankPowerMultiplier(state.rpgRun.spellRanks, ability.id)
      : 1;
    const manaCost = runAbilityManaCost(abilitySlotId, ability.mana, state.runModifiers);
    const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
    const selected = state.party[selectedIndex];

    if (state.cooldowns[abilitySlotId] > state.time + 0.01) return false;
    if (state.globalCooldownUntil > state.time + 0.001) return false;
    if (state.mana < manaCost) {
      set({ combatLog: addLog(state.combatLog, state.time, `Not enough ${HEALER_CLASSES[state.healerClassId].resourceName.toLowerCase()}.`, "danger") });
      return false;
    }
    if (ability.targeting === "ally" && (!selected || selected.hp <= 0)) return false;
    if (ability.targeting === "enemy" && state.boss.hp <= 0 && state.additionalBosses.every((entry) => entry.boss.hp <= 0)) return false;
    if (ability.pulseKind === "cleanse" && selected.debuffs.length === 0) {
      set({ combatLog: addLog(state.combatLog, state.time, `${selected.name} has nothing to ${ability.name}.`) });
      return false;
    }

    if (ability.castTime) {
      let resourceSpent = 0;
      let castTime = runAbilityCastTime(abilitySlotId, ability.castTime, state.runModifiers);
      const rpgSpellResources = { ...state.rpgSpellResources };
      if (ability.id === "druid-regrowth") {
        resourceSpent = Math.min(3, state.rpgRun ? rpgSpellResources.verdancy : state.healerMechanic.resource);
        if (state.rpgRun) rpgSpellResources.verdancy -= resourceSpent;
      }
      const availableSurge = state.rpgRun ? rpgSpellResources.tidalSurge : state.healerMechanic.resource;
      if (ability.id === "shaman-healing-wave" && availableSurge > 0) {
        resourceSpent = 1;
        castTime *= 0.5;
        if (state.rpgRun) rpgSpellResources.tidalSurge -= 1;
      }
      set({
        activeCast: {
          slotId: abilitySlotId,
          abilityId: ability.id,
          targetId: selected.id,
          startedAt: state.time,
          completesAt: state.time + castTime,
          resourceSpent,
        },
        healerMechanic: resourceSpent > 0 && !state.rpgRun
          ? { ...state.healerMechanic, resource: state.healerMechanic.resource - resourceSpent }
          : state.healerMechanic,
        rpgSpellResources,
        mana: Math.max(0, state.mana - manaCost),
        globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
        combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
      });
      return true;
    }

    let party = state.party.map((member) => ({
      ...member,
      debuffs: [...member.debuffs],
      healingEffects: member.healingEffects.map((effect) => ({ ...effect })),
      reactiveHeal: member.reactiveHeal ? { ...member.reactiveHeal } : null,
    }));
    let message = ability.name;
    const healingMultiplier = effectiveHealingMultiplier(state) * spellPower;
    let barrier = state.barrier;
    let boss = state.boss;
    let bossMotion = state.bossMotion;
    let additionalBosses = state.additionalBosses;
    let healerMechanic = { ...state.healerMechanic };
    const rpgSpellResources = { ...state.rpgSpellResources };
    const cooldowns = { ...state.cooldowns };

    switch (ability.id) {
      case "priest-renew":
        applyRenewAt(party, selectedIndex, state.time, state.runModifiers, spellPower);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
          applyRenewAt(party, index, state.time, state.runModifiers, spellPower);
        }
        message = `${ability.name} placed on ${selected.name}.`;
        break;
      case "priest-aegis-shield": {
        const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
          addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers);
        }
        message = `${selected.name} gains ${Math.round(amount)} absorption.`;
        break;
      }
      case "priest-purify":
      case "druid-natures-cure":
      case "shaman-cleanse-spirit":
      case "paladin-cleanse-light":
      case "chronomancer-erase-affliction": {
        const cleanseIndexes = [
          selectedIndex,
          ...lowestHealthIndexes(party, selectedIndex, state.runModifiers.purifyExtraTargets, (member) => member.debuffs.length > 0),
        ];
        const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
        for (const index of cleanseIndexes) {
          const target = party[index];
          const dispelledDebuffs = target.debuffs;
          const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
          bossMotion = primaryDispel.motion;
          additionalBosses = additionalBosses.map((entry) => {
            const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
            return { ...entry, motion: dispel.motion };
          });
          party[index] = { ...party[index], debuffs: [] };
          if (ability.id === "druid-natures-cure") {
            party[index] = triggerHealingEffects(party[index], healingMultiplier).member;
            if (state.runModifiers.purifyAppliesRenew) applyRejuvenationAt(party, index, state.time, state.runModifiers, spellPower);
            if (state.runModifiers.purifyAppliesShield) applyLifebloomAt(party, index, state.time, state.runModifiers, 1, spellPower);
          } else if (ability.id === "shaman-cleanse-spirit") {
            if (state.runModifiers.purifyAppliesRenew) applyRiptideAt(party, index, state.time, state.runModifiers, spellPower);
            if (state.runModifiers.purifyAppliesShield) applyEarthShieldAt(party, index, state.time, state.runModifiers, 3, spellPower);
          } else {
            if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers, spellPower);
            if (state.runModifiers.purifyAppliesShield) {
              addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers);
            }
          }
        }
        if (ability.id === "shaman-cleanse-spirit") {
          if (state.rpgRun) rpgSpellResources.tidalSurge = Math.min(2, rpgSpellResources.tidalSurge + 1);
          else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1);
        } else if (ability.id === "chronomancer-erase-affliction") {
          if (state.rpgRun) rpgSpellResources.chronoshards = Math.min(3, rpgSpellResources.chronoshards + 1);
          else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1);
        } else if (ability.id === "paladin-cleanse-light") {
          const result = healTargetAndBeacon(party, selectedIndex, 10 * healingMultiplier, healerMechanic, state.time);
          party = result.party;
        }
        message = primaryNames.includes("Widow Venom")
          ? "Widow Venom purged. A venom pool forms where the target stood."
          : `${primaryNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
        break;
      }
      case "priest-radiance":
        party = party.map((member) => healMember(member, 22 * healingMultiplier));
        if (state.runModifiers.radianceAppliesRenew) {
          for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers, spellPower);
        }
        if (state.runModifiers.radianceAbsorb > 0) {
          for (let index = 0; index < party.length; index += 1) {
            addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers);
          }
        }
        message = `${ability.name} heals the full party.`;
        break;
      case "priest-barrier":
        barrier = {
          kind: "barrier",
          center: [...state.partyPositions.aelia],
          expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
          nextHealAt: state.time + 1,
        };
        message = `${ability.name} protects a ${BARRIER_RADIUS}m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`;
        break;
      case "druid-rejuvenation":
        applyRejuvenationAt(party, selectedIndex, state.time, state.runModifiers, spellPower);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
          applyRejuvenationAt(party, index, state.time, state.runModifiers, spellPower);
        }
        message = `${ability.name} begins restoring ${selected.name}.`;
        break;
      case "druid-lifebloom": {
        const current = healingEffect(party[selectedIndex], "lifebloom");
        const verdancy = state.rpgRun ? rpgSpellResources.verdancy : healerMechanic.resource;
        if (current?.stacks === 3 && verdancy >= 2) {
          const bloom = 10 * current.stacks * state.runModifiers.shieldAbsorbMultiplier * healingMultiplier;
          party[selectedIndex] = healMember(removeHealingEffect(party[selectedIndex], "lifebloom"), bloom);
          if (state.rpgRun) rpgSpellResources.verdancy -= 2;
          else healerMechanic.resource -= 2;
          message = `${ability.name} blooms on ${selected.name} for ${Math.round(bloom)}.`;
        } else {
          const stacks = applyLifebloomAt(party, selectedIndex, state.time, state.runModifiers, 1, spellPower);
          for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
            applyLifebloomAt(party, index, state.time, state.runModifiers, 1, spellPower);
          }
          message = `${ability.name} grows to ${stacks} ${stacks === 1 ? "stack" : "stacks"} on ${selected.name}.`;
        }
        break;
      }
      case "druid-wild-growth": {
        const targets = lowestHealthIndexes(party, -1, 3);
        for (const index of targets) {
          party[index] = setHealingEffect(party[index], {
            id: "wild-growth",
            expiresAt: state.time + 6,
            nextTickAt: state.time + 1,
            tickInterval: 1,
            healingPerTick: 5 * spellPower,
            stacks: 1,
          });
        }
        if (state.runModifiers.radianceAppliesRenew) {
          for (let index = 0; index < party.length; index += 1) applyRejuvenationAt(party, index, state.time, state.runModifiers, spellPower);
        }
        if (state.runModifiers.radianceAbsorb > 0) {
          party = party.map((member) => healMember(member, state.runModifiers.radianceAbsorb * healingMultiplier));
        }
        message = `${ability.name} spreads to ${targets.length} injured allies.`;
        break;
      }
      case "druid-flourish":
        party = flourishParty(party, state.time, 5 + state.runModifiers.barrierDurationBonus);
        healerMechanic.flourishExpiresAt = state.time + 6 + state.runModifiers.barrierDurationBonus;
        healerMechanic.nextPulseAt = state.time + 1;
        message = `${ability.name} accelerates and extends every active growth.`;
        break;
      case "shaman-riptide":
        party[selectedIndex] = healMember(party[selectedIndex], 12 * healingMultiplier);
        applyRiptideAt(party, selectedIndex, state.time, state.runModifiers, spellPower);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
          party[index] = healMember(party[index], 6 * healingMultiplier);
          applyRiptideAt(party, index, state.time, state.runModifiers, spellPower);
        }
        if (state.rpgRun) rpgSpellResources.tidalSurge = Math.min(2, rpgSpellResources.tidalSurge + 1);
        else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1);
        message = `${ability.name} marks ${selected.name} as a chain anchor.`;
        break;
      case "shaman-earth-shield":
        applyEarthShieldAt(party, selectedIndex, state.time, state.runModifiers, 6, spellPower);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
          applyEarthShieldAt(party, index, state.time, state.runModifiers, 3, spellPower);
        }
        message = `${selected.name} gains 6 ${ability.name} charges.`;
        break;
      case "shaman-chain-heal": {
        const surgeSpent = state.rpgRun ? rpgSpellResources.tidalSurge : healerMechanic.resource;
        if (state.rpgRun) rpgSpellResources.tidalSurge = 0;
        else healerMechanic.resource = 0;
        const targets = chainHealIndexes(party, state.partyPositions, selectedIndex, 3 + surgeSpent);
        for (let order = 0; order < targets.length; order += 1) {
          const index = targets[order];
          const riptideBonus = order === 0 && healingEffect(party[index], "riptide") ? 1.25 : 1;
          party[index] = healMember(party[index], 30 * 0.75 ** order * riptideBonus * healingMultiplier);
        }
        if (state.runModifiers.radianceAppliesRenew) {
          for (let index = 0; index < party.length; index += 1) applyRiptideAt(party, index, state.time, state.runModifiers, spellPower);
        }
        if (state.runModifiers.radianceAbsorb > 0) {
          party = party.map((member) => healMember(member, state.runModifiers.radianceAbsorb * healingMultiplier));
        }
        message = `${ability.name} reaches ${targets.length} ${targets.length === 1 ? "ally" : "allies"}${surgeSpent ? ` using ${surgeSpent} Tidal Surge` : ""}.`;
        break;
      }
      case "shaman-spirit-link":
        barrier = {
          kind: "spirit-link",
          center: [...state.partyPositions.aelia],
          expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
          nextHealAt: state.time + 1,
        };
        message = `${ability.name} links allies inside a ${BARRIER_RADIUS}m circle.`;
        break;
      case "paladin-crusader-strike": {
        const requestedDamage = 18 * spellPower;
        let damage = 0;
        if (boss.hp > 0) {
          damage = Math.min(boss.hp, requestedDamage);
          boss = { ...boss, hp: boss.hp - damage };
        } else {
          const targetIndex = additionalBosses.findIndex((entry) => entry.boss.hp > 0);
          const target = additionalBosses[targetIndex];
          if (target) {
            damage = Math.min(target.boss.hp, requestedDamage);
            additionalBosses = additionalBosses.map((entry, index) => index === targetIndex
              ? { ...entry, boss: { ...entry.boss, hp: entry.boss.hp - damage } }
              : entry);
          }
        }
        const result = healMostInjured(party, 18 * healingMultiplier);
        party = result.party;
        if (state.rpgRun) rpgSpellResources.conviction = Math.min(3, rpgSpellResources.conviction + 1);
        else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1);
        message = result.targetId
          ? `${ability.name} deals ${Math.round(damage)} and restores ${Math.round(result.healing)} health.`
          : `${ability.name} deals ${Math.round(damage)} damage.`;
        break;
      }
      case "paladin-beacon-of-light":
        healerMechanic.beaconTargetId = selected.id;
        healerMechanic.beaconExpiresAt = state.time + 30;
        message = `${selected.name} becomes your Beacon of Light.`;
        break;
      case "paladin-word-of-glory": {
        const conviction = state.rpgRun ? rpgSpellResources.conviction : healerMechanic.resource;
        if (state.rpgRun) rpgSpellResources.conviction = 0;
        else healerMechanic.resource = 0;
        const amount = (18 + conviction * 14) * healingMultiplier;
        const result = healTargetAndBeacon(party, selectedIndex, amount, healerMechanic, state.time);
        party = result.party;
        if (conviction === 3) party = party.map((member) => healMember(member, 8 * healingMultiplier));
        message = `${ability.name} spends ${conviction} Conviction and restores ${Math.round(result.directHealing)} health.`;
        break;
      }
      case "paladin-avenging-crusader":
        healerMechanic.avengingCrusaderExpiresAt = state.time + 10 + state.runModifiers.barrierDurationBonus;
        message = `${ability.name} converts party damage into healing for ${10 + state.runModifiers.barrierDurationBonus} seconds.`;
        break;
      case "chronomancer-time-anchor": {
        const result = placeOrRewindTimeAnchor(party, selectedIndex, healerMechanic, state.time);
        party = result.party;
        healerMechanic = result.mechanic;
        if (result.restored > 0) {
          if (state.rpgRun) rpgSpellResources.chronoshards = Math.min(3, rpgSpellResources.chronoshards + 1);
          else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1);
        }
        message = result.anchored
          ? `${selected.name}'s timeline is anchored for 6 seconds.`
          : `${selected.name} rewinds ${Math.round(result.restored)} health.`;
        break;
      }
      case "chronomancer-echo-of-tomorrow":
        party[selectedIndex] = setHealingEffect(healMember(party[selectedIndex], 10 * healingMultiplier), {
          id: "temporal-echo",
          expiresAt: state.time + 3,
          nextTickAt: state.time + 3,
          tickInterval: 3,
          healingPerTick: 28 * spellPower,
          stacks: 1,
        });
        message = `${selected.name} receives healing now and again in 3 seconds.`;
        break;
      case "chronomancer-accelerate": {
        const chronoshards = state.rpgRun ? rpgSpellResources.chronoshards : healerMechanic.resource;
        if (state.rpgRun) rpgSpellResources.chronoshards = 0;
        else healerMechanic.resource = 0;
        party = party.map((member) => healMember(member, (10 + chronoshards * 8) * healingMultiplier));
        if (chronoshards > 0) {
          for (const slotId of Object.keys(cooldowns) as AbilitySlotId[]) {
            cooldowns[slotId] = Math.max(state.time, cooldowns[slotId] - chronoshards);
          }
        }
        message = `${ability.name} spends ${chronoshards} Chronoshards and advances active cooldowns.`;
        break;
      }
      case "chronomancer-time-loop":
        healerMechanic = startTimeLoop(party, healerMechanic, state.time + 6);
        message = `${ability.name} records every living ally for 6 seconds.`;
        break;
    }

    cooldowns[abilitySlotId] = ability.cooldown > 0
        ? state.time
          + runAbilityCooldown(abilitySlotId, ability.cooldown, state.runModifiers)
            * (state.rpgRun ? spellRankCooldownMultiplier(state.rpgRun.spellRanks, ability.id) : 1)
            * state.gearModifiers.aelia.cooldown
        : 0;
    const pulse: ScenePulse = {
      id: state.scenePulse.id + 1,
      kind: ability.pulseKind,
      targetId: ability.targeting === "party" ? "aelia" : ability.targeting === "ally" ? selected?.id : undefined,
    };

    set({
      party,
      boss,
      cooldowns,
      globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
      barrier,
      healerMechanic,
      rpgSpellResources,
      bossMotion,
      additionalBosses,
      mana: Math.max(0, state.mana - manaCost),
      combatLog: addLog(state.combatLog, state.time, message, "good"),
      scenePulse: pulse,
    });
    return true;
  },

  tick: (delta) => {
    const state = get();
    if (state.phase !== "combat" || state.paused || delta <= 0) return;

    const oldTime = state.time;
    const time = oldTime + Math.min(delta, 2);
    const healingMultiplier = effectiveHealingMultiplier(state);
    let reactiveResourceGains = 0;
    const damageLocalMember = (member: PartyMember, amount: number, at = time) => {
      const damaged = damageMember(member, amount);
      const reactive = resolveReactiveHealAfterDamage(member, damaged, at, state.gearModifiers.aelia.healingPower);
      if (reactive.triggered) reactiveResourceGains += 1;
      return reactive.member;
    };
    // Debuffs are copied only by the periodic-debuff pass below. Copying them here
    // as well doubled short-lived allocations for every simulation step.
    let party = state.party.map((member) => ({
      ...member,
      healingEffects: member.healingEffects.map((effect) => ({ ...effect })),
      reactiveHeal: member.reactiveHeal ? { ...member.reactiveHeal } : null,
    }));
    let boss = { ...state.boss };
    let bossInstanceId = state.bossInstanceId;
    let bossMotion = { ...state.bossMotion };
    let additionalBosses = state.additionalBosses.map((entry) => ({
      ...entry,
      boss: { ...entry.boss },
      motion: cloneMotion(entry.motion),
    }));
    let partyPositions = state.partyPositions;
    let combatLog = state.combatLog;
    let pulse = state.scenePulse;
    let activeCast = state.activeCast ? { ...state.activeCast } : null;
    let healerMechanic = { ...state.healerMechanic };
    let rpgSpellResources = { ...state.rpgSpellResources };
    let partyCombat = state.partyCombat;
    let partyDamageEvents = state.partyDamageEvents;
    let barrier = { ...state.barrier };
    let rpgRun = state.rpgRun;
    let rpgFocusId = state.rpgFocusId;
    let activityMode = state.activityMode;
    let endlessMode = state.endlessMode;
    let endlessBossKills = state.endlessBossKills;
    let endlessSpawnSequence = state.endlessSpawnSequence;
    let hockeyPvp = { ...state.hockeyPvp };
    let hockeyPvpOpponent: HockeyPvpOpponentState = {
      party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })),
      partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
      boss: { ...state.hockeyPvpOpponent.boss },
      bossMotion: cloneMotion(state.hockeyPvpOpponent.bossMotion),
      partyCombat: state.hockeyPvpOpponent.partyCombat,
    };
    let hockey = state.activityMode === "hockey-healing"
      ? advanceHockeyHealing(state.hockey, {
          delta: time - oldTime,
          time,
          playerPosition: state.partyPositions.aelia,
        })
      : state.hockey;
    let blockbreaker = state.activityMode === "blockbreaker"
      ? advanceBlockbreaker(state.blockbreaker, {
          delta: time - oldTime,
          time,
          playerPosition: state.partyPositions.aelia,
        })
      : state.blockbreaker;
    const aetherAdvance = state.activityMode === "aether-assault"
      ? advanceAetherAssault(state.aetherAssault, {
          delta: time - oldTime,
          time,
          playerPosition: state.partyPositions.aelia,
          canAutoFire: party[0].hp > 0 && party[0].knockedUntil <= time,
        })
      : { state: state.aetherAssault, playerDamage: 0 };
    let aetherAssault = aetherAdvance.state;
    if (aetherAdvance.playerDamage > 0 && party[0].hp > 0) {
      party[0] = damageMemberAt(
        party[0],
        aetherAdvance.playerDamage,
        state.partyPositions.aelia,
        barrier,
        time,
        partyCombat,
        state.partyPositions[partyCombat.tankAura.sourceId],
        state.difficultyDamageMultiplier,
        state.gearModifiers,
        "hazard",
        state.runModifiers.shieldDamageTakenMultiplier,
        () => { reactiveResourceGains += 1; },
      );
      combatLog = addLog(combatLog, time, `Aether strike hits ${state.playerName} for ${aetherAdvance.playerDamage} base damage.`, "danger");
    }
    const blockbreakerBreachEvents = state.activityMode === "blockbreaker"
      ? Math.max(0, blockbreaker.breaches - state.blockbreaker.breaches)
      : 0;
    if (blockbreakerBreachEvents > 0) {
      const breachDamage = BLOCKBREAKER_BREACH_DAMAGE * blockbreakerBreachEvents;
      party = party.map((member) => damageLocalMember(member, breachDamage));
      combatLog = addLog(
        combatLog,
        time,
        `Brick breach — ${breachDamage} damage to every party member.`,
        "danger",
      );
    }

    if (state.activityMode === "hockey-healing-pvp") {
      if (hockeyPvp.role === "cpu") {
        const goalie = advanceHockeyPvpCpuGoalie(
          hockeyPvp.opponentPlayerPosition,
          hockeyPvp.puckPosition,
          time - oldTime,
        );
        hockeyPvp.opponentPlayerPosition = goalie.position;
        hockeyPvp.opponentAimDirection = goalie.aimDirection;
        hockeyPvpOpponent.partyPositions.aelia = [...goalie.position];
      }
      hockeyPvp = advanceHockeyPvpPuck(hockeyPvp, {
        delta: time - oldTime,
        localPlayerPosition: state.partyPositions.aelia,
        localAimDirection: hockeyPvp.aimDirection,
        opponentPlayerPosition: hockeyPvp.opponentPlayerPosition,
        opponentAimDirection: hockeyPvp.opponentAimDirection,
      });
      const localGoals = Math.max(0, hockeyPvp.localGoalsConceded - state.hockeyPvp.localGoalsConceded);
      if (localGoals > 0) {
        party = party.map((member) => damageLocalMember(member, HOCKEY_PVP_GOAL_DAMAGE * localGoals));
        combatLog = addLog(combatLog, time, `Goal conceded — ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`, "danger");
      }
      const opponentGoals = Math.max(0, hockeyPvp.opponentGoalsConceded - state.hockeyPvp.opponentGoalsConceded);
      if (opponentGoals > 0 && hockeyPvp.role === "cpu") {
        hockeyPvpOpponent.party = hockeyPvpOpponent.party.map((member) => damageMember(member, HOCKEY_PVP_GOAL_DAMAGE * opponentGoals));
        combatLog = addLog(combatLog, time, `Goal scored — rival party takes ${HOCKEY_PVP_GOAL_DAMAGE} damage.`, "good");
      }
      hockeyPvp.appliedGoalSequence = hockeyPvp.goalSequence;
    }

    if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null;

    if (state.activityMode === "hockey-healing-pvp" && boss.hp <= 0) {
      const replacementId = hockeyPvpBossAt(hockeyPvp.seed, endlessBossKills);
      endlessSpawnSequence += 1;
      const replacement = createEncounterBoss(
        replacementId,
        0,
        1,
        DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier,
        time,
        "hockey",
      );
      boss = replacement.boss;
      bossMotion = replacement.motion;
      bossInstanceId = `hockey-pvp-${endlessSpawnSequence}-${replacementId}`;
      combatLog = addLog(combatLog, time, `${replacement.boss.name} enters both boss lanes.`, "danger");
    }

    if (state.activityMode === "hockey-healing-pvp"
      && hockeyPvp.role === "cpu"
      && hockeyPvpOpponent.boss.hp <= 0) {
      const replacementId = hockeyPvpBossAt(hockeyPvp.seed, hockeyPvp.opponentBossKills);
      const replacement = createEncounterBoss(
        replacementId,
        0,
        1,
        DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier,
        time,
        "hockey",
      );
      hockeyPvpOpponent.boss = replacement.boss;
      hockeyPvpOpponent.bossMotion = replacement.motion;
    }

    if (state.endlessMode && state.runMode !== "hockey-healing-pvp") {
      const slots: AdditionalBossState[] = [
        { instanceId: bossInstanceId, boss, motion: bossMotion },
        ...additionalBosses,
      ];
      for (let index = 0; index < slots.length; index += 1) {
        if (slots[index].boss.hp > 0) continue;
        const defeatedAt = slots[index].boss.defeatedAt ?? oldTime;
        slots[index].boss.defeatedAt = defeatedAt;
        if (time < defeatedAt + bossDeathDespawnSeconds(slots[index].boss.id)) continue;
        const activeBossIds = slots
          .filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
          .map((entry) => entry.boss.id);
        const replacementId = state.runMode === "rpg-roguelike" && rpgRun
          ? rpgRun.bossRoute[(rpgRun.bossIndex + endlessSpawnSequence + index + 1) % rpgRun.bossRoute.length]
          : selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0];
        endlessSpawnSequence += 1;
        const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
        const replacementHealthMultiplier = state.activityMode === "hockey-healing" || state.activityMode === "blockbreaker" || state.activityMode === "aether-assault"
          ? difficulty.healthMultiplier
          : bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
        const replacement = createEncounterBoss(
          replacementId,
          index,
          slots.length,
          replacementHealthMultiplier,
          time,
          state.activityMode === "hockey-healing" || state.activityMode === "blockbreaker" || state.activityMode === "aether-assault" ? "hockey" : "standard",
        );
        const instancePrefix = state.activityMode === "hockey-healing"
          ? "hockey"
          : state.activityMode === "blockbreaker"
            ? "blockbreaker"
            : state.activityMode === "aether-assault"
              ? "aether-assault"
            : "endless";
        slots[index] = { ...replacement, instanceId: `${instancePrefix}-${endlessSpawnSequence}-${replacementId}` };
        combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
      }
      boss = slots[0].boss;
      bossInstanceId = slots[0].instanceId;
      bossMotion = slots[0].motion;
      additionalBosses = slots.slice(1);
    }

    healerMechanic = expireHealerMechanics(healerMechanic, time);
    const timeLoopResult = resolveTimeLoop(party, healerMechanic, time);
    party = timeLoopResult.party;
    healerMechanic = timeLoopResult.mechanic;
    if (timeLoopResult.restored > 0) {
      combatLog = addLog(combatLog, time, `Time Loop restores ${Math.round(timeLoopResult.restored)} party health.`, "good");
      pulse = { id: pulse.id + 1, kind: "group-heal", targetId: "aelia" };
    }

    if (activeCast && activeCast.completesAt <= time) {
      const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
      const target = party[targetIndex];
      if (target?.hp > 0) {
        const castSpellPower = rpgRun
          ? spellRankPowerMultiplier(rpgRun.spellRanks, activeCast.abilityId)
          : 1;
        let healing = 0;
        if (activeCast.abilityId === "priest-mend") {
          healing = 38 * healingMultiplier * castSpellPower;
          party[targetIndex] = healMember(target, healing);
        } else if (activeCast.abilityId === "druid-regrowth") {
          healing = (24 + activeCast.resourceSpent * 7) * healingMultiplier * castSpellPower;
          party[targetIndex] = setHealingEffect(healMember(target, healing), {
            id: "regrowth",
            expiresAt: activeCast.completesAt + 6,
            nextTickAt: activeCast.completesAt + 1,
            tickInterval: 1,
            healingPerTick: 4 * castSpellPower,
            stacks: 1,
          });
        } else if (activeCast.abilityId === "shaman-healing-wave") {
          const lowHealthBonus = target.hp / target.maxHp < 0.5 ? 1.35 : 1;
          healing = (34 * lowHealthBonus + activeCast.resourceSpent * 10) * healingMultiplier * castSpellPower;
          party[targetIndex] = healMember(target, healing);
        } else if (activeCast.abilityId === "paladin-holy-light") {
          healing = 34 * healingMultiplier * castSpellPower;
          party = healTargetAndBeacon(party, targetIndex, healing, healerMechanic, activeCast.completesAt).party;
        } else if (activeCast.abilityId === "chronomancer-mend-timeline") {
          healing = 30 * healingMultiplier * castSpellPower;
          party[targetIndex] = healMember(target, healing);
        }
        for (const index of lowestHealthIndexes(party, targetIndex, state.runModifiers.mendExtraTargets)) {
          party[index] = healMember(party[index], healing * 0.5);
          if (activeCast.abilityId === "druid-regrowth") {
            party[index] = setHealingEffect(party[index], {
              id: "regrowth",
              expiresAt: activeCast.completesAt + 6,
              nextTickAt: activeCast.completesAt + 1,
              tickInterval: 1,
              healingPerTick: 2 * castSpellPower,
              stacks: 1,
            });
          }
        }
        const ability = HEALER_ABILITIES[activeCast.abilityId];
        const abilityName = ability.name;
        combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
        pulse = { id: pulse.id + 1, kind: ability.pulseKind, targetId: target.id };
      }
      activeCast = null;
    }

    party = party.map((member) => {
      const advanced = advanceHealingEffects(member, oldTime, time, healingMultiplier, healerMechanic.flourishExpiresAt);
      let next = advanced.member;
      if (rpgRun) {
        const verdancyTicks = (advanced.tickCounts.regrowth ?? 0)
          + (advanced.tickCounts.rejuvenation ?? 0)
          + (advanced.tickCounts.lifebloom ?? 0)
          + (advanced.tickCounts["wild-growth"] ?? 0);
        if (verdancyTicks > 0) rpgSpellResources.verdancy = Math.min(5, rpgSpellResources.verdancy + verdancyTicks);
      } else if (state.healerClassId === "druid" && advanced.tickCount > 0) {
        healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + advanced.tickCount);
      }
      if (next.reactiveHeal && next.reactiveHeal.expiresAt <= time) next = { ...next, reactiveHeal: null };

      const activeDebuffs = next.debuffs
        .map((debuff) => {
          let updated = { ...debuff };
          while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
            next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions[partyCombat.tankAura.sourceId], state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier, () => { reactiveResourceGains += 1; });
            updated.nextTickAt += 1;
          }
          return updated;
        })
        .filter((debuff) => debuff.expiresAt > time);
      return { ...next, debuffs: activeDebuffs };
    });

    if (barrier.kind && barrier.expiresAt > oldTime) {
      while (barrier.nextHealAt <= time && barrier.nextHealAt < barrier.expiresAt) {
        const pulseAt = barrier.nextHealAt;
        if (barrier.kind === "spirit-link") {
          party = equalizeHealthPercentages(party, state.partyPositions, barrier.center, BARRIER_RADIUS);
        }
        if (state.runModifiers.barrierHealingPerSecond > 0) {
          const healing = state.runModifiers.barrierHealingPerSecond * healingMultiplier;
          party = party.map((member) => distance(state.partyPositions[member.id], barrier.center) <= BARRIER_RADIUS
            ? healMember(member, healing)
            : member);
        }
        barrier.nextHealAt += 1;
      }
    }

    if (state.healerClassId === "druid" && healerMechanic.flourishExpiresAt > oldTime
      && state.runModifiers.barrierHealingPerSecond > 0) {
      while (healerMechanic.nextPulseAt <= time && healerMechanic.nextPulseAt < healerMechanic.flourishExpiresAt) {
        party = party.map((member) => healMember(member, state.runModifiers.barrierHealingPerSecond * healingMultiplier));
        healerMechanic.nextPulseAt += 1;
      }
    }

    const livingMotions = [
      ...(boss.hp > 0 ? [bossMotion] : []),
      ...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
    ];
    partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime, undefined, {
      brann: state.gearModifiers.brann.moveSpeed,
      nia: state.gearModifiers.nia.moveSpeed,
      orin: state.gearModifiers.orin.moveSpeed,
      vale: state.gearModifiers.vale.moveSpeed,
    });
    if (state.activityMode === "aether-assault") {
      partyPositions = {
        aelia: clampToHockeyArena(partyPositions.aelia, 0.65),
        brann: clampToHockeyEnemyHalf(partyPositions.brann, 0.55),
        nia: clampToHockeyEnemyHalf(partyPositions.nia, 0.55),
        orin: clampToHockeyEnemyHalf(partyPositions.orin, 0.55),
        vale: clampToHockeyEnemyHalf(partyPositions.vale, 0.55),
      };
    } else if (state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker") {
      partyPositions = {
        aelia: clampToHockeyHealerHalf(partyPositions.aelia, 0.65),
        brann: clampToHockeyEnemyHalf(partyPositions.brann, 0.55),
        nia: clampToHockeyEnemyHalf(partyPositions.nia, 0.55),
        orin: clampToHockeyEnemyHalf(partyPositions.orin, 0.55),
        vale: clampToHockeyEnemyHalf(partyPositions.vale, 0.55),
      };
    }

    const encounterBosses: AdditionalBossState[] = [
      { instanceId: bossInstanceId, boss, motion: bossMotion },
      ...additionalBosses,
    ];
    for (let index = 0; index < encounterBosses.length; index += 1) {
      const encounterBoss = encounterBosses[index];
      if (encounterBoss.boss.hp <= 0) continue;
      const partyBeforeMechanic = party;
      const mechanicResult = advanceBossMechanics({
        arenaLayout: state.activityMode === "aether-assault"
          ? "aether-assault"
          : state.activityMode === "hockey-healing" || state.activityMode === "blockbreaker"
          ? "hockey-healing"
          : state.activityMode === "hockey-healing-pvp"
            ? "hockey-healing-pvp"
            : "standard",
        boss: encounterBoss.boss,
        motion: encounterBoss.motion,
        party,
        partyPositions,
        time,
        delta: time - oldTime,
        damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions[partyCombat.tankAura.sourceId], state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier, () => { reactiveResourceGains += 1; }),
      });
      encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
      party = mechanicResult.party.map((member) => {
        const previous = partyBeforeMechanic.find((candidate) => candidate.id === member.id);
        if (!previous || member.knockedUntil <= previous.knockedUntil || member.knockedUntil <= time) return member;
        return { ...member, knockedUntil: time + (member.knockedUntil - time) * state.gearModifiers[member.id].stunDuration };
      });
      for (const event of mechanicResult.events) {
        combatLog = addLog(combatLog, event.at, event.message, event.tone);
        if (event.pulseKind) {
          pulse = { id: pulse.id + 1, kind: event.pulseKind, targetId: event.targetId };
        }
      }
    }

    const mechanicRemaining = encounterBosses
      .filter((entry) => entry.boss.hp > 0)
      .map((entry) => upcomingMechanic(entry.boss, entry.motion, time).remaining);
    const partyCombatResult = advancePartyCombat(partyCombat, {
      oldTime,
      time,
      party,
      oldPositions: state.partyPositions,
      positions: partyPositions,
      targets: encounterBosses,
      upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY,
      gearModifiers: aiCombatModifiers(state.gearModifiers),
      damageProfiles: state.rpgDamageProfiles ?? undefined,
    });
    partyCombat = partyCombatResult.state;
    partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24);
    for (const event of partyCombatResult.events) {
      const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
      if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
      if (healerMechanic.avengingCrusaderExpiresAt > event.at) {
        const crusaderHeal = healMostInjured(party, event.amount * 0.2 * healingMultiplier);
        party = crusaderHeal.party;
      }
    }
    for (const entry of encounterBosses) {
      if (entry.boss.hp <= 0 && entry.boss.defeatedAt === undefined) entry.boss.defeatedAt = time;
    }
    boss = encounterBosses[0].boss;
    bossInstanceId = encounterBosses[0].instanceId;
    bossMotion = encounterBosses[0].motion;
    additionalBosses = encounterBosses.slice(1);

    if (state.activityMode === "hockey-healing-pvp" && hockeyPvp.role === "cpu") {
      while (hockeyPvp.nextCpuHealAt <= time) {
        const living = hockeyPvpOpponent.party.filter((member) => member.hp > 0);
        const lowest = living.reduce<PartyMember | null>((current, member) =>
          !current || member.hp / member.maxHp < current.hp / current.maxHp ? member : current, null);
        if (lowest) {
          const averageHealth = living.reduce((total, member) => total + member.hp / member.maxHp, 0) / Math.max(1, living.length);
          hockeyPvpOpponent.party = hockeyPvpOpponent.party.map((member) => member.hp <= 0
            ? member
            : member.id === lowest.id
              ? healMember(member, (averageHealth < 0.55 ? 42 : 32) * healingMultiplier)
              : averageHealth < 0.55
                ? healMember(member, 12 * healingMultiplier)
                : member);
        }
        hockeyPvp.nextCpuHealAt += 1.1;
      }

      hockeyPvpOpponent.partyPositions = updatePartyPositions(
        hockeyPvpOpponent.partyPositions,
        hockeyPvpOpponent.boss.hp > 0 ? [hockeyPvpOpponent.bossMotion] : [],
        hockeyPvpOpponent.party,
        time,
        time - oldTime,
        undefined,
        {
          brann: state.gearModifiers.brann.moveSpeed,
          nia: state.gearModifiers.nia.moveSpeed,
          orin: state.gearModifiers.orin.moveSpeed,
          vale: state.gearModifiers.vale.moveSpeed,
        },
      );
      hockeyPvpOpponent.partyPositions = {
        aelia: clampToHockeyHealerHalf(hockeyPvp.opponentPlayerPosition, 0.65),
        brann: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.brann, 0.55),
        nia: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.nia, 0.55),
        orin: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.orin, 0.55),
        vale: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.vale, 0.55),
      };

      if (hockeyPvpOpponent.boss.hp > 0) {
        const opponentBeforeMechanic = hockeyPvpOpponent.party;
        const opponentMechanic = advanceBossMechanics({
          arenaLayout: "hockey-healing-pvp",
          boss: hockeyPvpOpponent.boss,
          motion: hockeyPvpOpponent.bossMotion,
          party: hockeyPvpOpponent.party,
          partyPositions: hockeyPvpOpponent.partyPositions,
          time,
          delta: time - oldTime,
          damageMember: (member, amount, position, at, kind) => damageMemberAt(
            member,
            amount,
            position,
            { kind: null, center: [0, 0], expiresAt: 0, nextHealAt: 0 },
            at,
            hockeyPvpOpponent.partyCombat,
            hockeyPvpOpponent.partyPositions[hockeyPvpOpponent.partyCombat.tankAura.sourceId],
            state.difficultyDamageMultiplier,
            state.gearModifiers,
            kind,
            state.runModifiers.shieldDamageTakenMultiplier,
          ),
        });
        hockeyPvpOpponent.boss = opponentMechanic.boss;
        hockeyPvpOpponent.bossMotion = constrainBossMotion(opponentMechanic.motion);
        hockeyPvpOpponent.party = opponentMechanic.party.map((member) => {
          const previous = opponentBeforeMechanic.find((candidate) => candidate.id === member.id);
          if (!previous || member.knockedUntil <= previous.knockedUntil || member.knockedUntil <= time) return member;
          return { ...member, knockedUntil: time + (member.knockedUntil - time) * state.gearModifiers[member.id].stunDuration };
        });

        const opponentCombat = advancePartyCombat(hockeyPvpOpponent.partyCombat, {
          oldTime,
          time,
          party: hockeyPvpOpponent.party,
          oldPositions: state.hockeyPvpOpponent.partyPositions,
          positions: hockeyPvpOpponent.partyPositions,
          targets: [{ instanceId: `hockey-pvp-opponent-${hockeyPvp.opponentBossKills}`, boss: hockeyPvpOpponent.boss, motion: hockeyPvpOpponent.bossMotion }],
          upcomingMechanicRemaining: upcomingMechanic(hockeyPvpOpponent.boss, hockeyPvpOpponent.bossMotion, time).remaining,
          gearModifiers: aiCombatModifiers(state.gearModifiers),
        });
        hockeyPvpOpponent.partyCombat = opponentCombat.state;
        for (const event of opponentCombat.events) {
          hockeyPvpOpponent.boss.hp = Math.max(0, hockeyPvpOpponent.boss.hp - event.amount);
        }
        if (hockeyPvpOpponent.boss.hp <= 0 && state.hockeyPvpOpponent.boss.hp > 0) {
          hockeyPvpOpponent.boss.defeatedAt = time;
          hockeyPvp.opponentBossKills += 1;
        }
      }
    }

    const healer = party.find((member) => member.id === "aelia")!;
    const healerDefeated = healer.hp <= 0;
    if (healerDefeated) activeCast = null;
    const partyWiped = isPartyWiped(party);
    const allCompanionsDefeated = areAllNonHealerAlliesDefeated(party);
    const blockbreakerAlliesDefeated = state.activityMode === "blockbreaker" && allCompanionsDefeated;
    if (blockbreakerAlliesDefeated && blockbreaker.status !== "lost") {
      blockbreaker = {
        ...blockbreaker,
        status: "lost",
        puckVelocity: [0, 0],
        reServeAt: null,
        lostAt: time,
      };
    }
    let phase: GamePhase = state.phase;
    let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
    let newlyDefeatedBossCount = 0;
    if (state.endlessMode) {
      for (let index = 0; index < encounterBosses.length; index += 1) {
        const current = encounterBosses[index];
        const previous = index === 0
          ? { instanceId: state.bossInstanceId, boss: state.boss }
          : state.additionalBosses[index - 1];
        if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue;
        newlyDefeatedBossCount += 1;
        const killLabel = state.activityMode === "hockey-healing-pvp"
          ? "PVP boss kill"
          : state.activityMode === "hockey-healing"
            ? "Hockey kill"
            : state.activityMode === "blockbreaker"
              ? "Blockbreaker kill"
              : state.activityMode === "aether-assault"
                ? "Aether Assault kill"
            : "Endless kill";
        combatLog = addLog(combatLog, time, `${current.boss.name} falls. ${killLabel} ${endlessBossKills + newlyDefeatedBossCount}.`, "good");
      }
    }
    endlessBossKills += newlyDefeatedBossCount;
    const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
    const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
    const pvpMode = state.activityMode === "hockey-healing-pvp";
    const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party);
    const rpgChallengeActive = rpgRun?.phase === "challenge-active";
    const rpgBossActive = rpgRun?.phase === "boss-combat";
    if (rpgChallengeActive && rpgRun) {
      const metrics = rpgChallengeMetrics(state.activityMode, blockbreaker, endlessBossKills, aetherAssault);
      const previousMetrics = rpgRun.currentChallenge?.metrics;
      if (!previousMetrics
        || previousMetrics.bricksBroken !== metrics.bricksBroken
        || previousMetrics.bossKills !== metrics.bossKills
        || previousMetrics.kills !== metrics.kills) {
        rpgRun = reduceRpgRoguelikeRun(rpgRun, { type: "challenge-progress", metrics });
      }
      const objective = rpgRun.currentChallenge?.objective;
      const succeeded = Boolean(objective && metrics[objective.metric] >= objective.target);
      const failed = healerDefeated || allCompanionsDefeated || partyWiped || hockeyLost || blockbreakerLost;
      if (succeeded || failed) {
        rpgRun = reduceRpgRoguelikeRun(rpgRun, { type: "challenge-complete", metrics, forcedFailure: failed });
        const cleared = rpgRun.lastChallengeResult?.succeeded ?? false;
        phase = gamePhaseForRpgRun(rpgRun);
        activityMode = "boss";
        endlessMode = false;
        rpgFocusId = normalizeRpgFocusId(rpgRun, null);
        activeCast = null;
        combatLog = addLog(
          combatLog,
          time,
          cleared ? "Hallway challenge cleared. Bonus chest quality secured." : "Hallway challenge failed. Boss route remains open.",
          cleared ? "good" : "danger",
        );
      } else {
        phase = "combat";
      }
    } else if (rpgBossActive && rpgRun) {
      if (healerDefeated) {
        const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster));
        rpgRun = reduceRpgRoguelikeRun(rpgRun, {
          type: "boss-lost",
          vitals,
          playerHp: 0,
        });
        phase = "defeat";
        endlessMode = false;
        rpgFocusId = normalizeRpgFocusId(rpgRun, null);
        combatLog = addLog(combatLog, time, "The healer falls. The expedition ends.", "danger");
      } else if (allCompanionsDefeated) {
        const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster));
        rpgRun = reduceRpgRoguelikeRun(rpgRun, {
          type: "boss-lost",
          vitals,
          playerHp: extractRpgPlayerVital(party),
        });
        phase = "defeat";
        endlessMode = false;
        rpgFocusId = normalizeRpgFocusId(rpgRun, null);
        combatLog = addLog(combatLog, time, "Every companion falls. The expedition ends.", "danger");
      } else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
        const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster));
        rpgRun = reduceRpgRoguelikeRun(rpgRun, {
          type: "boss-won",
          vitals,
          playerHp: extractRpgPlayerVital(party),
        });
        phase = "combat";
        endlessMode = false;
        rpgFocusId = normalizeRpgFocusId(rpgRun, null);
        combatLog = addLog(combatLog, time, `${boss.name} falls. North door unlocked.`, "good");
      } else if (partyWiped) {
        const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster));
        rpgRun = reduceRpgRoguelikeRun(rpgRun, {
          type: "boss-lost",
          vitals,
          playerHp: extractRpgPlayerVital(party),
        });
        phase = "defeat";
        endlessMode = false;
        rpgFocusId = normalizeRpgFocusId(rpgRun, null);
        combatLog = addLog(combatLog, time, "Boss room claims the expedition.", "danger");
      }
    } else if (rpgRun?.phase === "boss-cleared") {
      phase = "combat";
      endlessMode = false;
    } else if (pvpMode) {
      if (partyWiped) {
        phase = "defeat";
        hockeyPvp.status = "lost";
        combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
      } else if (opponentWiped) {
        phase = "victory";
        hockeyPvp.status = "won";
        combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName}'s party falls. PVP victory.`, "good");
      } else {
        phase = "combat";
      }
    } else if (state.endlessMode && (partyWiped || hockeyLost || blockbreakerLost)) {
      phase = "defeat";
      combatLog = addLog(
        combatLog,
        time,
        hockeyLost
          ? `Goal breached after ${hockey.returns} puck returns and ${endlessBossKills} boss kills.`
          : blockbreakerAlliesDefeated
            ? `All four allies fell after ${blockbreaker.bricksBroken} bricks and ${blockbreaker.score} points.`
          : state.activityMode === "aether-assault"
            ? `Party fell during wave ${aetherAssault.wave} with ${aetherAssault.score} Aether Assault points.`
          : `${endlessBossKills} bosses defeated before the party fell.`,
        "danger",
      );
    } else if (state.endlessMode) {
      phase = "combat";
    } else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
      const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
      phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
      if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
      combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
    } else if (partyWiped) {
      phase = "defeat";
      combatLog = addLog(combatLog, time, `The party falls. ${boss.name} claims the vault.`, "danger");
    }

    if (rpgRun && reactiveResourceGains > 0) {
      rpgSpellResources.tidalSurge = Math.min(2, rpgSpellResources.tidalSurge + reactiveResourceGains);
    } else if (state.healerClassId === "shaman" && reactiveResourceGains > 0) {
      healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + reactiveResourceGains);
    }

    set({
      time,
      party,
      bossId: boss.id,
      bossInstanceId,
      boss,
      additionalBosses,
      partyCombat,
      partyDamageEvents,
      partyPositions,
      bossMotion,
      phase,
      activityMode,
      endlessMode,
      rpgRun,
      rpgFocusId,
      activeTab: pvpMode && (phase === "victory" || phase === "defeat") ? "combat" : state.activeTab,
      endlessBossKills,
      endlessSpawnSequence,
      hockey,
      blockbreaker,
      aetherAssault,
      hockeyPvp,
      hockeyPvpOpponent,
      runBuffInputUnlockAt,
      mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
      activeCast,
      combatLog,
      scenePulse: pulse,
      barrier,
      healerMechanic,
      rpgSpellResources,
    });
  },
}));

export type GameSnapshot = Omit<GameState,
  | "configureHealer"
  | "startEncounter"
  | "restart"
  | "tick"
  | "castAbility"
  | "selectMember"
  | "cycleMember"
  | "setActiveTab"
  | "selectItem"
  | "setPlayerPosition"
  | "setHockeyAimDirection"
  | "applyHockeyPvpRemoteSnapshot"
  | "setPaused"
  | "togglePause"
  | "setPauseSelection"
  | "setSelectedRunBuff"
  | "chooseRunBuff"
  | "continueRoguelikeRound"
  | "startRogueTrialsEndless"
  | "setEndlessChoiceSelection"
  | "setAbilityLoadout"
  | "dispatchRpgAction"
  | "setRpgFocusId"
  | "cycleRpgFocus"
  | "moveRpgFocus"
>;

export function getGameSnapshot(): GameSnapshot {
  const {
    configureHealer: _configureHealer,
    startEncounter: _startEncounter,
    restart: _restart,
    tick: _tick,
    castAbility: _castAbility,
    selectMember: _selectMember,
    cycleMember: _cycleMember,
    setActiveTab: _setActiveTab,
    selectItem: _selectItem,
    setPlayerPosition: _setPlayerPosition,
    setHockeyAimDirection: _setHockeyAimDirection,
    applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot,
    setPaused: _setPaused,
    togglePause: _togglePause,
    setPauseSelection: _setPauseSelection,
    setSelectedRunBuff: _setSelectedRunBuff,
    chooseRunBuff: _chooseRunBuff,
    continueRoguelikeRound: _continueRoguelikeRound,
    startRogueTrialsEndless: _startRogueTrialsEndless,
    setEndlessChoiceSelection: _setEndlessChoiceSelection,
    setAbilityLoadout: _setAbilityLoadout,
    dispatchRpgAction: _dispatchRpgAction,
    setRpgFocusId: _setRpgFocusId,
    cycleRpgFocus: _cycleRpgFocus,
    moveRpgFocus: _moveRpgFocus,
    ...snapshot
  } = useGameStore.getState();
  return snapshot;
}

export function getHockeyPvpNetworkSnapshot(): HockeyPvpRemoteSnapshot | null {
  const state = useGameStore.getState();
  if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return null;
  const puck = state.hockeyPvp.role === "host" ? {
    puckPosition: [...state.hockeyPvp.puckPosition] as WorldPosition,
    puckVelocity: [...state.hockeyPvp.puckVelocity] as WorldPosition,
    localReturns: state.hockeyPvp.localReturns,
    opponentReturns: state.hockeyPvp.opponentReturns,
    localGoalsConceded: state.hockeyPvp.localGoalsConceded,
    opponentGoalsConceded: state.hockeyPvp.opponentGoalsConceded,
    goalSequence: state.hockeyPvp.goalSequence,
    lastGoalSide: state.hockeyPvp.lastGoalSide,
    serveIndex: state.hockeyPvp.serveIndex,
  } : undefined;
  return {
    sequence: Date.now(),
    time: state.time,
    party: structuredClone(state.party),
    partyPositions: structuredClone(state.partyPositions),
    boss: { id: state.boss.id, name: state.boss.name, hp: state.boss.hp, maxHp: state.boss.maxHp },
    bossPosition: [...state.bossMotion.position],
    bossMode: state.bossMotion.mode,
    bossKills: state.endlessBossKills,
    playerPosition: [...state.partyPositions.aelia],
    aimDirection: [...state.hockeyPvp.aimDirection],
    puck,
  };
}

export function abilityRemaining(abilitySlotId: AbilitySlotId, time: number, cooldowns: Record<AbilitySlotId, number>) {
  return Math.max(0, cooldowns[abilitySlotId] - time);
}

export function isRunBuffInputLocked(
  state: Pick<GameState, "phase" | "runBuffInputUnlockAt">,
  now = Date.now(),
) {
  return state.phase === "intermission" && now < state.runBuffInputUnlockAt;
}

export { upcomingMechanic };

export function upcomingEncounterMechanic(state: Pick<GameState, "boss" | "bossMotion" | "additionalBosses" | "time">) {
  const candidates = [
    ...(state.boss.hp > 0 ? [{ boss: state.boss, motion: state.bossMotion }] : []),
    ...state.additionalBosses.filter((entry) => entry.boss.hp > 0),
  ].map((entry) => upcomingMechanic(entry.boss, entry.motion, state.time));
  if (!candidates.length) {
    return { name: "Replacement incoming", remaining: 0, cycle: 1, urgent: false };
  }
  return candidates.reduce((next, candidate) => candidate.remaining < next.remaining ? candidate : next, candidates[0]);
}
