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
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { useGameStore } from "./store";
import type { BossId } from "./types";
interface BattleResult {
phase: ReturnType<typeof useGameStore.getState>["phase"];
time: number;
damageBySource: ReturnType<typeof useGameStore.getState>["partyCombat"]["combatants"];
}
function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds = 200): BattleResult {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), bossIds);
useGameStore.getState().startEncounter();
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
const state = useGameStore.getState();
return { phase: state.phase, time: state.time, damageBySource: state.partyCombat.combatants };
}
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
const result = simulateControlledBattle([first, second]);
expect(result.phase).toBe("victory");
expect(result.time).toBeGreaterThan(70);
expect(result.time).toBeLessThan(110);
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId].damageDone).toBeGreaterThan(0);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { BossId } from "./types";
export interface BossDefinition {
id: BossId;
name: string;
title: string;
trial: string;
icon: string;
accent: string;
summary: string;
briefing: string;
failure: string;
mapTitle: string;
mapCopy: string;
mechanics: readonly [string, string];
maxHp: number;
}
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
bulldrome: {
id: "bulldrome",
name: "Bulldrome",
title: "The Cinder Bull",
trial: "Trial I · Healer Initiation",
icon: "♜",
accent: "#e2744e",
summary: "Charges marked lanes and crushes grouped targets.",
briefing: "Keep formation alive. Sidestep the charge lane, then stack tightly for the Bull's pounce.",
failure: "Protect Brann and the healer. Purify Ember Brand before it burns through formation.",
mapTitle: "Hall of the Bull",
mapCopy: "Keep Brann between formation and Bull. Move clear when the charge lane turns red.",
mechanics: ["Bull Charge", "Crushing Pounce"],
maxHp: 500,
},
vexa: {
id: "vexa",
name: "Vexa",
title: "The Webmother",
trial: "Trial II · Tangled Remedy",
icon: "✣",
accent: "#b56cff",
summary: "Binds allies together and weaponizes every cleanse.",
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its pool poisons the formation.",
failure: "Break purple tethers quickly. Cleanse venom only after its target reaches open ground.",
mapTitle: "The Tangled Loom",
mapCopy: "Spread tethered allies toward opposite edges. Keep dropped venom pools away from the center lane.",
mechanics: ["Binding Web", "Venom Purge"],
maxHp: 535,
},
cindermaw: {
id: "cindermaw",
name: "Cindermaw",
title: "The Sky Tyrant",
trial: "Trial III · Ashen Orbit",
icon: "◆",
accent: "#ff9b45",
summary: "Sweeps the arena with flame and removes safe ground.",
briefing: "Rotate behind Searing Sweep. During Skyfall, leave each numbered impact circle before it becomes persistent fire.",
failure: "Follow the safe side of the breath cone. Keep moving as Skyfall removes sections of the arena.",
mapTitle: "The Ashen Crown",
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between Skyfall impacts.",
mechanics: ["Searing Sweep", "Skyfall"],
maxHp: 410,
},
};
+399
View File
@@ -0,0 +1,399 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
export const BULL_CHARGE = {
firstAt: 7,
repeatDelay: 4,
telegraphDuration: 1.8,
distance: 13.5,
speed: 10.5,
hitRadius: 1.35,
damage: 18,
knockdownDuration: 0.75,
aiClearance: 1.9,
aiEvadeSpeed: 3.4,
} as const;
export const BULL_POUNCE = {
afterCharges: 3,
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 300,
leapDuration: 0.55,
} as const;
export const BOSS_PERIODIC_MECHANICS = {
melee: { firstAt: 2, interval: 2.5, damage: 15 },
nova: { firstAt: 9, interval: 12, damage: 13 },
brand: { firstAt: 5, interval: 9, duration: 7, tickDamage: 6 },
} as const;
const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
export function createBossState(bossId: BossId = "bulldrome"): BossState {
if (bossId === "vexa") return createVexaState();
if (bossId === "cindermaw") return createCindermawState();
const definition = BOSS_DEFINITIONS.bulldrome;
return {
id: "bulldrome",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
brandCount: 0,
};
}
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
if (bossId === "vexa") return createVexaMotion();
if (bossId === "cindermaw") return createCindermawMotion();
return {
...createBaseMotion("bulldrome"),
mode: "holding",
position: [0, -8.2],
chargeStart: [0, -8.2],
chargeEnd: [0, 5.5],
chargeTargetId: "nia",
chargeHitIds: [],
phaseEndsAt: 0,
nextChargeAt: BULL_CHARGE.firstAt,
chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia",
pounceCenter: [0, 4.5],
pounceCount: 0,
};
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
const dx = target[0] - start[0];
const dz = target[1] - start[1];
const length = Math.max(0.001, Math.hypot(dx, dz));
return [
Math.max(-7.3, Math.min(7.3, start[0] + (dx / length) * BULL_CHARGE.distance)),
Math.max(-8.8, Math.min(7.1, start[1] + (dz / length) * BULL_CHARGE.distance)),
];
}
function livingMember(party: PartyMember[], memberId: MemberId) {
return party.find((member) => member.id === memberId && member.hp > 0);
}
function chooseTarget(party: PartyMember[], order: readonly MemberId[], startIndex: number): MemberId {
for (let offset = 0; offset < order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (livingMember(party, candidate)) return candidate;
}
return order[0];
}
function advanceMotionMechanics(
source: BossMotionState,
party: PartyMember[],
partyPositions: Record<MemberId, WorldPosition>,
time: number,
delta: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let motion: BossMotionState = {
...source,
position: [source.position[0], source.position[1]],
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
chargeHitIds: [...source.chargeHitIds],
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
};
let updatedParty = party;
if (motion.mode === "holding") {
const tank = partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * delta);
if (time >= motion.nextChargeAt) {
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
const target = partyPositions[targetId];
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
mode: "telegraph",
chargeStart: [motion.position[0], motion.position[1]],
chargeEnd: chargeEndpoint(motion.position, target),
chargeTargetId: targetId,
chargeHitIds: [],
phaseEndsAt: time + BULL_CHARGE.telegraphDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: motion.chargeCount + 1,
chargesSincePounce: motion.chargesSincePounce + 1,
};
events.push({
at: time,
message: `Bulldrome lines up a charge on ${targetName}.`,
tone: "danger",
pulseKind: "charge",
targetId,
});
}
} else if (motion.mode === "telegraph" && time >= motion.phaseEndsAt) {
const chargeDuration = distance(motion.chargeStart, motion.chargeEnd) / BULL_CHARGE.speed;
motion = { ...motion, mode: "charging", phaseEndsAt: time + chargeDuration };
events.push({ at: time, message: "Bulldrome charges! Clear the marked lane.", tone: "danger" });
} else if (motion.mode === "charging") {
const previousPosition: WorldPosition = [motion.position[0], motion.position[1]];
motion.position = moveToward(motion.position, motion.chargeEnd, BULL_CHARGE.speed * delta);
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(partyPositions[member.id], previousPosition, motion.position) > BULL_CHARGE.hitRadius) {
return member;
}
motion.chargeHitIds = [...motion.chargeHitIds, member.id];
events.push({
at: time,
message: `${member.name} is knocked down by the charge.`,
tone: "danger",
pulseKind: "charge",
targetId: member.id,
});
return {
...damageMember(member, BULL_CHARGE.damage, partyPositions[member.id], time),
knockedUntil: time + BULL_CHARGE.knockdownDuration,
};
});
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
}
} else if (motion.mode === "returning") {
const tank = partyPositions.brann;
const returnPoint: WorldPosition = [tank[0] + motion.formationOffsetX, tank[1] - 4.25];
motion.position = moveToward(motion.position, returnPoint, 4.4 * delta);
if (distance(motion.position, returnPoint) < 0.12) {
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
position: returnPoint,
mode: "stacking",
phaseEndsAt: time + BULL_POUNCE.stackDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
chargesSincePounce: 0,
pounceTargetId: targetId,
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]],
pounceCount: motion.pounceCount + 1,
};
events.push({
at: time,
message: `Bulldrome marks ${targetName}. Stack inside the circle!`,
tone: "danger",
pulseKind: "pounce",
targetId,
});
} else {
motion = {
...motion,
position: returnPoint,
mode: "holding",
nextChargeAt: time + BULL_CHARGE.repeatDelay,
};
events.push({ at: time, message: "Bulldrome returns to Brann and paws at the stone." });
}
}
} else if (motion.mode === "stacking") {
motion.pounceCenter = [partyPositions[motion.pounceTargetId][0], partyPositions[motion.pounceTargetId][1]];
if (time >= motion.phaseEndsAt) {
const targetName = updatedParty.find((member) => member.id === motion.pounceTargetId)?.name ?? motion.pounceTargetId;
motion = {
...motion,
mode: "pouncing",
chargeStart: [motion.position[0], motion.position[1]],
chargeEnd: [motion.pounceCenter[0], motion.pounceCenter[1]],
phaseEndsAt: time + BULL_POUNCE.leapDuration,
};
events.push({ at: time, message: `Bulldrome leaps at ${targetName}!`, tone: "danger" });
}
} else if (motion.mode === "pouncing") {
const leapDistance = distance(motion.chargeStart, motion.chargeEnd);
const leapSpeed = Math.max(12, leapDistance / BULL_POUNCE.leapDuration);
motion.position = moveToward(motion.position, motion.chargeEnd, leapSpeed * delta);
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
const stackedIds = updatedParty
.filter((member) => member.hp > 0 && distance(partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius)
.map((member) => member.id);
const sharedDamage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
updatedParty = updatedParty.map((member) => stackedIds.includes(member.id)
? damageMember(member, sharedDamage, partyPositions[member.id], time)
: member);
motion = {
...motion,
position: [motion.chargeEnd[0], motion.chargeEnd[1]],
mode: "returning",
phaseEndsAt: 0,
};
events.push({
at: time,
message: `Bulldrome pounces for ${Math.round(sharedDamage)} damage across ${stackedIds.length} stacked allies.`,
tone: "danger",
pulseKind: "pounce",
targetId: motion.pounceTargetId,
});
}
}
return { motion, party: updatedParty };
}
function resolvePeriodicMechanics(
boss: BossState,
motion: BossMotionState,
party: PartyMember[],
partyPositions: Record<MemberId, WorldPosition>,
time: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let updatedParty = party;
while (boss.nextMeleeAt <= time) {
if (motion.mode === "holding") {
const tankIndex = updatedParty.findIndex((member) => member.id === "brann");
updatedParty[tankIndex] = damageMember(
updatedParty[tankIndex],
BOSS_PERIODIC_MECHANICS.melee.damage,
partyPositions.brann,
boss.nextMeleeAt,
);
}
boss.nextMeleeAt += BOSS_PERIODIC_MECHANICS.melee.interval;
}
while (boss.nextNovaAt <= time) {
updatedParty = updatedParty.map((member) => damageMember(
member,
BOSS_PERIODIC_MECHANICS.nova.damage,
partyPositions[member.id],
boss.nextNovaAt,
));
events.push({
at: boss.nextNovaAt,
message: "Cinder Nova strikes the party.",
tone: "danger",
pulseKind: "boss",
});
boss.nextNovaAt += BOSS_PERIODIC_MECHANICS.nova.interval;
}
while (boss.nextBrandAt <= time) {
const targetId = CHARGE_TARGET_ORDER[boss.brandCount % CHARGE_TARGET_ORDER.length];
const targetIndex = updatedParty.findIndex((member) => member.id === targetId);
if (updatedParty[targetIndex].hp > 0) {
const appliedAt = boss.nextBrandAt;
updatedParty[targetIndex] = {
...updatedParty[targetIndex],
debuffs: [
...updatedParty[targetIndex].debuffs,
{
id: `brand-${boss.brandCount}`,
name: "Ember Brand",
expiresAt: appliedAt + BOSS_PERIODIC_MECHANICS.brand.duration,
nextTickAt: appliedAt + 1,
tickDamage: BOSS_PERIODIC_MECHANICS.brand.tickDamage,
},
],
};
events.push({
at: appliedAt,
message: `Ember Brand afflicts ${updatedParty[targetIndex].name}.`,
tone: "danger",
pulseKind: "debuff",
targetId,
});
}
boss.brandCount += 1;
boss.nextBrandAt += BOSS_PERIODIC_MECHANICS.brand.interval;
}
return updatedParty;
}
function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
const events: BossMechanicEvent[] = [];
const motionResult = advanceMotionMechanics(
context.motion,
context.party,
context.partyPositions,
context.time,
context.delta,
context.damageMember,
events,
);
const party = resolvePeriodicMechanics(
boss,
motionResult.motion,
motionResult.party,
context.partyPositions,
context.time,
context.damageMember,
events,
);
return { boss, motion: motionResult.motion, party, events };
}
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
return advanceBulldromeMechanics(context);
}
export function handleBossDispel(
bossId: BossId,
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
) {
if (bossId === "vexa" && debuffNames.includes("Widow Venom")) {
return {
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
}
return { motion, message: "Harmful magic removed." };
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
}
if (motion.mode === "charging") {
return { name: "Charge active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
}
if (motion.mode === "stacking") {
const names: Record<MemberId, string> = { aelia: "Aelia", brann: "Brann", nia: "Nia", orin: "Orin", vale: "Vale" };
return { name: `Stack on ${names[motion.pounceTargetId]}`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_POUNCE.stackDuration, urgent: true };
}
if (motion.mode === "pouncing") {
return { name: "Bulldrome Pounce", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 0.75, urgent: true };
}
const candidates: Array<{ name: string; remaining: number; cycle: number }> = [
{ name: "Cinder Nova", remaining: Math.max(0, boss.nextNovaAt - time), cycle: BOSS_PERIODIC_MECHANICS.nova.interval },
{ name: "Ember Brand", remaining: Math.max(0, boss.nextBrandAt - time), cycle: BOSS_PERIODIC_MECHANICS.brand.interval },
];
if (motion.mode === "holding" && Number.isFinite(motion.nextChargeAt)) {
candidates.push({ name: "Bull Charge", remaining: Math.max(0, motion.nextChargeAt - time), cycle: BULL_CHARGE.firstAt + 1 });
}
let next = candidates[0];
for (let index = 1; index < candidates.length; index += 1) {
if (candidates[index].remaining < next.remaining) next = candidates[index];
}
return { ...next, urgent: next.remaining < 2.5 };
}
+161
View File
@@ -0,0 +1,161 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointInCone } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDER_BREATH = {
firstAt: 6,
telegraphDuration: 2,
sweepDuration: 3.2,
range: 10.5,
halfAngle: Math.PI / 7,
sweepArc: Math.PI * 0.95,
tickDamage: 9,
tickInterval: 0.45,
} as const;
export const CINDER_SKYFALL = {
warning: 2,
stagger: 0.9,
radius: 1.8,
damage: 30,
fireDuration: 5,
} as const;
const SKYFALL_TARGETS: readonly MemberId[][] = [
["nia", "orin", "vale"],
["aelia", "brann", "orin"],
["vale", "nia", "aelia"],
];
export function createCindermawState(): BossState {
const definition = BOSS_DEFINITIONS.cindermaw;
return {
id: "cindermaw",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createCindermawMotion(): BossMotionState {
return { ...createBaseMotion("cindermaw"), position: [0, -2.8], nextMechanicAt: CINDER_BREATH.firstAt };
}
export function advanceCindermawMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.9 * context.delta);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
const startAngle = aimedAngle - direction * CINDER_BREATH.sweepArc * 0.5;
motion = {
...motion,
mode: "breath_telegraph",
phaseStartedAt: context.time,
phaseEndsAt: context.time + CINDER_BREATH.telegraphDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
breathAngle: startAngle,
breathStartAngle: startAngle,
breathEndAngle: startAngle + direction * CINDER_BREATH.sweepArc,
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: "Cindermaw draws a sweeping breath. Rotate behind it!", tone: "danger", pulseKind: "breath" });
} else {
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
const hazards = set.map((memberId, index) => {
const activatesAt = context.time + CINDER_SKYFALL.warning + index * CINDER_SKYFALL.stagger;
return {
id: `skyfall-${motion.mechanicCount}-${index}`,
kind: "skyfall" as const,
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
radius: CINDER_SKYFALL.radius,
activatesAt,
expiresAt: activatesAt + CINDER_SKYFALL.fireDuration,
damage: CINDER_SKYFALL.damage,
nextDamageAt: {},
resolved: false,
hitIds: [],
};
});
motion = {
...motion,
mode: "skyfall",
phaseStartedAt: context.time,
phaseEndsAt: hazards[hazards.length - 1].activatesAt + 0.5,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
hazards: [...motion.hazards, ...hazards],
};
events.push({ at: context.time, message: "Cindermaw takes flight. Three Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
}
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "breath_sweeping",
phaseStartedAt: context.time,
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Searing Sweep crosses the arena!", tone: "danger", pulseKind: "breath" });
} else if (motion.mode === "breath_sweeping") {
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
party = party.map((member) => {
if (member.hp <= 0) return member;
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, CINDER_BREATH.halfAngle, CINDER_BREATH.range);
if (!exposed) {
motion.mechanicNextDamageAt[member.id] = context.time;
return member;
}
let next = member;
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
while (tickAt <= context.time + 0.001) {
next = context.damageMember(next, CINDER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
tickAt += CINDER_BREATH.tickInterval;
}
motion.mechanicNextDamageAt[member.id] = tickAt;
if (!motion.mechanicHitIds.includes(member.id)) {
motion.mechanicHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is scorched by Searing Sweep.`, tone: "danger", pulseKind: "breath", targetId: member.id });
}
return next;
});
if (context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
} else if (motion.mode === "skyfall" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.6, 17, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "breath_telegraph") return { name: "Searing Sweep", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
const nextIsBreath = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsBreath ? "Searing Sweep" : "Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+139
View File
@@ -0,0 +1,139 @@
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicEvent } from "./types";
export function createBaseMotion(bossId: BossId): BossMotionState {
return {
bossId,
formationOffsetX: 0,
mode: "holding",
position: [0, -8.2],
chargeStart: [0, -8.2],
chargeEnd: [0, 5.5],
chargeTargetId: "nia",
chargeHitIds: [],
phaseEndsAt: 0,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia",
pounceCenter: [0, 4.5],
pounceCount: 0,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: 0,
phaseStartedAt: 0,
mechanicHitIds: [],
mechanicNextDamageAt: {},
tetherIds: [],
tetherBreakDistance: 0,
breathAngle: 0,
breathStartAngle: 0,
breathEndAngle: 0,
hazards: [],
};
}
export function cloneMotion(source: BossMotionState): BossMotionState {
return {
...source,
position: [source.position[0], source.position[1]],
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
chargeHitIds: [...source.chargeHitIds],
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
mechanicHitIds: [...source.mechanicHitIds],
mechanicNextDamageAt: { ...source.mechanicNextDamageAt },
tetherIds: [...source.tetherIds],
hazards: source.hazards.map((hazard) => ({
...hazard,
center: [hazard.center[0], hazard.center[1]],
nextDamageAt: { ...hazard.nextDamageAt },
hitIds: [...hazard.hitIds],
})),
};
}
export function chooseLivingTarget(
party: PartyMember[],
order: readonly MemberId[],
startIndex: number,
) {
for (let offset = 0; offset < order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (party.some((member) => member.id === candidate && member.hp > 0)) return candidate;
}
return order[0];
}
export function applyMelee(
boss: BossState,
motion: BossMotionState,
party: PartyMember[],
positions: Record<MemberId, WorldPosition>,
time: number,
interval: number,
amount: number,
damageMember: BossMechanicContext["damageMember"],
) {
while (boss.nextMeleeAt <= time) {
if (motion.mode === "holding") {
const tankIndex = party.findIndex((member) => member.id === "brann");
if (tankIndex >= 0 && party[tankIndex].hp > 0) {
party[tankIndex] = damageMember(party[tankIndex], amount, positions.brann, boss.nextMeleeAt);
}
}
boss.nextMeleeAt += interval;
}
}
export function resolveCircleHazards(
motion: BossMotionState,
party: PartyMember[],
positions: Record<MemberId, WorldPosition>,
time: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let updatedParty = party;
for (const hazard of motion.hazards) {
if (time < hazard.activatesAt || time >= hazard.expiresAt) continue;
const newlyHit: MemberId[] = [];
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0) return member;
const inside = distance(positions[member.id], hazard.center) <= hazard.radius;
if (!inside) {
delete hazard.nextDamageAt[member.id];
return member;
}
if (!hazard.hitIds.includes(member.id)) {
hazard.hitIds.push(member.id);
newlyHit.push(member.id);
}
if (hazard.kind !== "venom_pool") {
return hazard.resolved || hazard.hitIds.includes(member.id) && !newlyHit.includes(member.id)
? member
: damageMember(member, hazard.damage, positions[member.id], time);
}
let next = member;
let tickAt = hazard.nextDamageAt[member.id] ?? time;
while (tickAt <= time + 0.001) {
next = damageMember(next, hazard.damage, positions[member.id], tickAt);
tickAt += hazard.tickInterval ?? 1;
}
hazard.nextDamageAt[member.id] = tickAt;
return next;
});
if (newlyHit.length && !hazard.resolved) {
const label = hazard.kind === "skyfall" ? "Skyfall" : "Venom pool";
events.push({ at: time, message: `${label} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind: hazard.kind === "skyfall" ? "skyfall" : "venom" });
}
if (newlyHit.length || hazard.kind === "skyfall" && time >= hazard.activatesAt) hazard.resolved = true;
}
motion.hazards = motion.hazards.filter((hazard) => hazard.expiresAt > time);
return updatedParty;
}
export function memberName(party: PartyMember[], memberId: MemberId) {
return party.find((member) => member.id === memberId)?.name ?? memberId;
}
+33
View File
@@ -0,0 +1,33 @@
import type { BossMotionState, BossState, MemberId, PartyMember, PulseKind, WorldPosition } from "../types";
export interface BossMechanicEvent {
at: number;
message: string;
tone?: "danger" | "neutral";
pulseKind?: PulseKind;
targetId?: MemberId;
}
export interface BossMechanicResult {
boss: BossState;
motion: BossMotionState;
party: PartyMember[];
events: BossMechanicEvent[];
}
export interface BossMechanicContext {
boss: BossState;
motion: BossMotionState;
party: PartyMember[];
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number) => PartyMember;
}
export interface UpcomingMechanic {
name: string;
remaining: number;
cycle: number;
urgent: boolean;
}
+161
View File
@@ -0,0 +1,161 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { distance, moveToward } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const VEXA_TETHER = {
firstAt: 6,
duration: 4.5,
breakDistance: 6.8,
failureDamage: 24,
rootDuration: 1.4,
} as const;
export const VEXA_VENOM = {
duration: 10,
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
} as const;
const TETHER_PAIRS: readonly (readonly [MemberId, MemberId])[] = [
["brann", "vale"],
["nia", "orin"],
["aelia", "nia"],
];
const VENOM_TARGETS: readonly MemberId[][] = [
["nia", "vale"],
["orin", "brann"],
["aelia", "vale"],
];
export function createVexaState(): BossState {
const definition = BOSS_DEFINITIONS.vexa;
return {
id: "vexa",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createVexaMotion(): BossMotionState {
return { ...createBaseMotion("vexa"), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
}
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const pair = TETHER_PAIRS[Math.floor(motion.mechanicCount / 2) % TETHER_PAIRS.length];
const livingPair = pair.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
if (livingPair.length === 2) {
motion = {
...motion,
mode: "tethering",
phaseStartedAt: context.time,
phaseEndsAt: context.time + VEXA_TETHER.duration,
nextMechanicAt: Number.POSITIVE_INFINITY,
tetherIds: [...livingPair],
tetherBreakDistance: VEXA_TETHER.breakDistance,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: `Vexa binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
}
} else {
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
const targets = targetSet.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
party = party.map((member) => targets.includes(member.id)
? {
...member,
debuffs: [...member.debuffs, {
id: `widow-venom-${motion.mechanicCount}-${member.id}`,
name: "Widow Venom",
expiresAt: context.time + VEXA_VENOM.duration,
nextTickAt: context.time + 1,
tickDamage: VEXA_VENOM.tickDamage,
}],
}
: member);
motion = {
...motion,
mode: "venom_cast",
phaseStartedAt: context.time,
phaseEndsAt: context.time + VEXA_VENOM.castDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: "Vexa injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
}
} else if (motion.mode === "tethering") {
const [first, second] = motion.tetherIds;
if (!first || !second || distance(context.partyPositions[first], context.partyPositions[second]) >= motion.tetherBreakDistance) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
events.push({ at: context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
} else if (context.time >= motion.phaseEndsAt) {
party = party.map((member) => motion.tetherIds.includes(member.id)
? {
...context.damageMember(member, VEXA_TETHER.failureDamage, context.partyPositions[member.id], context.time),
knockedUntil: context.time + VEXA_TETHER.rootDuration,
}
: member);
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
events.push({ at: context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
}
} else if (motion.mode === "venom_cast" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.7, 13, context.damageMember);
return { boss, motion, party, events };
}
export function dropVexaVenomPool(
motion: BossMotionState,
memberId: MemberId,
center: [number, number],
time: number,
) {
const next = cloneMotion(motion);
next.hazards.push({
id: `venom-pool-${memberId}-${time.toFixed(2)}`,
kind: "venom_pool",
center: [center[0], center[1]],
radius: VEXA_VENOM.poolRadius,
activatesAt: time + 0.25,
expiresAt: time + VEXA_VENOM.poolDuration,
damage: VEXA_VENOM.poolDamage,
tickInterval: 1,
nextDamageAt: {},
resolved: false,
hitIds: [],
});
return next;
}
export function upcomingVexaMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "tethering") return { name: "Break Binding Web", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_TETHER.duration, urgent: true };
if (motion.mode === "venom_cast") return { name: "Move, then Purify", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_VENOM.castDuration, urgent: true };
const nextIsTether = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsTether ? "Binding Web" : "Venom Purge", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+17
View File
@@ -0,0 +1,17 @@
import { HEALER_CLASSES } from "./healers";
import type { AbilityId, HealerClassId, PartyMember } from "./types";
export const ABILITIES = HEALER_CLASSES.priest.abilities;
export const ABILITY_ORDER: AbilityId[] = ["mend", "renew", "shield", "purify", "radiance", "barrier"];
export function freshParty(classId: HealerClassId = "priest", playerName = "Aelia"): PartyMember[] {
const healer = HEALER_CLASSES[classId];
return [
{ id: "aelia", name: playerName, className: healer.specialization, role: "Healer", color: healer.color, maxHp: 100, hp: 100, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "brann", name: "Brann", className: "Knight", role: "Tank", color: "#69a8dd", maxHp: 150, hp: 150, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "nia", name: "Nia", className: "Ranger", role: "Damage", color: "#74c987", maxHp: 94, hp: 94, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "orin", name: "Orin", className: "Mage", role: "Damage", color: "#b17ee6", maxHp: 86, hp: 86, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "vale", name: "Vale", className: "Rogue", role: "Damage", color: "#d97171", maxHp: 92, hp: 92, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
];
}
+96
View File
@@ -0,0 +1,96 @@
import type { WorldPosition } from "./types";
export function distance(a: WorldPosition, b: WorldPosition) {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
export function moveToward(
current: WorldPosition,
target: WorldPosition,
maximumDistance: number,
): WorldPosition {
const dx = target[0] - current[0];
const dz = target[1] - current[1];
const length = Math.hypot(dx, dz);
if (length <= maximumDistance || length < 0.0001) return [target[0], target[1]];
return [current[0] + (dx / length) * maximumDistance, current[1] + (dz / length) * maximumDistance];
}
export function pointToSegmentDistance(
point: WorldPosition,
start: WorldPosition,
end: WorldPosition,
) {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const lengthSquared = dx * dx + dz * dz;
if (lengthSquared === 0) return distance(point, start);
const projection = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
);
return Math.hypot(
point[0] - (start[0] + projection * dx),
point[1] - (start[1] + projection * dz),
);
}
export function pointOutsideLane(
point: WorldPosition,
start: WorldPosition,
end: WorldPosition,
clearance: number,
preferredSide: -1 | 1,
): WorldPosition {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const lengthSquared = dx * dx + dz * dz;
if (lengthSquared < 0.0001) return [point[0], point[1]];
const length = Math.sqrt(lengthSquared);
const projection = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
);
const nearestX = start[0] + projection * dx;
const nearestZ = start[1] + projection * dz;
const normalX = -dz / length;
const normalZ = dx / length;
const signedDistance = (point[0] - nearestX) * normalX + (point[1] - nearestZ) * normalZ;
const side = Math.abs(signedDistance) > 0.01 ? Math.sign(signedDistance) : preferredSide;
return [nearestX + normalX * clearance * side, nearestZ + normalZ * clearance * side];
}
export function angleTo(origin: WorldPosition, target: WorldPosition) {
return Math.atan2(target[0] - origin[0], target[1] - origin[1]);
}
export function angularDistance(first: number, second: number) {
return Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second)));
}
export function pointInCone(
point: WorldPosition,
origin: WorldPosition,
facingAngle: number,
halfAngle: number,
range: number,
) {
return distance(point, origin) <= range && angularDistance(angleTo(origin, point), facingAngle) <= halfAngle;
}
export function pointOutsideCircle(
point: WorldPosition,
center: WorldPosition,
clearance: number,
fallbackAngle: number,
): WorldPosition {
const dx = point[0] - center[0];
const dz = point[1] - center[1];
const length = Math.hypot(dx, dz);
if (length < 0.001) {
return [center[0] + Math.sin(fallbackAngle) * clearance, center[1] + Math.cos(fallbackAngle) * clearance];
}
return [center[0] + (dx / length) * clearance, center[1] + (dz / length) * clearance];
}
+97
View File
@@ -0,0 +1,97 @@
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" },
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" },
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" },
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" },
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" },
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" },
};
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
return { ...bindings[id], ...definition };
}
export const HEALER_CLASS_ORDER: HealerClassId[] = ["priest", "druid", "shaman"];
export const HEALER_CLASSES: Record<HealerClassId, HealerClassDefinition> = {
priest: {
id: "priest",
name: "Priest",
specialization: "Discipline Priest",
icon: "✦",
color: "#e8c872",
resourceName: "Grace",
description: "Direct healing, protective shields, cleansing, and a damage-reducing sanctuary.",
abilities: {
mend: ability("mend", { name: "Mend", shortName: "Mend", cooldown: 0, castTime: 0.5, mana: 5, icon: "+", description: "Cast for 0.5 seconds to heal the selected ally for 38 health. No cooldown.", color: "#fff0bd" }),
renew: ability("renew", { name: "Renew", shortName: "Renew", cooldown: 0, mana: 7, icon: "✣", description: "Heal selected ally for 7 health every second for 8 seconds. No cooldown.", color: "#71df9c" }),
shield: ability("shield", { name: "Aegis Shield", shortName: "Shield", cooldown: 10, mana: 8, icon: "◇", description: "Give selected ally a 36-point damage shield.", color: "#6fc6ff" }),
purify: ability("purify", { name: "Purify", shortName: "Purify", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally.", color: "#b58cff" }),
radiance: ability("radiance", { name: "Radiance", shortName: "Radiance", cooldown: 14, mana: 12, icon: "☀", description: "Heal every party member for 22 health.", color: "#ffd66b" }),
barrier: ability("barrier", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 3m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }),
},
},
druid: {
id: "druid",
name: "Druid",
specialization: "Restoration Druid",
icon: "❧",
color: "#79d36f",
resourceName: "Mana",
description: "Placeholder nature kit built around regeneration, bark wards, and restorative growth.",
abilities: {
mend: ability("mend", { name: "Healing Touch", shortName: "Heal Touch", cooldown: 0, castTime: 0.5, mana: 5, icon: "❦", description: "Placeholder: cast a focused nature heal for 38 health.", color: "#b8ef8b" }),
renew: ability("renew", { name: "Rejuvenation", shortName: "Rejuvenate", cooldown: 0, mana: 7, icon: "☘", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#63d77e" }),
shield: ability("shield", { name: "Ironbark", shortName: "Ironbark", cooldown: 10, mana: 8, icon: "♧", description: "Placeholder: grant the selected ally 36 absorption.", color: "#a6c76b" }),
purify: ability("purify", { name: "Nature's Cure", shortName: "Nature Cure", cooldown: 3, mana: 5, icon: "✤", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#8de2b2" }),
radiance: ability("radiance", { name: "Wild Growth", shortName: "Wild Growth", cooldown: 14, mana: 12, icon: "✾", description: "Placeholder: heal every party member for 22 health.", color: "#d1ed73" }),
barrier: ability("barrier", { name: "Grove Ward", shortName: "Grove Ward", cooldown: 60, mana: 10, icon: "◌", description: "Placeholder: grow an 8-second protective grove that reduces damage by 30%.", color: "#70bc72" }),
},
},
shaman: {
id: "shaman",
name: "Shaman",
specialization: "Restoration Shaman",
icon: "ϟ",
color: "#65b9ed",
resourceName: "Mana",
description: "Placeholder elemental kit using tides, earth wards, cleansing, and spirit protection.",
abilities: {
mend: ability("mend", { name: "Healing Wave", shortName: "Heal Wave", cooldown: 0, castTime: 0.5, mana: 5, icon: "≈", description: "Placeholder: cast a focused water heal for 38 health.", color: "#8fdcf2" }),
renew: ability("renew", { name: "Riptide", shortName: "Riptide", cooldown: 0, mana: 7, icon: "≋", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#54c9c5" }),
shield: ability("shield", { name: "Earth Shield", shortName: "Earth Shield", cooldown: 10, mana: 8, icon: "⬡", description: "Placeholder: grant the selected ally 36 absorption.", color: "#d2b66c" }),
purify: ability("purify", { name: "Cleanse Spirit", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#9aaef5" }),
radiance: ability("radiance", { name: "Chain Heal", shortName: "Chain Heal", cooldown: 14, mana: 12, icon: "⌁", description: "Placeholder: heal every party member for 22 health.", color: "#6ee2db" }),
barrier: ability("barrier", { name: "Spirit Link", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Placeholder: place an 8-second spirit field that reduces damage by 30%.", color: "#9d8cf2" }),
},
},
};
const CLASS_INVENTORIES: Record<HealerClassId, InventoryItem[]> = {
priest: [
{ id: "priest-censer", name: "Censer of First Light", slot: "Main Hand", rarity: "Rare", icon: "♰", stats: ["+12 Grace", "+8% Mend healing"], effect: "Mend restores 2 mana when it lands on an ally below 50% health.", equipped: true },
{ id: "priest-vestment", name: "Ashwoven Vestment", slot: "Chest", rarity: "Uncommon", icon: "♜", stats: ["+18 Armor", "+6 Spirit"], effect: "Renew ticks have a 10% chance to extend Aegis Shield by 4 absorption.", equipped: true },
{ id: "priest-phial", name: "Moonwater Phial", slot: "Consumable", rarity: "Common", icon: "⚗", stats: ["Restores 40 mana"], effect: "Single use. Cannot be used during this prototype encounter.", equipped: false },
{ id: "priest-sigil", name: "Sigil of Quiet Resolve", slot: "Trinket", rarity: "Rare", icon: "◈", stats: ["+10% Purify range", "+5 Haste"], effect: "Purify grants its target 8 absorption when it removes Ember Brand.", equipped: false },
],
druid: [
{ id: "druid-branch", name: "Verdant Branch", slot: "Main Hand", rarity: "Common", icon: "❧", stats: ["+5 Spirit"], effect: "Placeholder Druid starter weapon.", equipped: true },
{ id: "druid-hide", name: "Mossbound Hide", slot: "Chest", rarity: "Common", icon: "♧", stats: ["+10 Armor"], effect: "Placeholder Druid starter armor.", equipped: true },
{ id: "druid-seed", name: "Dreamseed", slot: "Trinket", rarity: "Uncommon", icon: "•", stats: ["+3 Haste"], effect: "Placeholder Druid trinket.", equipped: false },
],
shaman: [
{ id: "shaman-totem", name: "Raincall Totem", slot: "Main Hand", rarity: "Common", icon: "ϟ", stats: ["+5 Spirit"], effect: "Placeholder Shaman starter focus.", equipped: true },
{ id: "shaman-mail", name: "Tideworn Mail", slot: "Chest", rarity: "Common", icon: "▧", stats: ["+12 Armor"], effect: "Placeholder Shaman starter armor.", equipped: true },
{ id: "shaman-stone", name: "Whispering Stone", slot: "Trinket", rarity: "Uncommon", icon: "◇", stats: ["+3 Haste"], effect: "Placeholder Shaman trinket.", equipped: false },
],
};
export function createClassInventory(classId: HealerClassId): InventoryItem[] {
return structuredClone(CLASS_INVENTORIES[classId]);
}
export function healerClass(classId: HealerClassId): HealerClassDefinition {
return HEALER_CLASSES[classId];
}
+211
View File
@@ -0,0 +1,211 @@
import { BULL_CHARGE } from "./bossMechanics";
import { CINDER_BREATH } from "./bosses/cindermaw";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
export type AiMemberId = Exclude<MemberId, "aelia">;
export interface PartyBehaviorContext {
memberId: AiMemberId;
current: WorldPosition;
formationTarget: WorldPosition;
bossMotion: BossMotionState;
partyPositions: Record<MemberId, WorldPosition>;
time: number;
}
export interface PartyBehaviorDecision {
target: WorldPosition;
speed: number;
}
export interface PartyBehavior {
id: string;
decide: (context: PartyBehaviorContext) => PartyBehaviorDecision | null;
}
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
brann: [-0.55, 0],
nia: [0.45, 0.4],
orin: [0.45, -0.4],
vale: [0, 0.65],
};
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPosition> {
return {
brann: [boss[0], boss[1] + 4.25],
nia: [boss[0] - 3.3, boss[1] + 7.2],
orin: [boss[0] + 3.3, boss[1] + 7.2],
vale: [boss[0] + 1.75, boss[1] + 3.4],
};
}
function clampToArena(position: WorldPosition): WorldPosition {
return [
Math.max(ARENA_BOUNDS.minX, Math.min(ARENA_BOUNDS.maxX, position[0])),
Math.max(ARENA_BOUNDS.minZ, Math.min(ARENA_BOUNDS.maxZ, position[1])),
];
}
export const stackForPounceBehavior: PartyBehavior = {
id: "stack-for-pounce",
decide: ({ memberId, bossMotion }) => {
if (bossMotion.mode !== "stacking") return null;
const offset = STACK_OFFSETS[memberId];
return {
target: memberId === bossMotion.pounceTargetId
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
speed: 3.4,
};
},
};
export const breakTetherBehavior: PartyBehavior = {
id: "break-tether",
decide: ({ memberId, current, bossMotion, partyPositions }) => {
if (bossMotion.mode !== "tethering" || !bossMotion.tetherIds.includes(memberId)) return null;
const otherId = bossMotion.tetherIds.find((id) => id !== memberId);
if (!otherId) return null;
const other = partyPositions[otherId];
const dx = current[0] - other[0];
const dz = current[1] - other[1];
const length = Math.hypot(dx, dz);
const fallback = EVADE_SIDES[memberId] * Math.PI * 0.5;
const target = length < 0.01
? [current[0] + Math.sin(fallback) * bossMotion.tetherBreakDistance, current[1] + Math.cos(fallback) * bossMotion.tetherBreakDistance] as WorldPosition
: [current[0] + (dx / length) * bossMotion.tetherBreakDistance, current[1] + (dz / length) * bossMotion.tetherBreakDistance] as WorldPosition;
return { target: clampToArena(target), speed: 3.7 };
},
};
export const evadeChargeBehavior: PartyBehavior = {
id: "evade-charge",
decide: ({ memberId, current, formationTarget, bossMotion }) => {
if (bossMotion.mode !== "telegraph" && bossMotion.mode !== "charging") return null;
const { chargeStart, chargeEnd } = bossMotion;
const currentUnsafe = pointToSegmentDistance(current, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
const formationUnsafe = pointToSegmentDistance(formationTarget, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
if (!currentUnsafe && !formationUnsafe) return null;
// Derive from the stable formation slot so the target cannot flip sides as the member moves.
const evadeTarget = pointOutsideLane(
formationTarget,
chargeStart,
chargeEnd,
BULL_CHARGE.aiClearance,
EVADE_SIDES[memberId],
);
return { target: clampToArena(evadeTarget), speed: BULL_CHARGE.aiEvadeSpeed };
},
};
export const avoidBreathBehavior: PartyBehavior = {
id: "avoid-breath",
decide: ({ memberId, bossMotion }) => {
if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
const side = EVADE_SIDES[memberId];
const safeAngle = bossMotion.breathAngle + side * (CINDER_BREATH.halfAngle + Math.PI * 0.42);
const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4;
return {
target: clampToArena([
bossMotion.position[0] + Math.sin(safeAngle) * radius,
bossMotion.position[1] + Math.cos(safeAngle) * radius,
]),
speed: 4.1,
};
},
};
export const avoidCircleHazardsBehavior: PartyBehavior = {
id: "avoid-circle-hazards",
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
for (let index = 0; index < bossMotion.hazards.length; index += 1) {
const hazard = bossMotion.hazards[index];
if (hazard.expiresAt <= time || hazard.activatesAt - time > 2.2) continue;
const clearance = hazard.radius + 0.55;
const currentUnsafe = Math.hypot(current[0] - hazard.center[0], current[1] - hazard.center[1]) < clearance;
const formationUnsafe = Math.hypot(formationTarget[0] - hazard.center[0], formationTarget[1] - hazard.center[1]) < clearance;
if (!currentUnsafe && !formationUnsafe) continue;
const source = formationUnsafe ? formationTarget : current;
const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
return { target: clampToArena(pointOutsideCircle(source, hazard.center, clearance, fallbackAngle)), speed: 4 };
}
return null;
},
};
export const maintainFormationBehavior: PartyBehavior = {
id: "maintain-formation",
decide: ({ formationTarget, bossMotion, memberId }) => {
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
},
};
export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
breakTetherBehavior,
stackForPounceBehavior,
evadeChargeBehavior,
avoidBreathBehavior,
avoidCircleHazardsBehavior,
maintainFormationBehavior,
];
export function updatePartyPositions(
current: Record<MemberId, WorldPosition>,
bossMotionOrMotions: BossMotionState | readonly BossMotionState[],
party: PartyMember[],
time: number,
delta: number,
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
) {
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
const activeMotions = bossMotions;
const next: Record<MemberId, WorldPosition> = {
aelia: [current.aelia[0], current.aelia[1]],
brann: [current.brann[0], current.brann[1]],
nia: [current.nia[0], current.nia[1]],
orin: [current.orin[0], current.orin[1]],
vale: [current.vale[0], current.vale[1]],
};
if (!activeMotions.length) return next;
const formationOrigin: WorldPosition = [
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
];
const formation = combatFormation(formationOrigin);
// Vale fights from the boss-cluster midpoint so short-range cleaves can
// connect with both targets when their hit volumes overlap.
formation.vale = [formationOrigin[0], formationOrigin[1] + 1.7];
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
const memberId = AI_MEMBER_IDS[index];
const member = party[index + 1];
if (!member || member.hp <= 0 || member.knockedUntil > time) continue;
for (const behavior of behaviors) {
let handled = false;
for (const bossMotion of activeMotions) {
const decision = behavior.decide({
memberId,
current: next[memberId],
formationTarget: formation[memberId],
bossMotion,
partyPositions: next,
time,
});
if (!decision) continue;
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
handled = true;
break;
}
if (handled) break;
}
}
return next;
}
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { freshParty } from "./data";
import {
advancePartyCombat,
createPartyCombatState,
PARTY_ABILITY_LOADOUTS,
tankAuraProtects,
type AiCombatantId,
type PartyAbilityId,
type PartyCombatTarget,
} from "./partyCombat";
import type { BossId, MemberId, PartyMember, WorldPosition } from "./types";
const BASE_POSITIONS: Record<MemberId, WorldPosition> = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
interface SimulationOptions {
duration?: number;
movingIds?: readonly AiCombatantId[];
activeIds?: readonly AiCombatantId[];
targetPositions?: readonly WorldPosition[];
stopOnVictory?: boolean;
upcomingMechanicRemaining?: number;
}
function createTargets(bossIds: readonly BossId[], positions?: readonly WorldPosition[]): PartyCombatTarget[] {
return bossIds.map((bossId, index) => {
const motion = createBossMotionState(bossId);
motion.position = positions?.[index]
? [positions[index][0], positions[index][1]]
: bossIds.length > 1
? [index === 0 ? -2.4 : 2.4, -4]
: [0, -4];
return { instanceId: `sim-${index}-${bossId}`, boss: createBossState(bossId), motion };
});
}
function simulate(bossIds: readonly BossId[], options: SimulationOptions = {}) {
const activeIds = options.activeIds ?? ["brann", "nia", "orin", "vale"];
const party = freshParty().map((member) => member.id === "aelia" || activeIds.includes(member.id as AiCombatantId)
? member
: { ...member, hp: 0 });
let combat = createPartyCombatState(party);
const targets = createTargets(bossIds, options.targetPositions);
const damageBySource: Partial<Record<AiCombatantId, number>> = {};
const usedAbilities = new Set<PartyAbilityId>();
const secondaryEvents: PartyAbilityId[] = [];
const step = 0.1;
let time = 0;
while (time < (options.duration ?? 180)) {
const nextTime = Number((time + step).toFixed(4));
const oldPositions = structuredClone(BASE_POSITIONS);
const positions = structuredClone(BASE_POSITIONS);
for (const memberId of options.movingIds ?? []) oldPositions[memberId] = [positions[memberId][0] - 0.1, positions[memberId][1]];
const result = advancePartyCombat(combat, {
oldTime: time,
time: nextTime,
party,
oldPositions,
positions,
targets,
upcomingMechanicRemaining: options.upcomingMechanicRemaining ?? Number.POSITIVE_INFINITY,
});
combat = result.state;
for (const actor of Object.values(combat.combatants)) {
if (actor.visualAction) usedAbilities.add(actor.visualAction.abilityId);
}
for (const event of result.events) {
const target = targets.find((entry) => entry.instanceId === event.targetInstanceId)!;
target.boss.hp = Math.max(0, target.boss.hp - event.amount);
damageBySource[event.sourceId] = (damageBySource[event.sourceId] ?? 0) + event.amount;
if (event.secondary) secondaryEvents.push(event.abilityId);
}
time = nextTime;
if (options.stopOnVictory !== false && targets.every((target) => target.boss.hp <= 0)) break;
}
return { time, targets, combat, damageBySource, usedAbilities, secondaryEvents };
}
describe("party ability combat", () => {
it("defines five unique ability slots for every AI party class", () => {
for (const loadout of Object.values(PARTY_ABILITY_LOADOUTS)) {
expect(loadout).toHaveLength(5);
expect(new Set(loadout).size).toBe(5);
}
});
it("does not damage a boss before an ability impact lands", () => {
const result = simulate(["bulldrome"], { duration: 0.1, stopOnVictory: false });
expect(result.targets[0].boss.hp).toBe(result.targets[0].boss.maxHp);
expect(Object.values(result.damageBySource)).toHaveLength(0);
});
it("reduces ranged damage while moving without reducing it to zero", () => {
const stationary = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], stopOnVictory: false });
const moving = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], movingIds: ["nia", "orin"], stopOnVictory: false });
const stationaryDamage = (stationary.damageBySource.nia ?? 0) + (stationary.damageBySource.orin ?? 0);
const movingDamage = (moving.damageBySource.nia ?? 0) + (moving.damageBySource.orin ?? 0);
expect(movingDamage).toBeGreaterThan(0);
expect(movingDamage).toBeLessThan(stationaryDamage * 0.7);
});
it("lets Vale cleave only when both bosses are inside melee radius", () => {
const clustered = simulate(["vexa", "cindermaw"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
const separated = simulate(["vexa", "cindermaw"], {
duration: 30,
activeIds: ["vale"],
targetPositions: [[0, -4], [7, -4]],
stopOnVictory: false,
});
expect(clustered.secondaryEvents.length).toBeGreaterThan(0);
expect(separated.secondaryEvents).toHaveLength(0);
expect(clustered.damageBySource.vale).toBeGreaterThan(separated.damageBySource.vale ?? 0);
});
it("activates Brann's moving six-second Bulwark aura before incoming damage", () => {
const result = simulate(["bulldrome"], { duration: 0.1, activeIds: ["brann"], stopOnVictory: false, upcomingMechanicRemaining: 1 });
expect(result.combat.combatants.brann.visualAction?.abilityId).toBe("bulwark_march");
expect(result.combat.tankAura.expiresAt).toBe(6);
expect(tankAuraProtects([2.9, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(true);
expect(tankAuraProtects([3.1, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(false);
expect(tankAuraProtects([0, 0], [0, 0], result.combat.tankAura, 6)).toBe(false);
});
it("uses every button in each five-ability DPS rotation during a full fight", () => {
const result = simulate(["bulldrome", "vexa"]);
const movementPhase = simulate(["bulldrome"], { duration: 5, activeIds: ["nia"], movingIds: ["nia"], stopOnVictory: false });
const usedAbilities = new Set([...result.usedAbilities, ...movementPhase.usedAbilities]);
for (const memberId of ["nia", "orin", "vale"] as const) {
expect([...usedAbilities]).toEqual(expect.arrayContaining([...PARTY_ABILITY_LOADOUTS[memberId]]));
}
});
});
describe("dual-boss damage simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
const result = simulate([first, second]);
expect(result.targets.every((target) => target.boss.hp <= 0)).toBe(true);
expect(result.time).toBeGreaterThan(35);
expect(result.time).toBeLessThan(150);
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId]).toBeGreaterThan(0);
});
});
+394
View File
@@ -0,0 +1,394 @@
import { distance } from "./geometry";
import type { BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
export type AiCombatantId = Exclude<MemberId, "aelia">;
export type PartyAbilityId =
| "sword_slash" | "shield_slam" | "revenge" | "sweeping_guard" | "bulwark_march"
| "quick_shot" | "aimed_shot" | "rapid_fire" | "kill_shot" | "deadeye"
| "arcane_bolt" | "ember_lance" | "arcane_burst" | "comet" | "overcharge"
| "quick_cut" | "twin_fang" | "backstab" | "fan_of_blades" | "blade_flurry";
export const PARTY_ABILITY_NAMES: Record<PartyAbilityId, string> = {
sword_slash: "Sword Slash",
shield_slam: "Shield Slam",
revenge: "Revenge",
sweeping_guard: "Sweeping Guard",
bulwark_march: "Bulwark March",
quick_shot: "Quick Shot",
aimed_shot: "Aimed Shot",
rapid_fire: "Rapid Fire",
kill_shot: "Kill Shot",
deadeye: "Deadeye",
arcane_bolt: "Arcane Bolt",
ember_lance: "Ember Lance",
arcane_burst: "Arcane Burst",
comet: "Comet",
overcharge: "Overcharge",
quick_cut: "Quick Cut",
twin_fang: "Twin Fang",
backstab: "Backstab",
fan_of_blades: "Fan of Blades",
blade_flurry: "Blade Flurry",
};
export const PARTY_ABILITY_LOADOUTS: Record<AiCombatantId, readonly PartyAbilityId[]> = {
brann: ["sword_slash", "shield_slam", "revenge", "sweeping_guard", "bulwark_march"],
nia: ["quick_shot", "aimed_shot", "rapid_fire", "kill_shot", "deadeye"],
orin: ["arcane_bolt", "ember_lance", "arcane_burst", "comet", "overcharge"],
vale: ["quick_cut", "twin_fang", "backstab", "fan_of_blades", "blade_flurry"],
};
export interface PartyCombatTarget {
instanceId: string;
boss: BossState;
motion: BossMotionState;
}
export interface PartyDamageEvent {
id: number;
at: number;
sourceId: AiCombatantId;
abilityId: PartyAbilityId;
targetInstanceId: string;
amount: number;
secondary: boolean;
}
export interface PartyCombatAction {
abilityId: PartyAbilityId;
targetInstanceId: string;
startedAt: number;
completesAt: number;
impactTimes: number[];
nextImpactIndex: number;
baseDamage: number;
requiresStationary: boolean;
multiplier: number;
}
export interface PartyVisualAction {
abilityId: PartyAbilityId;
targetInstanceId: string;
startedAt: number;
impactAt: number;
endsAt: number;
}
export interface PartyCombatantState {
id: AiCombatantId;
readyAt: number;
resource: number;
points: number;
cooldowns: Partial<Record<PartyAbilityId, number>>;
activeAction: PartyCombatAction | null;
visualAction: PartyVisualAction | null;
overchargeStacks: number;
bladeFlurryUntil: number;
revengeReadyUntil: number;
lastHp: number;
damageDone: number;
}
export interface TankAuraState {
expiresAt: number;
radius: number;
damageReduction: number;
}
export interface PartyCombatState {
combatants: Record<AiCombatantId, PartyCombatantState>;
tankAura: TankAuraState;
nextEventId: number;
}
export interface PartyCombatContext {
oldTime: number;
time: number;
party: PartyMember[];
oldPositions: Record<MemberId, WorldPosition>;
positions: Record<MemberId, WorldPosition>;
targets: PartyCombatTarget[];
upcomingMechanicRemaining: number;
}
interface AbilitySpec {
id: PartyAbilityId;
duration: number;
impactOffsets: number[];
damage: number;
gcd: number;
cooldown?: number;
requiresStationary?: boolean;
}
const EMPTY_AURA: TankAuraState = { expiresAt: 0, radius: 3, damageReduction: 0.3 };
const RANGED_IDS: readonly AiCombatantId[] = ["nia", "orin"];
const VALE_CLEAVE_RADIUS = 3.6;
const VALE_MELEE_RANGE = 3.65;
const BRANN_MELEE_RANGE = 4.8;
const PARTY_DAMAGE_SCALE = 1.65;
function combatant(id: AiCombatantId, hp: number): PartyCombatantState {
return {
id,
readyAt: 0,
resource: id === "vale" ? 100 : 0,
points: 0,
cooldowns: {},
activeAction: null,
visualAction: null,
overchargeStacks: 0,
bladeFlurryUntil: 0,
revengeReadyUntil: 0,
lastHp: hp,
damageDone: 0,
};
}
export function createPartyCombatState(party: PartyMember[]): PartyCombatState {
const hp = (id: AiCombatantId) => party.find((member) => member.id === id)?.hp ?? 0;
return {
combatants: {
brann: combatant("brann", hp("brann")),
nia: combatant("nia", hp("nia")),
orin: combatant("orin", hp("orin")),
vale: combatant("vale", hp("vale")),
},
tankAura: { ...EMPTY_AURA },
nextEventId: 1,
};
}
function cloneCombatant(source: PartyCombatantState): PartyCombatantState {
return {
...source,
cooldowns: { ...source.cooldowns },
activeAction: source.activeAction ? { ...source.activeAction, impactTimes: [...source.activeAction.impactTimes] } : null,
visualAction: source.visualAction ? { ...source.visualAction } : null,
};
}
function isReady(actor: PartyCombatantState, abilityId: PartyAbilityId, at: number) {
return (actor.cooldowns[abilityId] ?? 0) <= at + 0.001;
}
function moving(id: AiCombatantId, context: PartyCombatContext) {
const elapsed = Math.max(0.001, context.time - context.oldTime);
return distance(context.oldPositions[id], context.positions[id]) / elapsed > 0.35;
}
function livingTargets(targets: PartyCombatTarget[]) {
return targets.filter((target) => target.boss.hp > 0);
}
function targetsInRange(source: WorldPosition, targets: PartyCombatTarget[], range: number) {
return livingTargets(targets).filter((target) => distance(source, target.motion.position) <= range);
}
function targetFor(id: AiCombatantId, context: PartyCombatContext, range = Number.POSITIVE_INFINITY) {
const eligible = targetsInRange(context.positions[id], context.targets, range);
if (!eligible.length) return undefined;
if (id === "orin" && eligible.length > 1) return eligible[1];
if (RANGED_IDS.includes(id)) return eligible[0];
let nearest = eligible[0];
for (let index = 1; index < eligible.length; index += 1) {
if (distance(context.positions[id], eligible[index].motion.position) < distance(context.positions[id], nearest.motion.position)) nearest = eligible[index];
}
return nearest;
}
function partyNeedsBulwark(context: PartyCombatContext) {
const brann = context.party.find((member) => member.id === "brann");
const lowMembers = context.party.filter((member) => member.hp > 0 && member.hp / member.maxHp < 0.6).length;
return context.upcomingMechanicRemaining <= 2 || (brann?.hp ?? 0) / Math.max(1, brann?.maxHp ?? 1) < 0.7 || lowMembers >= 2;
}
function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean, context: PartyCombatContext): AbilitySpec | null {
const source = context.positions[actor.id];
const rangedTarget = targetFor(actor.id, context);
if (actor.id === "nia") {
if (!rangedTarget) return null;
if (rangedTarget.boss.hp / rangedTarget.boss.maxHp <= 0.25 && isReady(actor, "kill_shot", at)) return { id: "kill_shot", duration: 0.45, impactOffsets: [0.24], damage: 7, gcd: 1.1, cooldown: 10 };
if (!isMoving && isReady(actor, "rapid_fire", at)) return { id: "rapid_fire", duration: 2, impactOffsets: [0.4, 0.8, 1.2, 1.6], damage: 2, gcd: 2, cooldown: 9, requiresStationary: true };
if (!isMoving && actor.resource >= 50) return { id: "deadeye", duration: 1, impactOffsets: [1], damage: 10, gcd: 1.1, requiresStationary: true };
if (!isMoving) return { id: "aimed_shot", duration: 1.5, impactOffsets: [1.5], damage: 4, gcd: 1.5, requiresStationary: true };
return { id: "quick_shot", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.15 };
}
if (actor.id === "orin") {
if (!rangedTarget) return null;
if (!isMoving && actor.overchargeStacks === 0 && isReady(actor, "overcharge", at)) return { id: "overcharge", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 20 };
if (!isMoving && isReady(actor, "comet", at)) return { id: "comet", duration: 2, impactOffsets: [2], damage: 9, gcd: 2, cooldown: 12, requiresStationary: true };
if (!isMoving && actor.points >= 3) return { id: "arcane_burst", duration: 1, impactOffsets: [1], damage: 8, gcd: 1.1, requiresStationary: true };
if (isReady(actor, "ember_lance", at)) return { id: "ember_lance", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.1, cooldown: 4 };
if (isMoving) return null;
return { id: "arcane_bolt", duration: 1.4, impactOffsets: [1.4], damage: 3, gcd: 1.4, requiresStationary: true };
}
if (actor.id === "vale") {
const nearby = targetsInRange(source, context.targets, VALE_CLEAVE_RADIUS);
const target = targetFor(actor.id, context, VALE_MELEE_RANGE);
if (!target) return null;
if (nearby.length > 1 && actor.bladeFlurryUntil <= at && actor.resource >= 20 && isReady(actor, "blade_flurry", at)) return { id: "blade_flurry", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 15 };
if (nearby.length > 1 && actor.resource >= 35 && isReady(actor, "fan_of_blades", at)) return { id: "fan_of_blades", duration: 0.65, impactOffsets: [0.4], damage: 3, gcd: 1.1, cooldown: 6 };
if (actor.points >= 3 && actor.resource >= 25) return { id: "backstab", duration: 0.7, impactOffsets: [0.45], damage: 5, gcd: 1.1 };
if (nearby.length > 1 && actor.resource >= 30) return { id: "twin_fang", duration: 0.65, impactOffsets: [0.4], damage: 3, gcd: 1.1 };
return { id: "quick_cut", duration: 0.55, impactOffsets: [0.32], damage: 2, gcd: 1.1 };
}
const target = targetFor(actor.id, context, BRANN_MELEE_RANGE);
if (!target) return null;
if (partyNeedsBulwark(context) && isReady(actor, "bulwark_march", at)) return { id: "bulwark_march", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 30 };
if (actor.revengeReadyUntil > at && isReady(actor, "revenge", at)) return { id: "revenge", duration: 0.6, impactOffsets: [0.35], damage: 3, gcd: 1.1, cooldown: 5 };
if (isReady(actor, "shield_slam", at)) return { id: "shield_slam", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 6 };
if (targetsInRange(source, context.targets, BRANN_MELEE_RANGE).length > 1 && isReady(actor, "sweeping_guard", at)) return { id: "sweeping_guard", duration: 0.65, impactOffsets: [0.4], damage: 2, gcd: 1.1, cooldown: 4 };
return { id: "sword_slash", duration: 0.6, impactOffsets: [0.35], damage: 1, gcd: 1.2 };
}
function applyStartCosts(actor: PartyCombatantState, spec: AbilitySpec, at: number, state: PartyCombatState) {
if (spec.cooldown) actor.cooldowns[spec.id] = at + spec.cooldown;
if (spec.id === "quick_shot") actor.resource = Math.min(100, actor.resource + 10);
if (spec.id === "aimed_shot") actor.resource = Math.min(100, actor.resource + 15);
if (spec.id === "deadeye") actor.resource -= 50;
if (spec.id === "arcane_bolt") actor.points = Math.min(3, actor.points + 1);
if (spec.id === "arcane_burst") actor.points = 0;
if (spec.id === "overcharge") actor.overchargeStacks = 3;
if (spec.id === "quick_cut") actor.points = Math.min(5, actor.points + 1);
if (spec.id === "twin_fang") actor.resource -= 30;
if (spec.id === "backstab") { actor.resource -= 25; actor.points = Math.max(0, actor.points - 3); }
if (spec.id === "fan_of_blades") actor.resource -= 35;
if (spec.id === "blade_flurry") { actor.resource -= 20; actor.bladeFlurryUntil = at + 8; }
if (spec.id === "bulwark_march") state.tankAura.expiresAt = at + 6;
}
function startAction(actor: PartyCombatantState, spec: AbilitySpec, target: PartyCombatTarget, at: number, state: PartyCombatState) {
applyStartCosts(actor, spec, at, state);
let multiplier = 1;
if (actor.id === "orin" && spec.damage > 0 && actor.overchargeStacks > 0) {
multiplier = 1.25;
actor.overchargeStacks -= 1;
}
actor.readyAt = at + spec.gcd;
actor.activeAction = {
abilityId: spec.id,
targetInstanceId: target.instanceId,
startedAt: at,
completesAt: at + spec.duration,
impactTimes: spec.impactOffsets.map((offset) => at + offset),
nextImpactIndex: 0,
baseDamage: spec.damage,
requiresStationary: spec.requiresStationary ?? false,
multiplier,
};
actor.visualAction = {
abilityId: spec.id,
targetInstanceId: target.instanceId,
startedAt: at,
impactAt: spec.impactOffsets.length ? at + spec.impactOffsets[0] : at + spec.duration,
endsAt: at + Math.max(spec.duration, spec.gcd),
};
}
function damageTarget(
state: PartyCombatState,
actor: PartyCombatantState,
action: PartyCombatAction,
target: PartyCombatTarget,
amount: number,
at: number,
secondary: boolean,
events: PartyDamageEvent[],
) {
if (target.boss.hp <= 0 || amount <= 0) return;
const dealt = Math.min(target.boss.hp, amount * action.multiplier * PARTY_DAMAGE_SCALE);
target.boss.hp -= dealt;
actor.damageDone += dealt;
events.push({ id: state.nextEventId++, at, sourceId: actor.id, abilityId: action.abilityId, targetInstanceId: target.instanceId, amount: dealt, secondary });
}
function resolveImpact(state: PartyCombatState, actor: PartyCombatantState, action: PartyCombatAction, at: number, context: PartyCombatContext, targets: PartyCombatTarget[], events: PartyDamageEvent[]) {
const source = context.positions[actor.id];
const range = actor.id === "vale" ? VALE_MELEE_RANGE : actor.id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
let target = targets.find((entry) => entry.instanceId === action.targetInstanceId && entry.boss.hp > 0 && distance(source, entry.motion.position) <= range);
target ??= targetFor(actor.id, { ...context, targets }, range);
if (!target) return;
if (action.abilityId === "fan_of_blades" || action.abilityId === "sweeping_guard") {
const radius = action.abilityId === "fan_of_blades" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE;
for (const nearby of targetsInRange(source, targets, radius)) damageTarget(state, actor, action, nearby, action.baseDamage, at, nearby.instanceId !== target.instanceId, events);
return;
}
damageTarget(state, actor, action, target, action.baseDamage, at, false, events);
const secondaryTargets = targetsInRange(source, targets, actor.id === "vale" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE).filter((entry) => entry.instanceId !== target!.instanceId);
if (action.abilityId === "twin_fang") {
for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, 2, at, true, events);
} else if (actor.id === "vale" && actor.bladeFlurryUntil > at && !["fan_of_blades", "blade_flurry"].includes(action.abilityId)) {
for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, action.baseDamage * 0.5, at, true, events);
}
}
export function advancePartyCombat(source: PartyCombatState, context: PartyCombatContext) {
const state: PartyCombatState = {
combatants: {
brann: cloneCombatant(source.combatants.brann),
nia: cloneCombatant(source.combatants.nia),
orin: cloneCombatant(source.combatants.orin),
vale: cloneCombatant(source.combatants.vale),
},
tankAura: { ...source.tankAura },
nextEventId: source.nextEventId,
};
const targets = context.targets.map((target) => ({ ...target, boss: { ...target.boss } }));
const events: PartyDamageEvent[] = [];
const elapsed = context.time - context.oldTime;
for (const id of ["brann", "nia", "orin", "vale"] as const) {
const actor = state.combatants[id];
const member = context.party.find((entry) => entry.id === id);
if (!member || member.hp <= 0) continue;
if (member.knockedUntil > context.time) {
actor.activeAction = null;
actor.readyAt = Math.max(actor.readyAt, member.knockedUntil);
continue;
}
if (id === "vale") actor.resource = Math.min(100, actor.resource + 12 * elapsed);
if (id === "brann" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5;
actor.lastHp = member.hp;
const isMoving = moving(id, context);
if (isMoving && actor.activeAction?.requiresStationary) {
actor.activeAction = null;
actor.readyAt = Math.max(actor.readyAt, context.oldTime + 0.2);
if (actor.visualAction) actor.visualAction.endsAt = context.oldTime;
}
for (let safety = 0; safety < 12; safety += 1) {
const action = actor.activeAction;
if (action) {
while (action.nextImpactIndex < action.impactTimes.length && action.impactTimes[action.nextImpactIndex] <= context.time + 0.001) {
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], context, targets, events);
action.nextImpactIndex += 1;
}
if (action.completesAt > context.time + 0.001) break;
actor.activeAction = null;
continue;
}
const startAt = Math.max(context.oldTime, actor.readyAt);
if (startAt > context.time + 0.001) break;
const spec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
if (!spec) { actor.readyAt = context.time + 0.1; break; }
const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
const target = targetFor(id, { ...context, targets }, range);
if (!target) { actor.readyAt = context.time + 0.1; break; }
startAction(actor, spec, target, startAt, state);
}
}
return { state, events };
}
export function tankAuraProtects(position: WorldPosition, tankPosition: WorldPosition, aura: TankAuraState, time: number) {
return aura.expiresAt > time && distance(position, tankPosition) <= aura.radius;
}
+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);
});
});
+537
View File
@@ -0,0 +1,537 @@
import { create } from "zustand";
import {
advanceBossMechanics,
createBossMotionState,
createBossState,
handleBossDispel,
upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data";
import { distance } from "./geometry";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import type {
ActiveCast,
AbilityId,
BarrierState,
BossMotionState,
BossState,
BossId,
BottomTab,
GamePhase,
HealerClassId,
InventoryItem,
MemberId,
PartyMember,
ScenePulse,
WorldPosition,
} from "./types";
export interface CombatLogEntry {
id: number;
time: number;
message: string;
tone: "good" | "danger" | "neutral";
}
export interface AdditionalBossState {
instanceId: string;
boss: BossState;
motion: BossMotionState;
}
interface GameState {
bossId: BossId;
paused: boolean;
pauseSelection: "resume" | "exit";
healerClassId: HealerClassId;
playerName: string;
phase: GamePhase;
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<AbilityId, number>;
globalCooldownUntil: number;
activeTab: BottomTab;
selectedItemId: string;
inventory: InventoryItem[];
combatLog: CombatLogEntry[];
scenePulse: ScenePulse;
playerPosition: [number, number];
activeCast: ActiveCast | null;
barrier: BarrierState;
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[]) => void;
startEncounter: () => void;
restart: () => void;
tick: (delta: number) => void;
castAbility: (abilityId: AbilityId) => boolean;
selectMember: (memberId: MemberId) => void;
cycleMember: (direction: 1 | -1) => void;
setActiveTab: (tab: BottomTab) => void;
selectItem: (itemId: string) => void;
setPlayerPosition: (position: [number, number]) => void;
setPaused: (paused: boolean) => void;
togglePause: () => void;
setPauseSelection: (selection: "resume" | "exit") => void;
}
const emptyCooldowns = (): Record<AbilityId, number> => ({
mend: 0,
renew: 0,
shield: 0,
purify: 0,
radiance: 0,
barrier: 0,
});
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const BARRIER_RADIUS = 3;
export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 2);
return unique.length ? unique : ["bulldrome"];
};
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
const motion = cloneMotion(createBossMotionState(bossId));
const offset = count > 1 ? (index === 0 ? -2.65 : 2.65) : 0;
motion.formationOffsetX = offset;
motion.position[0] += offset;
motion.chargeStart[0] += offset;
motion.chargeEnd[0] += offset;
motion.pounceCenter[0] += offset;
const stagger = index * 2.4;
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
return motion;
}
function createEncounterBoss(bossId: BossId, index: number, count: number): AdditionalBossState {
const boss = createBossState(bossId);
const stagger = index * 0.8;
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4;
if (Number.isFinite(boss.nextBrandAt)) boss.nextBrandAt += index * 2.4;
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
}
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
const bossPosition = createBossMotionState(bossIds[0]).position;
if (bossIds.length > 1) bossPosition[0] = 0;
return { aelia: [0, 4.5], ...combatFormation(bossPosition) };
};
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) };
}
export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
}
function damageMemberAt(
member: PartyMember,
amount: number,
position: WorldPosition,
barrier: BarrierState,
time: number,
partyCombat?: PartyCombatState,
tankPosition?: WorldPosition,
) {
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,
);
return damageMember(member, amount * (1 - reduction));
}
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",
) {
const bossIds = normalizeBossIds(requestedBossIds);
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(bossId, index, bossIds.length));
const primary = encounterBosses[0];
const party = freshParty(healerClassId, playerName);
return {
bossId: primary.boss.id,
paused: false,
pauseSelection: "resume" as const,
healerClassId,
playerName,
phase: "briefing" as GamePhase,
time: 0,
party,
boss: primary.boss,
additionalBosses: encounterBosses.slice(1),
partyPositions: freshPartyPositions(bossIds),
bossMotion: primary.motion,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [] as PartyDamageEvent[],
mana: 100,
maxMana: 100,
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: "mend" as const },
playerPosition: [0, 4.5] as [number, number],
activeCast: null as ActiveCast | null,
barrier: { center: [0, 4.5], expiresAt: 0 } as BarrierState,
};
}
export const useGameStore = create<GameState>((set, get) => ({
...initialState(),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome") => set(initialState(healerClassId, playerName, inventory, bossIds)),
startEncounter: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({
...initialState(healerClassId, playerName, inventory, bossIds),
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, playerName, inventory, boss, additionalBosses } = get();
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]));
},
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 }),
setPlayerPosition: (playerPosition) => set((state) => ({
playerPosition,
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
})),
castAbility: (abilityId) => {
const state = get();
if (state.phase !== "combat") return false;
if (state.paused) return false;
if (state.activeCast) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
const selected = state.party[selectedIndex];
if (state.cooldowns[abilityId] > state.time + 0.01) return false;
if (state.globalCooldownUntil > state.time + 0.001) return false;
if (state.mana < ability.mana) {
set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
return false;
}
if (ability.targeting === "ally" && (!selected || selected.hp <= 0)) return false;
if (abilityId === "purify" && selected.debuffs.length === 0) {
set({ combatLog: addLog(state.combatLog, state.time, `${selected.name} has nothing to ${ability.name}.`) });
return false;
}
if (abilityId === "mend") {
set({
activeCast: {
abilityId: "mend",
targetId: selected.id,
startedAt: state.time,
completesAt: state.time + (ability.castTime ?? 0.5),
},
mana: Math.max(0, state.mana - ability.mana),
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] }));
let message = ability.name;
let barrier = state.barrier;
let bossMotion = state.bossMotion;
let additionalBosses = state.additionalBosses;
switch (abilityId) {
case "renew":
party[selectedIndex] = {
...party[selectedIndex],
renewExpiresAt: state.time + 8,
renewNextTickAt: state.time + 1,
};
message = `${ability.name} placed on ${selected.name}.`;
break;
case "shield":
party[selectedIndex] = {
...party[selectedIndex],
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36),
};
message = `${selected.name} gains 36 absorption.`;
break;
case "purify":
{
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
if (state.boss.id === "vexa") {
const dispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
bossMotion = dispel.motion;
}
additionalBosses = state.additionalBosses.map((entry) => {
if (entry.boss.id !== "vexa") return entry;
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
return { ...entry, motion: dispel.motion };
});
message = dispelledNames.includes("Widow Venom")
? "Widow Venom purged. A venom pool forms where the target stood."
: `${dispelledNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
}
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
break;
case "radiance":
party = party.map((member) => healMember(member, 22));
message = `${ability.name} heals the full party.`;
break;
case "barrier":
barrier = { center: [...state.partyPositions.aelia], expiresAt: state.time + 8 };
message = `${ability.name} protects a 3m circle for 8 seconds.`;
break;
}
const cooldowns = {
...state.cooldowns,
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown : 0,
};
const pulse: ScenePulse = {
id: state.scenePulse.id + 1,
kind: abilityId,
targetId: abilityId === "barrier" ? "aelia" : selected?.id,
};
set({
party,
cooldowns,
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
barrier,
bossMotion,
additionalBosses,
mana: Math.max(0, state.mana - ability.mana),
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);
let party = state.party.map((member) => ({ ...member, debuffs: member.debuffs.map((debuff) => ({ ...debuff })) }));
let boss = { ...state.boss };
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 partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents;
const barrier = state.barrier;
if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
const target = party[targetIndex];
if (target?.hp > 0) {
party[targetIndex] = healMember(target, 38);
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for 38.`, "good");
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
}
activeCast = null;
}
party = party.map((member) => {
let next = member;
if (next.renewExpiresAt > oldTime && next.renewNextTickAt <= time) {
let tickAt = next.renewNextTickAt;
const lastTickAt = Math.min(time, next.renewExpiresAt);
while (tickAt <= lastTickAt + 0.001) {
next = healMember(next, 7);
tickAt += 1;
}
next = {
...next,
renewExpiresAt: time >= next.renewExpiresAt ? 0 : next.renewExpiresAt,
renewNextTickAt: time >= next.renewExpiresAt ? 0 : tickAt,
};
}
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.brann);
updated.nextTickAt += 1;
}
return updated;
})
.filter((debuff) => debuff.expiresAt > time);
return { ...next, debuffs: activeDebuffs };
});
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);
const encounterBosses: AdditionalBossState[] = [
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
...additionalBosses,
];
for (let index = 0; index < encounterBosses.length; index += 1) {
const encounterBoss = encounterBosses[index];
if (encounterBoss.boss.hp <= 0) continue;
const mechanicResult = advanceBossMechanics({
boss: encounterBoss.boss,
motion: encounterBoss.motion,
party,
partyPositions,
time,
delta: time - oldTime,
damageMember: (member, amount, position, at) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann),
});
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: mechanicResult.motion };
party = mechanicResult.party;
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,
});
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);
}
boss = encounterBosses[0].boss;
bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!;
let phase: GamePhase = state.phase;
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
phase = "victory";
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
} else if (tank.hp <= 0 || healer.hp <= 0) {
phase = "defeat";
combatLog = addLog(combatLog, time, tank.hp <= 0 ? `Brann falls. ${boss.name} breaks formation.` : `${healer.name} falls. Healing ends.`, "danger");
}
set({
time,
party,
boss,
additionalBosses,
partyCombat,
partyDamageEvents,
partyPositions,
bossMotion,
phase,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
activeCast,
combatLog,
scenePulse: pulse,
});
},
}));
export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) {
return Math.max(0, cooldowns[abilityId] - time);
}
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));
return candidates.reduce((next, candidate) => candidate.remaining < next.remaining ? candidate : next, candidates[0]);
}
+155
View File
@@ -0,0 +1,155 @@
export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
export type BossId = "bulldrome" | "vexa" | "cindermaw";
export type GamePhase = "briefing" | "combat" | "victory" | "defeat";
export type BottomTab = "combat" | "map" | "pack";
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall";
export type BossMotionMode =
| "holding"
| "telegraph"
| "charging"
| "returning"
| "stacking"
| "pouncing"
| "tethering"
| "venom_cast"
| "breath_telegraph"
| "breath_sweeping"
| "skyfall";
export type WorldPosition = [number, number];
export type CircleHazardKind = "venom_pool" | "skyfall";
export interface CircleHazard {
id: string;
kind: CircleHazardKind;
center: WorldPosition;
radius: number;
activatesAt: number;
expiresAt: number;
damage: number;
tickInterval?: number;
nextDamageAt: Partial<Record<MemberId, number>>;
resolved: boolean;
hitIds: MemberId[];
}
export interface Debuff {
id: string;
name: string;
expiresAt: number;
nextTickAt: number;
tickDamage: number;
}
export interface PartyMember {
id: MemberId;
name: string;
className: string;
role: "Healer" | "Tank" | "Damage";
color: string;
maxHp: number;
hp: number;
absorb: number;
renewExpiresAt: number;
renewNextTickAt: number;
knockedUntil: number;
debuffs: Debuff[];
}
export interface BossState {
id: BossId;
name: string;
maxHp: number;
hp: number;
nextMeleeAt: number;
nextNovaAt: number;
nextBrandAt: number;
brandCount: number;
}
export interface BossMotionState {
bossId: BossId;
formationOffsetX: number;
mode: BossMotionMode;
position: WorldPosition;
chargeStart: WorldPosition;
chargeEnd: WorldPosition;
chargeTargetId: MemberId;
chargeHitIds: MemberId[];
phaseEndsAt: number;
nextChargeAt: number;
chargeCount: number;
chargesSincePounce: number;
pounceTargetId: MemberId;
pounceCenter: WorldPosition;
pounceCount: number;
nextMechanicAt: number;
mechanicCount: number;
phaseStartedAt: number;
mechanicHitIds: MemberId[];
mechanicNextDamageAt: Partial<Record<MemberId, number>>;
tetherIds: MemberId[];
tetherBreakDistance: number;
breathAngle: number;
breathStartAngle: number;
breathEndAngle: number;
hazards: CircleHazard[];
}
export interface AbilityDefinition {
id: AbilityId;
name: string;
shortName: string;
key: string;
gamepad: string;
cooldown: number;
castTime?: number;
mana: number;
icon: string;
description: string;
targeting: "enemy" | "ally" | "party";
color: string;
}
export interface ActiveCast {
abilityId: "mend";
targetId: MemberId;
startedAt: number;
completesAt: number;
}
export interface BarrierState {
center: WorldPosition;
expiresAt: number;
}
export interface ScenePulse {
id: number;
kind: PulseKind;
targetId?: MemberId;
}
export interface InventoryItem {
id: string;
name: string;
slot: string;
rarity: "Common" | "Uncommon" | "Rare";
icon: string;
stats: string[];
effect: string;
equipped: boolean;
}
export type HealerClassId = "priest" | "druid" | "shaman";
export interface HealerClassDefinition {
id: HealerClassId;
name: string;
specialization: string;
icon: string;
color: string;
resourceName: string;
description: string;
abilities: Record<AbilityId, AbilityDefinition>;
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useRef } from "react";
import { ABILITY_ORDER } from "./data";
import { useGameStore } from "./store";
import type { AbilityId } from "./types";
const gamepadAbilityMap: Record<number, AbilityId> = {
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
};
export function useGameLoop() {
useEffect(() => {
let frame = 0;
let previous = performance.now();
let accumulator = 0;
const loop = (now: number) => {
const delta = Math.min((now - previous) / 1000, 0.25);
previous = now;
accumulator += delta;
if (accumulator >= 0.1) {
useGameStore.getState().tick(accumulator);
accumulator = 0;
}
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, []);
}
export function useActionBindings(enabled = true, onExit?: () => void) {
const exitRef = useRef(onExit);
exitRef.current = onExit;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return;
if (event.repeat) return;
const store = useGameStore.getState();
const key = event.key.toLowerCase();
if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false);
if (key === "arrowup") store.setPauseSelection("resume");
if (key === "arrowdown") store.setPauseSelection("exit");
if (key === "enter") {
if (store.pauseSelection === "resume") store.setPaused(false);
else exitRef.current?.();
}
return;
}
const numberIndex = Number(event.key) - 1;
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
store.castAbility(ABILITY_ORDER[numberIndex]);
return;
}
switch (key) {
case "q":
store.cycleMember(-1);
break;
case "e":
store.cycleMember(1);
break;
case "m":
store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
break;
case "i":
store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack");
break;
case "enter":
if (store.phase === "briefing") store.startEncounter();
if (store.phase === "victory" || store.phase === "defeat") store.restart();
break;
case "escape":
if (store.phase === "combat") store.setPaused(true);
else exitRef.current?.();
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enabled]);
useEffect(() => {
let frame = 0;
let previousButtons: boolean[] = [];
const poll = () => {
const gamepad = navigator.getGamepads?.()[0];
if (gamepad && enabled) {
const buttons = gamepad.buttons.map((button) => button.pressed);
const store = useGameStore.getState();
if (store.paused) {
if (buttons[12] && !previousButtons[12]) store.setPauseSelection("resume");
if (buttons[13] && !previousButtons[13]) store.setPauseSelection("exit");
if ((buttons[1] && !previousButtons[1]) || (buttons[9] && !previousButtons[9])) store.setPaused(false);
if (buttons[0] && !previousButtons[0]) {
if (store.pauseSelection === "resume") store.setPaused(false);
else exitRef.current?.();
}
} else {
for (const [button, ability] of Object.entries(gamepadAbilityMap)) {
const index = Number(button);
if (buttons[index] && !previousButtons[index]) store.castAbility(ability);
}
if (buttons[12] && !previousButtons[12]) store.cycleMember(-1);
if (buttons[13] && !previousButtons[13]) store.cycleMember(1);
if (buttons[8] && !previousButtons[8]) store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
if (buttons[9] && !previousButtons[9]) {
if (store.phase === "briefing") store.startEncounter();
if (store.phase === "victory" || store.phase === "defeat") store.restart();
if (store.phase === "combat") store.setPaused(true);
}
}
previousButtons = buttons;
} else {
previousButtons = [];
}
frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
return () => cancelAnimationFrame(frame);
}, [enabled]);
}