Release v0.1.5 2026-07-12

This commit is contained in:
Warren H
2026-07-12 22:10:12 -04:00
parent bef71d391a
commit 35553c18dd
41 changed files with 2005 additions and 2654 deletions
+29 -423
View File
@@ -1,447 +1,53 @@
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
import { clampToArena } from "./arena";
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
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";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import {
advanceMechanicLoadout,
BOSS_MECHANIC_REGISTRY,
BULL_CHARGE,
BULL_POUNCE,
handleMechanicDispel,
upcomingLoadoutMechanic,
} from "./bosses/mechanicPool";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
import type { BossId, BossMotionState, BossState, MemberId, 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: 200,
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 { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
export function createBossState(bossId: BossId = "bulldrome"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
let state: BossState;
if (archetype === "web-caster") state = createVexaState(bossId);
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
else if (archetype === "duelist") state = createEmberMantisState(bossId);
else if (archetype === "ram") state = createObsidianRamState(bossId);
else if (archetype === "ricochet") state = createCinderbackState(bossId);
else if (archetype === "burrower") state = createSandglassState();
else if (archetype === "crab") state = createCragclawState();
else if (archetype === "ghost") state = createMournveilState();
else if (archetype === "golem") state = createCrownshardState();
else {
state = {
id: bossId,
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,
};
}
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
return {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2,
};
}
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
let motion: BossMotionState;
if (archetype === "web-caster") motion = createVexaMotion(bossId);
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
else if (archetype === "ram") motion = createObsidianRamMotion(bossId);
else if (archetype === "ricochet") motion = createCinderbackMotion(bossId);
else if (archetype === "burrower") motion = createSandglassMotion();
else if (archetype === "crab") motion = createCragclawMotion();
else if (archetype === "ghost") motion = createMournveilMotion();
else if (archetype === "golem") motion = createCrownshardMotion();
else motion = {
...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,
return {
...createBaseMotion(bossId),
position: [0, -6.8],
chargeStart: [0, -6.8],
nextMechanicAt: 5,
};
return { ...motion, bossId };
}
function mechanicArchetype(bossId: BossId): BossArchetype {
return BOSS_ARCHETYPE_BY_ID[bossId];
}
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 clampToArena([
start[0] + (dx / length) * BULL_CHARGE.distance,
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") {
returnBossToArenaCenter(motion, delta, 1.8);
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") {
if (returnBossToArenaCenter(motion, delta, 4.4)) {
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,
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,
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 {
const archetype = mechanicArchetype(context.boss.id);
const result = archetype === "web-caster" ? advanceVexaMechanics(context)
: archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context)
: archetype === "duelist" ? advanceEmberMantisMechanics(context)
: archetype === "ram" ? advanceObsidianRamMechanics(context)
: archetype === "ricochet" ? advanceCinderbackMechanics(context)
: archetype === "burrower" ? advanceSandglassMechanics(context)
: archetype === "crab" ? advanceCragclawMechanics(context)
: archetype === "ghost" ? advanceMournveilMechanics(context)
: archetype === "golem" ? advanceCrownshardMechanics(context)
: advanceBulldromeMechanics(context);
return advancePooledBossMechanics(context, result);
return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds);
}
export function handleBossDispel(
bossId: BossId,
_bossId: BossId,
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
) {
if (mechanicArchetype(bossId) === "web-caster" && 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." };
return handleMechanicDispel(motion, memberId, position, time, debuffNames);
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
const pooled = upcomingPooledMechanic(motion, time);
if (pooled) return pooled;
const archetype = mechanicArchetype(boss.id);
if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time);
if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time);
if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time);
if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time);
if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time);
if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time);
if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time);
if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time);
if (archetype === "golem") return upcomingCrownshardMechanic(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 };
return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, motion, time);
}