Update 3D game 2026-07-10 21:20

This commit is contained in:
Warren H
2026-07-10 21:20:17 -04:00
parent 141ec64963
commit e0be0458aa
720 changed files with 366857 additions and 0 deletions
+468
View File
@@ -0,0 +1,468 @@
import { beforeEach, describe, expect, it } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
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.getState().tick(2);
useGameStore.getState().tick(2);
useGameStore.getState().tick(1.1);
const branded = useGameStore.getState().party.find((member) => member.id === "nia")!;
expect(branded.debuffs).toHaveLength(1);
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.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, nextNovaAt: 999, nextBrandAt: 999 },
bossMotion: { ...state.bossMotion, nextChargeAt: 999 },
}));
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 closes to melee range", () => {
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(distance(moved.vale, bossPosition)).toBeLessThan(distance(start.vale, bossPosition));
});
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("telegraphs, executes, and recovers from a Bull charge", () => {
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
const telegraph = useGameStore.getState().bossMotion;
expect(telegraph.mode).toBe("telegraph");
expect(telegraph.chargeTargetId).toBe("nia");
const midpoint: [number, number] = [
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
];
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, nia: midpoint },
party: state.party.map((member) => member.id === "nia" ? { ...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("nia"); step += 1) {
useGameStore.getState().tick(0.05);
}
const hitState = useGameStore.getState();
expect(hitState.bossMotion.chargeHitIds).toContain("nia");
expect(hitState.party.find((member) => member.id === "nia")!.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.nextChargeAt).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, nextNovaAt: 999, nextBrandAt: 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 300 pounce damage", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
playerPosition: [0, 0],
partyPositions: {
aelia: [0, 0],
brann: [0, 0],
nia: [0, 0],
orin: [0, 0],
vale: [0, 0],
},
bossMotion: {
...state.bossMotion,
mode: "returning",
position: [0, -4.25],
chargesSincePounce: 3,
},
}));
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).toBe("aelia");
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("returning");
for (const member of impacted.party) {
expect(member.hp).toBeCloseTo(startingHp[member.id] - 42, 3);
}
expect(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
});
});
describe("Vexa encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "vexa");
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("repeatedly damages the player while they remain in a venom pool", () => {
useGameStore.getState().setPlayerPosition([0, 0]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: {
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
nextMechanicAt: 999,
},
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.3);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(1);
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
});
it("follows Brann when the tank is displaced", () => {
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, brann: [3, 1] },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
const startX = useGameStore.getState().bossMotion.position[0];
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().bossMotion.position[0]).toBeGreaterThan(startX);
});
});
describe("PVE dual-boss encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["vexa", "cindermaw"]);
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("vexa");
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["cindermaw"]);
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");
});
});
describe("Cindermaw encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
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,
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);
});
});