Release v0.1.5 2026-07-12

This commit is contained in:
Warren H
2026-07-12 22:10:12 -04:00
parent bef71d391a
commit 35553c18dd
41 changed files with 2005 additions and 2654 deletions
+191 -36
View File
@@ -3,9 +3,25 @@ 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";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
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(() => {
@@ -106,15 +122,16 @@ describe("Disc Priest combat simulation", () => {
});
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")!;
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("nia");
useGameStore.getState().selectMember(branded.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.debuffs).toHaveLength(0);
expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0);
});
it("Radiance heals every living party member", () => {
@@ -131,8 +148,7 @@ describe("Disc Priest combat simulation", () => {
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 },
boss: { ...state.boss },
}));
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
useGameStore.getState().tick(2);
@@ -190,40 +206,40 @@ describe("Disc Priest combat simulation", () => {
});
it("telegraphs, executes, and recovers from a Bull charge", () => {
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
while (useGameStore.getState().bossMotion.mode !== "telegraph") useGameStore.getState().tick(0.1);
const telegraph = useGameStore.getState().bossMotion;
expect(telegraph.mode).toBe("telegraph");
expect(telegraph.chargeTargetId).toBe("nia");
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, nia: midpoint },
party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member),
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("nia"); step += 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("nia");
expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
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.nextChargeAt).toBeGreaterThan(recovered.time);
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, nextNovaAt: 999, nextBrandAt: 999 },
boss: { ...state.boss, nextMeleeAt: 999 },
}));
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
useGameStore.getState().tick(0.1);
@@ -260,7 +276,7 @@ describe("Disc Priest combat simulation", () => {
it("marks a stack target after three charges and splits 200 pounce damage", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
boss: { ...state.boss, nextMeleeAt: 999 },
playerPosition: [0, 0],
partyPositions: {
aelia: [0, 0],
@@ -269,18 +285,13 @@ describe("Disc Priest combat simulation", () => {
orin: [0, 0],
vale: [0, 0],
},
bossMotion: {
...state.bossMotion,
mode: "returning",
position: [0, -1],
chargesSincePounce: 3,
},
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).toBe("aelia");
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) {
@@ -292,7 +303,7 @@ describe("Disc Priest combat simulation", () => {
useGameStore.getState().tick(0.05);
}
const impacted = useGameStore.getState();
expect(impacted.bossMotion.mode).toBe("returning");
expect(impacted.bossMotion.mode).toBe("holding");
for (const member of impacted.party) {
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
}
@@ -345,7 +356,7 @@ describe("Broodfang encounter", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: {
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
nextMechanicAt: 999,
},
}));
@@ -356,8 +367,8 @@ describe("Broodfang encounter", () => {
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);
expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(1);
@@ -418,6 +429,129 @@ describe("PVE dual-boss encounter", () => {
});
});
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(
@@ -445,23 +579,43 @@ describe("Roguelike rounds", () => {
expect(useGameStore.getState().phase).toBe("intermission");
expect(useGameStore.getState().round).toBe(1);
expect(useGameStore.getState().time).toBe(intermissionTime);
expect(useGameStore.getState().chooseRunBuff("vital-bloom")).toBe(true);
const chosenBuffId = useGameStore.getState().draftBuffIds[0]!;
expect(chosenBuffId).toBeDefined();
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.runBuffs).toEqual(["vital-bloom"]);
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(112);
expect(roundTwo.party[0].maxHp).toBe(100);
});
it("rejects buff claims outside intermission", () => {
expect(useGameStore.getState().chooseRunBuff("deep-wells")).toBe(false);
expect(useGameStore.getState().chooseRunBuff("mend-efficiency")).toBe(false);
expect(useGameStore.getState().round).toBe(1);
});
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,
});
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("shared arena boundary", () => {
@@ -473,8 +627,8 @@ describe("shared arena boundary", () => {
it("constrains player, party, and boss positions to the same room", () => {
useGameStore.getState().setPlayerPosition([100, 100]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 },
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100] },
partyPositions: {
...state.partyPositions,
brann: [50, 50],
@@ -533,6 +687,7 @@ describe("Tempestscale encounter", () => {
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
bossMotion: {
...state.bossMotion,
activeMechanicId: "storm-breath",
mode: "breath_sweeping",
position: [0, -2.8],
phaseStartedAt: state.time,