import { beforeEach, describe, expect, it, vi } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
import type { RunBuffRanks } from "./types";

function startBuffedEncounter(runBuffRanks: RunBuffRanks) {
  useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
  useGameStore.setState({ runBuffRanks, runModifiers: compileRunModifiers(runBuffRanks) });
  useGameStore.getState().startEncounter();
  useGameStore.setState((state) => ({
    boss: { ...state.boss, nextMeleeAt: 999 },
    bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
  }));
}

function testDebuff(id: string) {
  return { id, name: id, expiresAt: 10, nextTickAt: 9, tickDamage: 1 };
}

describe("Disc Priest combat simulation", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
    useGameStore.getState().startEncounter();
  });

  it("resolves Mend after a 0.5 second cast with no cooldown", () => {
    const party = useGameStore.getState().party.map((member) =>
      member.id === "nia" ? { ...member, hp: 40 } : member,
    );
    useGameStore.setState({ party });
    useGameStore.getState().selectMember("nia");

    expect(useGameStore.getState().castAbility("mend")).toBe(true);
    useGameStore.getState().tick(0.4);
    expect(useGameStore.getState().party.find((member) => member.id === "nia")?.hp).toBe(40);
    useGameStore.getState().tick(0.11);

    const state = useGameStore.getState();
    expect(state.party.find((member) => member.id === "nia")?.hp).toBe(78);
    expect(state.activeCast).toBeNull();
    expect(state.cooldowns.mend).toBe(0);
    expect(state.castAbility("mend")).toBe(true);
  });

  it("uses the rebalanced Priest mana costs", () => {
    expect(Object.fromEntries(Object.entries(HEALER_CLASSES.priest.abilities).map(([id, ability]) => [id, ability.mana]))).toEqual({
      mend: 5,
      renew: 7,
      shield: 8,
      purify: 5,
      radiance: 12,
      barrier: 10,
    });
  });

  it("uses a three-second Purify cooldown for every healer class", () => {
    expect(HEALER_CLASSES.priest.abilities.purify.cooldown).toBe(3);
    expect(HEALER_CLASSES.druid.abilities.purify.cooldown).toBe(3);
    expect(HEALER_CLASSES.shaman.abilities.purify.cooldown).toBe(3);
  });

  it("configures placeholder healer kits and class-owned inventory", () => {
    const inventory = createClassInventory("druid");
    useGameStore.getState().configureHealer("druid", "Aelia", inventory);

    const state = useGameStore.getState();
    expect(state.healerClassId).toBe("druid");
    expect(state.party[0].className).toBe("Restoration Druid");
    expect(state.party[0].name).toBe("Aelia");
    expect(state.inventory).toEqual(inventory);
    expect(HEALER_CLASSES.druid.abilities.mend.name).toBe("Healing Touch");
    expect(HEALER_CLASSES.shaman.abilities.radiance.name).toBe("Chain Heal");
  });

  it("ticks Renew once per second for eight seconds", () => {
    const party = useGameStore.getState().party.map((member) =>
      member.id === "brann" ? { ...member, hp: 70 } : member,
    );
    useGameStore.setState({ party });
    useGameStore.getState().castAbility("renew");

    for (let index = 0; index < 8; index += 1) useGameStore.getState().tick(1);

    const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
    expect(brann.renewExpiresAt).toBe(0);
    expect(brann.hp).toBeGreaterThan(70);
  });

  it("gives Renew no individual cooldown", () => {
    expect(useGameStore.getState().castAbility("renew")).toBe(true);
    expect(useGameStore.getState().cooldowns.renew).toBe(0);

    useGameStore.getState().tick(0.5);
    expect(useGameStore.getState().castAbility("renew")).toBe(true);
    expect(useGameStore.getState().cooldowns.renew).toBe(0);
  });

  it("blocks all abilities during the shared 0.5 second global cooldown", () => {
    expect(useGameStore.getState().castAbility("renew")).toBe(true);
    expect(useGameStore.getState().castAbility("shield")).toBe(false);

    useGameStore.getState().tick(0.49);
    expect(useGameStore.getState().castAbility("shield")).toBe(false);

    useGameStore.getState().tick(0.02);
    expect(useGameStore.getState().castAbility("shield")).toBe(true);
  });

  it("uses blue absorption before health", () => {
    useGameStore.getState().castAbility("shield");
    useGameStore.getState().tick(2);

    const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
    expect(brann.hp).toBe(150);
    expect(brann.absorb).toBe(21);
  });

  it("Purify removes Ember Brand from selected ally", () => {
    useGameStore.setState((state) => ({
      bossMotion: { ...state.bossMotion, mechanicCount: 3, nextMechanicAt: state.time },
    }));
    useGameStore.getState().tick(0.1);
    const branded = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Ember Brand"))!;
    expect(branded.debuffs).toHaveLength(1);

    useGameStore.getState().selectMember(branded.id);
    expect(useGameStore.getState().castAbility("purify")).toBe(true);
    expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0);
  });

  it("Radiance heals every living party member", () => {
    useGameStore.setState({
      party: useGameStore.getState().party.map((member) => ({ ...member, hp: member.hp - 30 })),
    });
    useGameStore.getState().castAbility("radiance");

    for (const member of useGameStore.getState().party) {
      expect(member.hp).toBe(member.maxHp - 8);
    }
  });

  it("reduces damage by 30% for party members inside Barrier", () => {
    useGameStore.getState().setPlayerPosition([0, -3.7]);
    useGameStore.setState((state) => ({
      boss: { ...state.boss },
    }));
    expect(useGameStore.getState().castAbility("barrier")).toBe(true);
    useGameStore.getState().tick(2);

    const state = useGameStore.getState();
    expect(state.party.find((member) => member.id === "brann")?.hp).toBeCloseTo(139.5, 4);
    expect(barrierProtects(state.partyPositions.brann, state.barrier, state.time)).toBe(true);
    expect(state.cooldowns.barrier).toBe(60);
  });

  it("expires Barrier after eight seconds", () => {
    useGameStore.getState().castAbility("barrier");
    const barrier = useGameStore.getState().barrier;
    expect(barrierProtects(barrier.center, barrier, 7.99)).toBe(true);
    expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
  });

  it("keeps ranged allies stable while Vale holds behind the boss", () => {
    const start = structuredClone(useGameStore.getState().partyPositions);
    const bossPosition = useGameStore.getState().bossMotion.position;
    useGameStore.getState().tick(1);
    const moved = useGameStore.getState().partyPositions;
    expect(moved.aelia).toEqual(start.aelia);
    expect(moved.brann).toEqual(start.brann);
    expect(moved.nia).toEqual(start.nia);
    expect(moved.orin).toEqual(start.orin);
    expect(moved.brann[1]).toBeGreaterThan(bossPosition[1]);
    expect(moved.vale[1]).toBeLessThan(bossPosition[1]);
  });

  it("freezes authoritative simulation while paused", () => {
    useGameStore.getState().tick(1);
    const before = useGameStore.getState();
    useGameStore.getState().setPaused(true);
    useGameStore.getState().tick(5);

    const paused = useGameStore.getState();
    expect(paused.time).toBe(before.time);
    expect(paused.party).toEqual(before.party);
    expect(paused.boss.hp).toBe(before.boss.hp);
    expect(paused.castAbility("renew")).toBe(false);
  });

  it("cancels an active cast and blocks new abilities when the healer falls", () => {
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
      bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
      party: state.party.map((member) => member.id === "nia" ? { ...member, hp: 40 } : member),
    }));
    useGameStore.getState().selectMember("nia");
    expect(useGameStore.getState().castAbility("mend")).toBe(true);
    useGameStore.setState((state) => ({
      party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
    }));

    useGameStore.getState().tick(0.6);
    const state = useGameStore.getState();
    expect(state.phase).toBe("combat");
    expect(state.activeCast).toBeNull();
    expect(state.party.find((member) => member.id === "nia")?.hp).toBe(40);
    expect(state.castAbility("renew")).toBe(false);
  });

  it("does not publish unchanged player positions while idle", () => {
    let updates = 0;
    const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
    const start = useGameStore.getState().playerPosition;

    useGameStore.getState().setPlayerPosition([start[0], start[1]]);
    expect(updates).toBe(0);

    useGameStore.getState().setPlayerPosition([start[0] + 0.25, start[1]]);
    expect(updates).toBe(1);
    unsubscribe();
  });

  it("telegraphs, executes, and recovers from a Bull charge", () => {
    while (useGameStore.getState().bossMotion.mode !== "telegraph") useGameStore.getState().tick(0.1);
    const telegraph = useGameStore.getState().bossMotion;
    expect(telegraph.mode).toBe("telegraph");
    const targetId = telegraph.chargeTargetId;

    const midpoint: [number, number] = [
      (telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
      (telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
    ];
    useGameStore.setState((state) => ({
      partyPositions: { ...state.partyPositions, [targetId]: midpoint },
      party: state.party.map((member) => member.id === targetId ? { ...member, knockedUntil: state.time + 10 } : member),
    }));

    while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
    for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes(targetId); step += 1) {
      useGameStore.getState().tick(0.05);
    }

    const hitState = useGameStore.getState();
    expect(hitState.bossMotion.chargeHitIds).toContain(targetId);
    expect(hitState.party.find((member) => member.id === targetId)!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);

    for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
      useGameStore.getState().tick(0.1);
    }
    const recovered = useGameStore.getState();
    expect(recovered.bossMotion.mode).toBe("holding");
    expect(recovered.bossMotion.nextMechanicAt).toBeGreaterThan(recovered.time);
  });

  it("moves every mobile AI party member out of the charge lane before impact", () => {
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
    }));
    while (useGameStore.getState().bossMotion.mode !== "telegraph") {
      useGameStore.getState().tick(0.1);
    }

    const warning = useGameStore.getState().bossMotion;
    const exposedAtWarning = (["brann", "nia", "orin", "vale"] as const).filter((memberId) =>
      pointToSegmentDistance(
        useGameStore.getState().partyPositions[memberId],
        warning.chargeStart,
        warning.chargeEnd,
      ) <= BULL_CHARGE.hitRadius,
    );
    expect(exposedAtWarning).toContain(warning.chargeTargetId);

    while (useGameStore.getState().bossMotion.mode === "telegraph") {
      useGameStore.getState().tick(0.1);
    }
    const charge = useGameStore.getState();
    for (const memberId of exposedAtWarning) {
      expect(pointToSegmentDistance(
        charge.partyPositions[memberId],
        charge.bossMotion.chargeStart,
        charge.bossMotion.chargeEnd,
      )).toBeGreaterThan(BULL_CHARGE.hitRadius);
    }

    while (useGameStore.getState().bossMotion.mode === "charging") {
      useGameStore.getState().tick(0.05);
    }
    const hitIds = useGameStore.getState().bossMotion.chargeHitIds;
    for (const memberId of exposedAtWarning) expect(hitIds).not.toContain(memberId);
  });

  it("marks a stack target after three charges and splits 200 pounce damage", () => {
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
      playerPosition: [0, 0],
      partyPositions: {
        aelia: [0, 0],
        brann: [0, 0],
        nia: [0, 0],
        orin: [0, 0],
        vale: [0, 0],
      },
      bossMotion: { ...state.bossMotion, activeMechanicId: null, mode: "holding", position: [0, -1], mechanicCount: 1, nextMechanicAt: state.time },
    }));
    const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));

    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
    expect(useGameStore.getState().bossMotion.pounceTargetId).toBeTruthy();
    expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);

    for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
      useGameStore.getState().tick(0.1);
    }
    expect(useGameStore.getState().bossMotion.mode).toBe("pouncing");

    for (let step = 0; step < 20 && useGameStore.getState().bossMotion.mode === "pouncing"; step += 1) {
      useGameStore.getState().tick(0.05);
    }
    const impacted = useGameStore.getState();
    expect(impacted.bossMotion.mode).toBe("holding");
    for (const member of impacted.party) {
      expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
    }
    expect(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
  });
});

describe("shared party wipe rules", () => {
  it.each(["encounter", "roguelike", "rogue-trials"] as const)(
    "keeps %s combat running after individual party deaths",
    (runMode) => {
      useGameStore.getState().configureHealer(
        "priest",
        "Aelia",
        createClassInventory("priest"),
        ["bulldrome", "broodfang-spider"],
        runMode,
      );
      useGameStore.getState().startEncounter();
      useGameStore.setState((state) => ({
        boss: { ...state.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
        bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
        additionalBosses: state.additionalBosses.map((entry) => ({
          ...entry,
          boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
          motion: { ...entry.motion, nextMechanicAt: 999 },
        })),
        party: state.party.map((member) => member.id === "aelia" || member.id === "brann" ? { ...member, hp: 0 } : member),
      }));

      useGameStore.getState().tick(0.05);
      expect(useGameStore.getState().phase).toBe("combat");

      useGameStore.setState((state) => ({
        party: state.party.map((member) => ({ ...member, hp: 0 })),
      }));
      useGameStore.getState().tick(0.05);
      expect(useGameStore.getState().phase).toBe("defeat");
    },
  );

  it.each([
    ["encounter", "victory"],
    ["roguelike", "intermission"],
    ["rogue-trials", "intermission"],
  ] as const)("awards %s boss completion when both sides fall on the same tick", (runMode, expectedPhase) => {
    useGameStore.getState().configureHealer(
      "priest",
      "Aelia",
      createClassInventory("priest"),
      ["bulldrome", "broodfang-spider"],
      runMode,
    );
    useGameStore.getState().startEncounter();
    useGameStore.setState((state) => ({
      boss: { ...state.boss, hp: 0 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
      party: state.party.map((member) => ({ ...member, hp: 0 })),
    }));

    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().phase).toBe(expectedPhase);
  });
});

describe("Broodfang encounter", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
    useGameStore.getState().startEncounter();
    useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
  });

  it("lets modular party behavior spread tethered allies until Binding Web snaps", () => {
    while (useGameStore.getState().bossMotion.mode !== "tethering") useGameStore.getState().tick(0.1);
    const tether = useGameStore.getState().bossMotion;
    expect(tether.tetherIds).toEqual(["brann", "vale"]);
    const [first, second] = tether.tetherIds;
    expect(Math.hypot(
      useGameStore.getState().partyPositions[first][0] - useGameStore.getState().partyPositions[second][0],
      useGameStore.getState().partyPositions[first][1] - useGameStore.getState().partyPositions[second][1],
    )).toBeLessThan(tether.tetherBreakDistance);

    for (let step = 0; step < 50 && useGameStore.getState().bossMotion.mode === "tethering"; step += 1) {
      useGameStore.getState().tick(0.1);
    }
    expect(useGameStore.getState().bossMotion.mode).toBe("holding");
    expect(useGameStore.getState().bossMotion.tetherIds).toEqual([]);
  });

  it("drops a persistent venom pool when Widow Venom is purified", () => {
    useGameStore.setState((state) => ({
      bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
    }));
    useGameStore.getState().tick(0.05);
    const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
    expect(poisoned).toBeDefined();

    useGameStore.getState().selectMember(poisoned.id);
    expect(useGameStore.getState().castAbility("purify")).toBe(true);
    const state = useGameStore.getState();
    expect(state.party.find((member) => member.id === poisoned.id)?.debuffs).toEqual([]);
    expect(state.bossMotion.hazards).toHaveLength(1);
    expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
  });

  it("arms a lower-damage venom pool after giving the party time to move", () => {
    useGameStore.getState().setPlayerPosition([0, 0]);
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
      bossMotion: {
        ...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
        nextMechanicAt: 999,
      },
    }));
    const startingHp = useGameStore.getState().party[0].hp;

    useGameStore.getState().tick(VENOM_PURGE.poolArmDelay - 0.05);
    expect(useGameStore.getState().party[0].hp).toBe(startingHp);

    useGameStore.getState().tick(0.1);
    const firstTickHp = useGameStore.getState().party[0].hp;
    useGameStore.getState().tick(1);
    const secondTickHp = useGameStore.getState().party[0].hp;

    expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage);
    expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage);

    useGameStore.getState().setPlayerPosition([6, 6]);
    useGameStore.getState().tick(1);
    expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
  });

  it("returns to the room center when the tank is displaced", () => {
    useGameStore.setState((state) => ({
      partyPositions: { ...state.partyPositions, brann: [3, 1] },
      bossMotion: { ...state.bossMotion, position: [5, 5], nextMechanicAt: 999 },
    }));
    const start = useGameStore.getState().bossMotion.position;
    useGameStore.getState().tick(0.5);
    const result = useGameStore.getState().bossMotion.position;
    expect(Math.hypot(result[0] - ARENA_CENTER[0], result[1] - ARENA_CENTER[1])).toBeLessThan(
      Math.hypot(start[0] - ARENA_CENTER[0], start[1] - ARENA_CENTER[1]),
    );
  });
});

describe("PVE dual-boss encounter", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["broodfang-spider", "tempestscale-dragon"]);
    useGameStore.getState().startEncounter();
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, nextMeleeAt: 999 } })),
    }));
  });

  it("runs two distinct bosses concurrently and requires both to fall", () => {
    const initial = useGameStore.getState();
    expect(initial.boss.id).toBe("broodfang-spider");
    expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["tempestscale-dragon"]);
    expect(initial.bossMotion.position[0]).toBeLessThan(initial.additionalBosses[0].motion.position[0]);

    useGameStore.getState().tick(1);
    const damaged = useGameStore.getState();
    const primaryDamage = damaged.partyDamageEvents
      .filter((event) => event.targetInstanceId === `boss-0-${damaged.boss.id}`)
      .reduce((sum, event) => sum + event.amount, 0);
    const secondaryDamage = damaged.partyDamageEvents
      .filter((event) => event.targetInstanceId === damaged.additionalBosses[0].instanceId)
      .reduce((sum, event) => sum + event.amount, 0);
    expect(primaryDamage + secondaryDamage).toBeGreaterThan(0);
    expect(damaged.boss.hp).toBeCloseTo(damaged.boss.maxHp - primaryDamage, 4);
    expect(damaged.additionalBosses[0].boss.hp).toBeCloseTo(damaged.additionalBosses[0].boss.maxHp - secondaryDamage, 4);

    useGameStore.setState((state) => ({
      boss: { ...state.boss, hp: 0 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 1 } })),
    }));
    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().phase).toBe("combat");

    useGameStore.getState().tick(1);
    expect(useGameStore.getState().phase).toBe("victory");
  });

  it("drops a dispelled venom pool only for the spider that applied the debuff", () => {
    useGameStore.setState((state) => ({
      bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
      additionalBosses: state.additionalBosses.map((entry) => ({
        ...entry,
        motion: { ...entry.motion, nextMechanicAt: 999 },
      })),
    }));
    useGameStore.getState().tick(0.05);
    const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
    useGameStore.getState().selectMember(poisoned.id);

    expect(useGameStore.getState().castAbility("purify")).toBe(true);
    expect(useGameStore.getState().bossMotion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(1);
    expect(useGameStore.getState().additionalBosses[0].motion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(0);
  });
});

describe("Roguelike ability buffs", () => {
  it("reduces Mend cost and cast time while echoing to lowest-health allies", () => {
    startBuffedEncounter({ "mend-echo": 2, "mend-efficiency": 3, "mend-cast-speed": 3 });
    useGameStore.setState((state) => ({
      party: state.party.map((member) => ({
        ...member,
        hp: member.id === "aelia" ? 90 : member.id === "brann" ? 60 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : 30,
      })),
    }));
    useGameStore.getState().selectMember("brann");

    expect(useGameStore.getState().castAbility("mend")).toBe(true);
    expect(useGameStore.getState().mana).toBe(97);
    expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
    useGameStore.getState().tick(0.22);

    const party = useGameStore.getState().party;
    expect(party.find((member) => member.id === "brann")?.hp).toBe(98);
    expect(party.find((member) => member.id === "nia")?.hp).toBe(29);
    expect(party.find((member) => member.id === "orin")?.hp).toBe(39);
    expect(party.find((member) => member.id === "vale")?.hp).toBe(30);
  });

  it("spreads longer, stronger Renew effects without recursive targeting", () => {
    startBuffedEncounter({ "renew-spread": 2, "renew-duration": 2, "renew-potency": 2 });
    useGameStore.setState((state) => ({
      party: state.party.map((member) => ({
        ...member,
        hp: member.id === "brann" ? 50 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
      })),
    }));
    useGameStore.getState().selectMember("brann");
    expect(useGameStore.getState().castAbility("renew")).toBe(true);

    let party = useGameStore.getState().party;
    expect(party.filter((member) => member.renewExpiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]);
    useGameStore.getState().tick(1.01);
    party = useGameStore.getState().party;
    expect(party.find((member) => member.id === "brann")?.hp).toBeCloseTo(59.8);
    expect(party.find((member) => member.id === "nia")?.hp).toBeCloseTo(19.8);
    expect(party.find((member) => member.id === "orin")?.hp).toBeCloseTo(29.8);
  });

  it("strengthens and echoes Shield to deterministic secondary targets", () => {
    startBuffedEncounter({ "shield-echo": 2, "shield-potency": 2 });
    useGameStore.setState((state) => ({
      party: state.party.map((member) => ({
        ...member,
        hp: member.id === "brann" ? 70 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
      })),
    }));
    useGameStore.getState().selectMember("brann");
    expect(useGameStore.getState().castAbility("shield")).toBe(true);

    const party = useGameStore.getState().party;
    expect(party.find((member) => member.id === "brann")?.absorb).toBe(54);
    expect(party.find((member) => member.id === "nia")?.absorb).toBe(27);
    expect(party.find((member) => member.id === "orin")?.absorb).toBe(27);
  });

  it("chains Purify and applies triggered Renew and half-strength Shield", () => {
    startBuffedEncounter({ "purify-renew": 1, "purify-shield": 1, "purify-chain": 1 });
    useGameStore.setState((state) => ({
      party: state.party.map((member) => member.id === "brann"
        ? { ...member, hp: 70, debuffs: [testDebuff("tank-mark")] }
        : member.id === "nia"
          ? { ...member, hp: 10, debuffs: [testDebuff("ranger-mark")] }
          : member.id === "orin"
            ? { ...member, hp: 20, debuffs: [testDebuff("mage-mark")] }
            : member),
    }));
    useGameStore.getState().selectMember("brann");
    expect(useGameStore.getState().castAbility("purify")).toBe(true);

    const party = useGameStore.getState().party;
    for (const id of ["brann", "nia"] as const) {
      const member = party.find((candidate) => candidate.id === id)!;
      expect(member.debuffs).toEqual([]);
      expect(member.renewExpiresAt).toBe(8);
      expect(member.absorb).toBe(18);
    }
    expect(party.find((member) => member.id === "orin")?.debuffs).toHaveLength(1);
  });

  it("applies Radiance cooldown, party Renew, and party absorption", () => {
    startBuffedEncounter({ "radiance-cooldown": 3, "radiance-renew": 1, "radiance-shield": 2 });
    useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 30) })) }));
    expect(useGameStore.getState().castAbility("radiance")).toBe(true);

    const state = useGameStore.getState();
    expect(state.cooldowns.radiance).toBeCloseTo(14 * 0.8 ** 3);
    expect(state.party.every((member) => member.renewExpiresAt === 8)).toBe(true);
    expect(state.party.every((member) => member.absorb === 18)).toBe(true);
  });

  it("extends, quickens, and pulses healing from Barrier", () => {
    startBuffedEncounter({ "barrier-cooldown": 3, "barrier-duration": 3, "barrier-regen": 3 });
    useGameStore.setState((state) => ({
      party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 50 } : member),
    }));
    expect(useGameStore.getState().castAbility("barrier")).toBe(true);
    expect(useGameStore.getState().cooldowns.barrier).toBeCloseTo(60 * 0.8 ** 3);
    expect(useGameStore.getState().barrier.expiresAt).toBe(14);
    useGameStore.getState().tick(1.01);
    expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBe(59);
    expect(useGameStore.getState().barrier.nextHealAt).toBe(2);
  });

  it("reduces incoming damage while absorption is present", () => {
    startBuffedEncounter({ "shield-guard": 3 });
    useGameStore.setState((state) => ({
      party: state.party.map((member) => member.id === "aelia" ? {
        ...member,
        hp: 50,
        absorb: 100,
        debuffs: [{ id: "pulse", name: "Pulse", expiresAt: 2, nextTickAt: 0.5, tickDamage: 10 }],
      } : member),
    }));
    useGameStore.getState().tick(1);
    expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.absorb).toBeCloseTo(92.4);
  });
});

describe("Roguelike rounds", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer(
      "priest",
      "Aelia",
      createClassInventory("priest"),
      ["bulldrome", "broodfang-spider"],
      "roguelike",
    );
    useGameStore.getState().startEncounter();
  });

  it("blocks progression for a buff, then starts round two with new bosses at 110% base HP", () => {
    const roundOne = useGameStore.getState();
    const previousBossIds = [roundOne.boss.id, roundOne.additionalBosses[0].boss.id];
    useGameStore.setState((state) => ({
      boss: { ...state.boss, hp: 0 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
    }));

    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().phase).toBe("intermission");
    const intermissionTime = useGameStore.getState().time;
    useGameStore.getState().tick(10);
    expect(useGameStore.getState().phase).toBe("intermission");
    expect(useGameStore.getState().round).toBe(1);
    expect(useGameStore.getState().time).toBe(intermissionTime);
    const chosenBuffId = useGameStore.getState().draftBuffIds[0]!;
    expect(chosenBuffId).toBeDefined();
    useGameStore.setState({ runBuffInputUnlockAt: 0 });
    expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true);

    const roundTwo = useGameStore.getState();
    const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
    expect(roundTwo.phase).toBe("combat");
    expect(roundTwo.round).toBe(2);
    expect(roundTwo.runBuffRanks[chosenBuffId]).toBe(1);
    expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
    expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
    expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
    expect(roundTwo.party[0].maxHp).toBe(100);
  });

  it("rejects buff claims outside intermission", () => {
    expect(useGameStore.getState().chooseRunBuff("mend-efficiency")).toBe(false);
    expect(useGameStore.getState().round).toBe(1);
  });

  it("ignores all buff-selection input for 2.5 seconds after the bosses fall", () => {
    const defeatedAt = 1_000_000;
    const now = vi.spyOn(Date, "now").mockReturnValue(defeatedAt);
    useGameStore.setState((state) => ({
      boss: { ...state.boss, hp: 0 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
    }));

    useGameStore.getState().tick(0.05);
    const lockedState = useGameStore.getState();
    const initialSelection = lockedState.selectedRunBuffId!;
    const alternateSelection = lockedState.draftBuffIds.find((buffId) => buffId !== initialSelection)!;
    expect(lockedState.runBuffInputUnlockAt).toBe(defeatedAt + RUN_BUFF_INPUT_LOCK_MS);

    useGameStore.getState().setSelectedRunBuff(alternateSelection);
    expect(useGameStore.getState().selectedRunBuffId).toBe(initialSelection);
    expect(useGameStore.getState().chooseRunBuff(initialSelection)).toBe(false);

    now.mockReturnValue(defeatedAt + RUN_BUFF_INPUT_LOCK_MS - 1);
    expect(useGameStore.getState().chooseRunBuff(initialSelection)).toBe(false);

    now.mockReturnValue(defeatedAt + RUN_BUFF_INPUT_LOCK_MS);
    useGameStore.getState().setSelectedRunBuff(alternateSelection);
    expect(useGameStore.getState().selectedRunBuffId).toBe(alternateSelection);
    expect(useGameStore.getState().chooseRunBuff(alternateSelection)).toBe(true);
    now.mockRestore();
  });

  it("offers explicit continuation after every buff reaches maximum rank", () => {
    const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
    useGameStore.setState({
      phase: "intermission",
      runBuffRanks: maxedRanks,
      runModifiers: compileRunModifiers(maxedRanks),
      draftBuffIds: [],
      selectedRunBuffId: null,
      runBuffInputUnlockAt: Date.now() + RUN_BUFF_INPUT_LOCK_MS,
    });

    expect(useGameStore.getState().continueRoguelikeRound()).toBe(false);
    useGameStore.setState({ runBuffInputUnlockAt: 0 });
    expect(useGameStore.getState().continueRoguelikeRound()).toBe(true);
    expect(useGameStore.getState().phase).toBe("combat");
    expect(useGameStore.getState().round).toBe(2);
    expect(useGameStore.getState().runBuffRanks).toEqual(maxedRanks);
    expect(useGameStore.getState().draftBuffIds).toEqual([]);
    expect(useGameStore.getState().continueRoguelikeRound()).toBe(false);
  });
});

describe("Rogue Trials", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer(
      "priest",
      "Aelia",
      createClassInventory("priest"),
      ["bulldrome", "broodfang-spider"],
      "rogue-trials",
    );
    useGameStore.getState().startEncounter();
  });

  it("builds toward an unseen three-boss finale on round five", () => {
    for (let expectedRound = 2; expectedRound <= 5; expectedRound += 1) {
      const before = useGameStore.getState();
      const seenBefore = [...before.seenBossIds];
      useGameStore.setState((state) => ({
        phase: "intermission",
        runBuffInputUnlockAt: 0,
        boss: { ...state.boss, hp: 0 },
        additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
      }));

      const buffId = useGameStore.getState().draftBuffIds[0]!;
      expect(useGameStore.getState().chooseRunBuff(buffId)).toBe(true);
      const next = useGameStore.getState();
      const nextBossIds = [next.boss.id, ...next.additionalBosses.map((entry) => entry.boss.id)];
      expect(next.round).toBe(expectedRound);
      expect(nextBossIds).toHaveLength(expectedRound === 5 ? 3 : 2);
      expect(nextBossIds.every((bossId) => !seenBefore.includes(bossId))).toBe(true);
    }

    const finale = useGameStore.getState();
    expect(finale.seenBossIds).toHaveLength(11);
    expect(finale.additionalBosses).toHaveLength(2);
  });

  it("ends in victory after all three finale bosses fall", () => {
    useGameStore.getState().configureHealer(
      "priest",
      "Aelia",
      createClassInventory("priest"),
      ["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
      "rogue-trials",
    );
    useGameStore.getState().startEncounter();
    useGameStore.setState((state) => ({
      round: 5,
      phase: "combat",
      boss: { ...state.boss, hp: 0 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
    }));

    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().phase).toBe("victory");
  });

  it("offers endless mode, counts each kill, and refills the dead boss slot", () => {
    useGameStore.getState().configureHealer(
      "priest",
      "Aelia",
      createClassInventory("priest"),
      ["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
      "rogue-trials",
    );
    useGameStore.setState({ round: 5, phase: "victory" });

    expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
    const started = useGameStore.getState();
    expect(started.phase).toBe("combat");
    expect(started.endlessMode).toBe(true);
    expect(started.endlessBossKills).toBe(0);
    expect(started.additionalBosses).toHaveLength(2);
    useGameStore.setState((state) => ({
      boss: { ...state.boss, hp: 1, nextMeleeAt: 999 },
      bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
      additionalBosses: state.additionalBosses.map((entry) => ({
        ...entry,
        boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
        motion: { ...entry.motion, nextMechanicAt: 999 },
      })),
    }));

    for (let step = 0; step < 50 && useGameStore.getState().endlessBossKills === 0; step += 1) {
      useGameStore.getState().tick(0.1);
    }
    const defeated = useGameStore.getState();
    expect(defeated.endlessBossKills).toBe(1);
    expect(defeated.boss.hp).toBe(0);
    const defeatedInstanceId = defeated.bossInstanceId;

    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
    const despawnAt = useGameStore.getState().boss.defeatedAt! + BOSS_DEATH_DESPAWN_SECONDS;
    while (useGameStore.getState().time + 0.11 < despawnAt) useGameStore.getState().tick(0.1);
    expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
    useGameStore.getState().tick(0.12);
    const replaced = useGameStore.getState();
    expect(replaced.phase).toBe("combat");
    expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
    expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
    expect(replaced.endlessBossKills).toBe(1);
    expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
  });

  it("keeps endless mode running until the whole party falls", () => {
    useGameStore.setState((state) => ({
      round: 5,
      phase: "victory",
      boss: { ...state.boss, hp: 0 },
      additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
    }));
    expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
    useGameStore.setState((state) => ({
      endlessBossKills: 7,
      party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
    }));

    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().phase).toBe("combat");
    expect(useGameStore.getState().endlessBossKills).toBe(7);

    useGameStore.setState((state) => ({
      party: state.party.map((member) => ({ ...member, hp: 0 })),
    }));
    useGameStore.getState().tick(0.05);
    expect(useGameStore.getState().phase).toBe("defeat");
    expect(useGameStore.getState().endlessBossKills).toBe(7);
  });

  it("restarts a completed trial with a fresh two-boss first round", () => {
    useGameStore.getState().configureHealer(
      "priest",
      "Aelia",
      createClassInventory("priest"),
      ["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
      "rogue-trials",
    );
    useGameStore.setState({ round: 5, phase: "victory" });

    useGameStore.getState().restart();
    const restarted = useGameStore.getState();
    expect(restarted.round).toBe(1);
    expect(restarted.phase).toBe("briefing");
    expect(restarted.additionalBosses).toHaveLength(1);
    expect(restarted.seenBossIds).toHaveLength(2);
  });
});

describe("shared arena boundary", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
    useGameStore.getState().startEncounter();
  });

  it("constrains player, party, and boss positions to the same room", () => {
    useGameStore.getState().setPlayerPosition([100, 100]);
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
      bossMotion: { ...state.bossMotion, position: [100, -100] },
      partyPositions: {
        ...state.partyPositions,
        brann: [50, 50],
        nia: [-50, 50],
        orin: [50, -50],
        vale: [-50, -50],
      },
    }));

    useGameStore.getState().tick(0.1);
    const state = useGameStore.getState();
    expect(isInsideArena(state.playerPosition)).toBe(true);
    for (const position of Object.values(state.partyPositions)) expect(isInsideArena(position)).toBe(true);
    expect(isInsideArena(state.bossMotion.position)).toBe(true);
  });
});

describe("Tempestscale encounter", () => {
  beforeEach(() => {
    useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "tempestscale-dragon");
    useGameStore.getState().startEncounter();
    useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
  });

  it("rotates Searing Sweep while AI party behavior moves to safe flanks", () => {
    while (useGameStore.getState().bossMotion.mode !== "breath_telegraph") useGameStore.getState().tick(0.1);
    expect(useGameStore.getState().bossMotion.breathEndAngle).not.toBe(useGameStore.getState().bossMotion.breathStartAngle);

    for (let step = 0; step < 80 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
      useGameStore.getState().tick(0.1);
    }
    const hitIds = useGameStore.getState().bossMotion.mechanicHitIds;
    for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hitIds).not.toContain(memberId);
  });

  it("creates three staggered Skyfall impacts and moves AI clear", () => {
    useGameStore.setState((state) => ({
      bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
    }));
    useGameStore.getState().tick(0.05);
    const warning = useGameStore.getState();
    expect(warning.bossMotion.mode).toBe("skyfall");
    expect(warning.bossMotion.hazards).toHaveLength(3);
    expect(warning.bossMotion.hazards[1].activatesAt).toBeGreaterThan(warning.bossMotion.hazards[0].activatesAt);

    for (let step = 0; step < 45; step += 1) useGameStore.getState().tick(0.1);
    expect(useGameStore.getState().bossMotion.hazards).toHaveLength(3);
    for (const hazard of useGameStore.getState().bossMotion.hazards) {
      for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hazard.hitIds).not.toContain(memberId);
    }
  });

  it("deals repeated Searing Sweep damage until the exposed player moves out", () => {
    useGameStore.setState((state) => ({
      boss: { ...state.boss, nextMeleeAt: 999 },
      partyPositions: { ...state.partyPositions, aelia: [0, 1] },
      bossMotion: {
        ...state.bossMotion,
        activeMechanicId: "storm-breath",
        mode: "breath_sweeping",
        position: [0, -2.8],
        phaseStartedAt: state.time,
        phaseEndsAt: state.time + 3,
        breathAngle: 0,
        breathStartAngle: 0,
        breathEndAngle: 0,
        mechanicHitIds: [],
        mechanicNextDamageAt: {},
      },
    }));
    const startingHp = useGameStore.getState().party[0].hp;

    useGameStore.getState().tick(0.1);
    const firstTickHp = useGameStore.getState().party[0].hp;
    useGameStore.getState().tick(0.5);
    const secondTickHp = useGameStore.getState().party[0].hp;

    expect(firstTickHp).toBeLessThan(startingHp);
    expect(secondTickHp).toBeLessThan(firstTickHp);

    useGameStore.getState().setPlayerPosition([6, 6]);
    useGameStore.getState().tick(0.5);
    expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
  });
});
