Release v0.1.2 2026-07-11
This commit is contained in:
@@ -28,7 +28,10 @@ describe("full-mechanics dual-boss battle simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = [
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "cindermaw"],
|
||||
["bulldrome", "ember-mantis-duelist"],
|
||||
["vexa", "cindermaw"],
|
||||
["vexa", "ember-mantis-duelist"],
|
||||
["cindermaw", "ember-mantis-duelist"],
|
||||
];
|
||||
|
||||
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||
|
||||
+16
-1
@@ -16,7 +16,7 @@ export interface BossDefinition {
|
||||
maxHp: number;
|
||||
}
|
||||
|
||||
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
|
||||
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw", "ember-mantis-duelist"];
|
||||
|
||||
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
bulldrome: {
|
||||
@@ -64,4 +64,19 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
mechanics: ["Searing Sweep", "Skyfall"],
|
||||
maxHp: 410,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
id: "ember-mantis-duelist",
|
||||
name: "Ember Mantis Duelist",
|
||||
title: "The Cinderblade",
|
||||
trial: "Trial IV · Blades in Motion",
|
||||
icon: "⚔",
|
||||
accent: "#ff6a2a",
|
||||
summary: "Sidesteps across the arena before carving single and crossed slash lanes.",
|
||||
briefing: "Track each sidestep. Clear the glowing Line Slash, then find a safe quadrant when both scythes form Cross Slash.",
|
||||
failure: "Do not chase the duelist through a telegraph. Preserve space and move perpendicular to each ember lane.",
|
||||
mapTitle: "The Cinderblade Court",
|
||||
mapCopy: "Follow the mantis laterally, but cross glowing cut lanes only after the blades finish their recovery.",
|
||||
mechanics: ["Line Slash", "Cross Slash"],
|
||||
maxHp: 520,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
|
||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
|
||||
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
|
||||
@@ -39,6 +40,7 @@ const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale"
|
||||
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
if (bossId === "vexa") return createVexaState();
|
||||
if (bossId === "cindermaw") return createCindermawState();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
|
||||
const definition = BOSS_DEFINITIONS.bulldrome;
|
||||
return {
|
||||
id: "bulldrome",
|
||||
@@ -55,6 +57,7 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||
if (bossId === "vexa") return createVexaMotion();
|
||||
if (bossId === "cindermaw") return createCindermawMotion();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
|
||||
return {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "holding",
|
||||
@@ -348,6 +351,7 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
|
||||
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
|
||||
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
|
||||
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
|
||||
return advanceBulldromeMechanics(context);
|
||||
}
|
||||
|
||||
@@ -371,6 +375,7 @@ export function handleBossDispel(
|
||||
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 (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time);
|
||||
if (motion.mode === "telegraph") {
|
||||
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "../data";
|
||||
import { pointToSegmentDistance } from "../geometry";
|
||||
import { evadeSlashLanesBehavior } from "../partyBehaviors";
|
||||
import type { BossMechanicContext } from "./types";
|
||||
import {
|
||||
advanceEmberMantisMechanics,
|
||||
createEmberMantisMotion,
|
||||
createEmberMantisState,
|
||||
EMBER_MANTIS_SLASH,
|
||||
} from "./emberMantis";
|
||||
|
||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [0, 0],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
};
|
||||
|
||||
function context(
|
||||
boss: ReturnType<typeof createEmberMantisState>,
|
||||
motion: ReturnType<typeof createEmberMantisMotion>,
|
||||
party = freshParty(),
|
||||
time = 0,
|
||||
delta = 0.1,
|
||||
): BossMechanicContext {
|
||||
return {
|
||||
boss,
|
||||
motion,
|
||||
party,
|
||||
partyPositions: structuredClone(POSITIONS),
|
||||
time,
|
||||
delta,
|
||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Ember Mantis Duelist mechanics", () => {
|
||||
it("sidesteps, telegraphs Line Slash, then damages targets left in the lane", () => {
|
||||
const boss = createEmberMantisState();
|
||||
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
|
||||
expect(sidestep.motion.mode).toBe("mantis_sidestep");
|
||||
expect(sidestep.motion.chargeTargetId).toBe("nia");
|
||||
|
||||
const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6));
|
||||
expect(telegraph.motion.mode).toBe("mantis_line_telegraph");
|
||||
expect(telegraph.motion.slashLanes).toHaveLength(1);
|
||||
|
||||
const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp;
|
||||
const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91));
|
||||
expect(impact.motion.mode).toBe("mantis_recover");
|
||||
expect(impact.motion.mechanicHitIds).toContain("nia");
|
||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage);
|
||||
expect(impact.events.some((event) => event.message.includes("Line Slash"))).toBe(true);
|
||||
});
|
||||
|
||||
it("alternates into two crossed slash lanes", () => {
|
||||
const motion = {
|
||||
...createEmberMantisMotion(),
|
||||
mode: "mantis_sidestep" as const,
|
||||
mechanicCount: 1,
|
||||
chargeTargetId: "orin" as const,
|
||||
chargeEnd: [0, -6.6] as [number, number],
|
||||
phaseEndsAt: 0,
|
||||
};
|
||||
const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1));
|
||||
expect(result.motion.mode).toBe("mantis_cross_telegraph");
|
||||
expect(result.motion.slashLanes).toHaveLength(2);
|
||||
expect(result.motion.slashLanes[0].id).toContain("cross");
|
||||
});
|
||||
|
||||
it("gives mobile allies a target outside crossed lanes", () => {
|
||||
const motion = {
|
||||
...createEmberMantisMotion(),
|
||||
mode: "mantis_sidestep" as const,
|
||||
mechanicCount: 1,
|
||||
chargeTargetId: "orin" as const,
|
||||
chargeEnd: [0, -6.6] as [number, number],
|
||||
phaseEndsAt: 0,
|
||||
};
|
||||
const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion;
|
||||
const decision = evadeSlashLanesBehavior.decide({
|
||||
memberId: "orin",
|
||||
current: POSITIONS.orin,
|
||||
formationTarget: POSITIONS.orin,
|
||||
bossMotion: telegraph,
|
||||
partyPositions: POSITIONS,
|
||||
time: 1,
|
||||
});
|
||||
expect(decision).not.toBeNull();
|
||||
const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) =>
|
||||
pointToSegmentDistance(decision!.target, lane.start, lane.end),
|
||||
));
|
||||
expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const EMBER_MANTIS_SLASH = {
|
||||
firstAt: 5,
|
||||
repeatDelay: 3.4,
|
||||
sidestepDuration: 0.55,
|
||||
sidestepDistance: 3.8,
|
||||
sidestepSpeed: 7.2,
|
||||
telegraphDuration: 0.9,
|
||||
recoverDuration: 0.7,
|
||||
laneLength: 18,
|
||||
lineWidth: 1.65,
|
||||
crossWidth: 1.45,
|
||||
lineDamage: 32,
|
||||
crossDamage: 25,
|
||||
crossAngle: Math.PI * 0.18,
|
||||
staggerDuration: 0.35,
|
||||
aiClearance: 1.35,
|
||||
aiEvadeSpeed: 4.8,
|
||||
} as const;
|
||||
|
||||
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
|
||||
const MIN_BOSS_X = -5.8;
|
||||
const MAX_BOSS_X = 5.8;
|
||||
|
||||
export function createEmberMantisState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: 2.2,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmberMantisMotion(): BossMotionState {
|
||||
return {
|
||||
...createBaseMotion("ember-mantis-duelist"),
|
||||
position: [0, -6.6],
|
||||
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
|
||||
};
|
||||
}
|
||||
|
||||
function clampBossX(value: number) {
|
||||
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
|
||||
}
|
||||
|
||||
function createLane(
|
||||
id: string,
|
||||
center: WorldPosition,
|
||||
angle: number,
|
||||
width: number,
|
||||
damage: number,
|
||||
): SlashLane {
|
||||
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
|
||||
const dx = Math.sin(angle) * halfLength;
|
||||
const dz = Math.cos(angle) * halfLength;
|
||||
return {
|
||||
id,
|
||||
start: [center[0] - dx, center[1] - dz],
|
||||
end: [center[0] + dx, center[1] + dz],
|
||||
width,
|
||||
damage,
|
||||
};
|
||||
}
|
||||
|
||||
function beginSidestep(
|
||||
motion: BossMotionState,
|
||||
party: BossMechanicContext["party"],
|
||||
partyPositions: BossMechanicContext["partyPositions"],
|
||||
time: number,
|
||||
) {
|
||||
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
|
||||
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
|
||||
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
||||
if (Math.abs(targetX - motion.position[0]) < 1) {
|
||||
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
||||
}
|
||||
return {
|
||||
...motion,
|
||||
mode: "mantis_sidestep" as const,
|
||||
chargeTargetId: targetId,
|
||||
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
|
||||
phaseStartedAt: time,
|
||||
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
slashLanes: [],
|
||||
mechanicHitIds: [],
|
||||
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
|
||||
};
|
||||
}
|
||||
|
||||
function beginSlashTelegraph(
|
||||
motion: BossMotionState,
|
||||
partyPositions: BossMechanicContext["partyPositions"],
|
||||
time: number,
|
||||
) {
|
||||
const target = partyPositions[motion.chargeTargetId];
|
||||
const aimedAngle = angleTo(motion.position, target);
|
||||
const isCrossSlash = motion.mechanicCount % 2 === 1;
|
||||
const slashNumber = motion.mechanicCount + 1;
|
||||
const lanes = isCrossSlash
|
||||
? [
|
||||
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
||||
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
||||
]
|
||||
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
|
||||
|
||||
return {
|
||||
...motion,
|
||||
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
|
||||
phaseStartedAt: time,
|
||||
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
|
||||
mechanicCount: slashNumber,
|
||||
slashLanes: lanes,
|
||||
mechanicHitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlash(
|
||||
motion: BossMotionState,
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
|
||||
const hitIds: MemberId[] = [];
|
||||
const party = context.party.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const lane = motion.slashLanes.find((candidate) =>
|
||||
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
|
||||
);
|
||||
if (!lane) return member;
|
||||
hitIds.push(member.id);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `${member.name} is caught by ${isCrossSlash ? "Cross Slash" : "Line Slash"}.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: member.id,
|
||||
});
|
||||
return {
|
||||
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
|
||||
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
|
||||
};
|
||||
});
|
||||
return { party, hitIds };
|
||||
}
|
||||
|
||||
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
let party = context.party;
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(
|
||||
motion.position,
|
||||
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
|
||||
2.5 * context.delta,
|
||||
);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
motion = beginSidestep(motion, party, context.partyPositions, context.time);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `Ember Mantis sidesteps toward ${memberName(party, motion.chargeTargetId)}. Track the blades.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
});
|
||||
}
|
||||
} else if (motion.mode === "mantis_sidestep") {
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
|
||||
if (context.time >= motion.phaseEndsAt) {
|
||||
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
|
||||
const cross = motion.mode === "mantis_cross_telegraph";
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: cross ? "Cross Slash! Find a safe quadrant." : "Line Slash! Clear the glowing lane.",
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
});
|
||||
}
|
||||
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
|
||||
if (context.time >= motion.phaseEndsAt) {
|
||||
const resolved = resolveSlash(motion, context, events);
|
||||
party = resolved.party;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "mantis_recover",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
|
||||
mechanicHitIds: resolved.hitIds,
|
||||
};
|
||||
}
|
||||
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "holding",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: 0,
|
||||
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
|
||||
slashLanes: [],
|
||||
mechanicHitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingEmberMantisMechanic(
|
||||
boss: BossState,
|
||||
motion: BossMotionState,
|
||||
time: number,
|
||||
): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "mantis_sidestep") {
|
||||
return { name: "Duelist repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_line_telegraph") {
|
||||
return { name: "Line Slash — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_cross_telegraph") {
|
||||
return { name: "Cross Slash — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_recover") {
|
||||
return { name: "Cinderblade exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
|
||||
}
|
||||
const nextIsCross = motion.mechanicCount % 2 === 1;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return {
|
||||
name: nextIsCross ? "Cross Slash" : "Line Slash",
|
||||
remaining,
|
||||
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
|
||||
urgent: remaining < 2.5,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||
breathStartAngle: 0,
|
||||
breathEndAngle: 0,
|
||||
hazards: [],
|
||||
slashLanes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +51,11 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
|
||||
nextDamageAt: { ...hazard.nextDamageAt },
|
||||
hitIds: [...hazard.hitIds],
|
||||
})),
|
||||
slashLanes: source.slashLanes.map((lane) => ({
|
||||
...lane,
|
||||
start: [lane.start[0], lane.start[1]],
|
||||
end: [lane.end[0], lane.end[1]],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { CINDER_BREATH } from "./bosses/cindermaw";
|
||||
import { EMBER_MANTIS_SLASH } from "./bosses/emberMantis";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
@@ -122,6 +123,42 @@ export const avoidBreathBehavior: PartyBehavior = {
|
||||
},
|
||||
};
|
||||
|
||||
export const evadeSlashLanesBehavior: PartyBehavior = {
|
||||
id: "evade-slash-lanes",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion }) => {
|
||||
if (bossMotion.mode !== "mantis_line_telegraph" && bossMotion.mode !== "mantis_cross_telegraph") return null;
|
||||
if (!bossMotion.slashLanes.length) return null;
|
||||
const unsafe = (position: WorldPosition) => bossMotion.slashLanes.some((lane) =>
|
||||
pointToSegmentDistance(position, lane.start, lane.end) < EMBER_MANTIS_SLASH.aiClearance,
|
||||
);
|
||||
if (!unsafe(current) && !unsafe(formationTarget)) return null;
|
||||
|
||||
const origin = unsafe(formationTarget) ? formationTarget : current;
|
||||
const memberOffset = AI_MEMBER_IDS.indexOf(memberId) * (Math.PI / 4);
|
||||
let best = current;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
for (const radius of [2.2, 3.4]) {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const angle = memberOffset + (index / 8) * Math.PI * 2;
|
||||
const candidate = clampToArena([
|
||||
origin[0] + Math.sin(angle) * radius,
|
||||
origin[1] + Math.cos(angle) * radius,
|
||||
]);
|
||||
const laneDistance = Math.min(...bossMotion.slashLanes.map((lane) =>
|
||||
pointToSegmentDistance(candidate, lane.start, lane.end),
|
||||
));
|
||||
const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08;
|
||||
const score = laneDistance - travelPenalty;
|
||||
if (score > bestScore) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { target: best, speed: EMBER_MANTIS_SLASH.aiEvadeSpeed };
|
||||
},
|
||||
};
|
||||
|
||||
export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
id: "avoid-circle-hazards",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
|
||||
@@ -143,7 +180,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover"].includes(bossMotion.mode)) return null;
|
||||
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
|
||||
},
|
||||
};
|
||||
@@ -152,6 +189,7 @@ export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
|
||||
breakTetherBehavior,
|
||||
stackForPounceBehavior,
|
||||
evadeChargeBehavior,
|
||||
evadeSlashLanesBehavior,
|
||||
avoidBreathBehavior,
|
||||
avoidCircleHazardsBehavior,
|
||||
maintainFormationBehavior,
|
||||
|
||||
@@ -144,7 +144,10 @@ describe("dual-boss damage simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = [
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "cindermaw"],
|
||||
["bulldrome", "ember-mantis-duelist"],
|
||||
["vexa", "cindermaw"],
|
||||
["vexa", "ember-mantis-duelist"],
|
||||
["cindermaw", "ember-mantis-duelist"],
|
||||
];
|
||||
|
||||
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
|
||||
|
||||
+16
-3
@@ -1,9 +1,9 @@
|
||||
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 BossId = "bulldrome" | "vexa" | "cindermaw" | "ember-mantis-duelist";
|
||||
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 PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
|
||||
export type BossMotionMode =
|
||||
| "holding"
|
||||
| "telegraph"
|
||||
@@ -15,7 +15,11 @@ export type BossMotionMode =
|
||||
| "venom_cast"
|
||||
| "breath_telegraph"
|
||||
| "breath_sweeping"
|
||||
| "skyfall";
|
||||
| "skyfall"
|
||||
| "mantis_sidestep"
|
||||
| "mantis_line_telegraph"
|
||||
| "mantis_cross_telegraph"
|
||||
| "mantis_recover";
|
||||
export type WorldPosition = [number, number];
|
||||
|
||||
export type CircleHazardKind = "venom_pool" | "skyfall";
|
||||
@@ -34,6 +38,14 @@ export interface CircleHazard {
|
||||
hitIds: MemberId[];
|
||||
}
|
||||
|
||||
export interface SlashLane {
|
||||
id: string;
|
||||
start: WorldPosition;
|
||||
end: WorldPosition;
|
||||
width: number;
|
||||
damage: number;
|
||||
}
|
||||
|
||||
export interface Debuff {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -95,6 +107,7 @@ export interface BossMotionState {
|
||||
breathStartAngle: number;
|
||||
breathEndAngle: number;
|
||||
hazards: CircleHazard[];
|
||||
slashLanes: SlashLane[];
|
||||
}
|
||||
|
||||
export interface AbilityDefinition {
|
||||
|
||||
Reference in New Issue
Block a user