Compare commits

...
1 Commits
Author SHA1 Message Date
Warren H 35553c18dd Release v0.1.5 2026-07-12 2026-07-12 22:10:12 -04:00
41 changed files with 2005 additions and 2654 deletions
+11 -4
View File
@@ -96,17 +96,24 @@ Stable locked 60 FPS is a release requirement, not a best-effort goal.
- Verify the changed code and its direct dependencies. Do not run tests, visual QA, or controller checks for unrelated screens or systems by default.
- For a screen change, check that screen's applicable display layouts, browser fallback, and controller path only.
- For a reusable mechanic or domain change, run its focused tests plus direct consumers affected by the change.
- Run viewport, display-layout, and browser-fallback testing only when a change adds or modifies a screen, menu, modal, HUD, overlay, or other UI/layout behavior.
- Combat rules, encounter mechanics, world-space telegraphs, animations, VFX, tuning, and other gameplay-only changes do not require viewport testing unless they also change UI or screen layout.
- Expand to broader regression or full-suite verification only for shared foundations, cross-cutting changes, risky refactors, release validation, or when explicitly requested.
- State what was verified and any deliberately unverified scope in the handoff.
## Definition of done
A screen or mechanic is not complete until:
A screen or UI change is not complete until:
1. Its main-display UI is designed and verified at the approximately `960 x 540` Android CSS/layout viewport, with rendering validated against the `1920 x 1080` physical panel.
2. Its applicable secondary UI is designed and verified at the approximately `620 x 540` Android CSS/layout viewport, with rendering validated against the `1240 x 1080` physical panel.
3. It has a complete single-display browser fallback.
4. Every action is reachable and understandable using only a controller, with no click-to-focus step.
5. Shared game logic is modular, typed, and testable outside the renderer/UI.
6. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
Every code change is not complete until:
1. Shared game logic is modular, typed, and testable outside the renderer/UI.
2. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
3. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
Gameplay-only mechanic and domain changes do not inherit the screen/UI viewport requirements above.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.4",
"version": "0.1.5",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+8 -3
View File
@@ -2,6 +2,7 @@ import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
@@ -85,12 +86,16 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget;
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
@@ -106,7 +111,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / ability.cooldown) } as React.CSSProperties}>
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
+23 -9
View File
@@ -1,14 +1,19 @@
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
import { RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers";
import { useGameStore } from "../game/store";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runBuffs = useGameStore((state) => state.runBuffs);
const healerClassId = useGameStore((state) => state.healerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1;
const abilities = HEALER_CLASSES[healerClassId].abilities;
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<header>
@@ -16,10 +21,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className="buff-choice-grid">
{choices.map((buffId) => {
<div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const stacks = countRunBuff(runBuffs, buffId);
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
return (
<button
key={buffId}
@@ -31,14 +38,21 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
<b>{buff.summary}</b>
<span><small>{rank ? `Rank ${rank} ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
<p>{buff.detail}</p>
</button>
);
})}
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
<p>Keep completed build and begin next randomized encounter.</p>
</button>
)}
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Claim</footer>
<footer>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</footer>
</div>
);
}
+115 -56
View File
@@ -5,9 +5,11 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import { ABILITY_ORDER } from "../game/data";
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
import type { AbilityId, BossId } from "../game/types";
import {
GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER,
@@ -483,10 +485,14 @@ function GearScreen() {
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId);
const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId);
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility);
const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion);
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
@@ -503,6 +509,10 @@ function GearScreen() {
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest";
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === selectedPassiveAbilityId);
const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId];
const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities;
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
@@ -518,7 +528,7 @@ function GearScreen() {
id: `slot-${slotId}`,
run: () => selectSlot(slotId),
neighbors: {
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`,
right: previewEntryId,
@@ -531,25 +541,42 @@ function GearScreen() {
run: () => selectInfusion(infusion.id),
neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
right: index === infusionChoices.length - 1 ? "install-infusion" : undefined,
},
})),
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({
id: `passive-ability-${abilityId}`,
run: () => selectPassiveAbility(abilityId),
neighbors: {
up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`,
down: index < 3
? `passive-ability-${ABILITY_ORDER[index + 3]}`
: `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`,
left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`,
right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined,
},
})) : []),
...(healerOwner ? passiveChoices.map((passive, index) => ({
id: `passive-${passive.id}`,
run: () => installPassive(passive.id),
run: () => {
selectPassiveInfusion(passive.id);
installPassive(passive.id);
},
enabled: passiveUnlocked,
neighbors: {
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } },
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-");
if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
@@ -585,7 +612,25 @@ function GearScreen() {
<div className="gear-infusion-options">
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
</div>
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
{healerOwner && <div className="gear-passive-options">
<span>Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<div className="gear-passive-ability-filter">
{ABILITY_ORDER.map((abilityId) => <FocusButton key={abilityId} id={`passive-ability-${abilityId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</FocusButton>)}
</div>
<div className="gear-passive-choice-list">
{passiveChoices.map((passive) => <FocusButton
key={passive.id}
id={`passive-${passive.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
disabled={!passiveUnlocked}
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
onFocus={() => selectPassiveInfusion(passive.id)}
onPointerEnter={() => selectPassiveInfusion(passive.id)}
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilityId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}
</div>
</div>}
</article>}
</div>
<ControllerLegend back />
@@ -593,15 +638,15 @@ function GearScreen() {
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{passiveContext ? <article className={passiveUnlocked ? "is-met" : "is-missing"}><i>{passiveUnlocked ? "✓" : "×"}</i><span><strong>{formatRunBuffEffect(selectedPassive.id, 1)}</strong><small>{selectedPassive.detail}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}</b></article> : (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -664,8 +709,6 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const bossPageSize = 12;
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
@@ -673,14 +716,11 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
const changeBossPage = (nextPage: number) => {
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = selectedBossGroup.bossIds;
const bossGridColumns = Math.min(2, visibleBossIds.length);
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
@@ -688,31 +728,41 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
const groupColumns = 5;
const row = Math.floor(index / groupColumns);
const column = index % groupColumns;
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
return {
id: `boss-group-${group.id}`,
run: () => selectBossGroup(group.id),
neighbors: {
up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back",
down: groupAt(row + 1, column)
? `boss-group-${groupAt(row + 1, column)?.id}`
: group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined,
left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined,
right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined,
},
};
}) : []),
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const row = Math.floor(index / bossGridColumns);
const column = index % bossGridColumns;
const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn];
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
? `boss-${visibleBossIds[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
},
};
}) : []),
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
@@ -724,15 +774,15 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
? [
[selectedBoss.name, selectedBoss.summary],
[selectedBoss.mechanics[0], selectedBoss.briefing],
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."],
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
]
: isPve
? [
@@ -753,17 +803,26 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<div className="boss-picker-heading">
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
<div>
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton>
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton>
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
<div className="boss-group-grid" aria-label="Choose boss group">
{BOSS_GROUPS.map((group) => (
<FocusButton
key={group.id}
id={`boss-group-${group.id}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-group-choice ${group.id === selectedBossGroup.id ? "is-selected" : ""}`}
aria-pressed={group.id === selectedBossGroup.id}
onClick={() => selectBossGroup(group.id)}
>
<b>{group.letter}</b><span><strong>Group {group.letter}</strong><small>{group.name}</small></span>
</FocusButton>
))}
</div>
</div>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
<div className="boss-group-heading"><span>Group {selectedBossGroup.letter} · {selectedBossGroup.name}</span><small>{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians</small></div>
<div className="boss-choice-grid">
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return (
<FocusButton
key={bossId}
@@ -775,7 +834,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
+18 -88
View File
@@ -6,6 +6,7 @@ import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import { bossAnimationCue } from "../game/bosses/mechanicPool";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
@@ -598,7 +599,9 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
function BullBoss({ bossIndex }: { bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const motionMode = motion?.mode ?? "holding";
const animationCue = motion ? bossAnimationCue(motion) : "idle";
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
@@ -618,14 +621,12 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const clipName = phase === "victory" || defeated
? "Death"
: motionMode === "telegraph"
: animationCue === "attack"
? "Idle_Headlow"
: motionMode === "pouncing"
: animationCue === "special"
? "Gallop_Jump"
: motionMode === "charging" || motionMode === "returning"
: animationCue === "move"
? "Gallop"
: motionMode === "stacking"
? "Idle_Headlow"
: "Idle";
useEffect(() => {
@@ -661,9 +662,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
} else if (motion.mode === "returning") {
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
facingZ = ARENA_CENTER[1] - motion.position[1];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
@@ -745,7 +743,7 @@ const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
},
"crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 1.04,
scale: 0.828,
idle: "Idle",
move: "Swoop",
attack: "SonicPulse",
@@ -820,84 +818,17 @@ const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
},
};
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
"charging",
"returning",
"sandglass_burrowing",
"crab_scuttling",
] as const;
const PROTOTYPE_ATTACK_MODES = [
"tethering",
"venom_cast",
"breath_telegraph",
"breath_sweeping",
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_quake",
"ram_shatter",
"cinderback_curl",
"cinderback_slam",
"ghost_soul_cross",
"ghost_soul_cross_followup",
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
"telegraph",
"stacking",
"pouncing",
"sandglass_burrow_telegraph",
"sandglass_eruption",
"sandglass_hourglass",
"crab_scuttle_telegraph",
"crab_tidal_burst",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if (config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
if (motionMode === "sandglass_hourglass") return config.special;
if (motionMode === "sandglass_recover") return "Stagger";
}
if (kind === "cragclaw-crab") {
if (motionMode === "crab_scuttling") return config.move;
if (motionMode === "crab_scuttle_telegraph") return config.attack;
if (motionMode === "crab_tidal_burst") return config.special;
}
if (kind === "mournveil-ghost") {
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
if (motionMode === "ghost_haunting") return config.special;
}
if (kind === "crownshard-golem") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "crystal-bat-matriarch") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
if (motionMode === "golem_recover") return "Stagger";
}
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
return config[bossAnimationCue(motion)];
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const motionMode = motion?.mode ?? "holding";
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
@@ -915,7 +846,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
});
}, [kind, model]);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
useEffect(() => {
const next = actions[clipName];
@@ -944,7 +875,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return;
const motion = current.motion;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing";
const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging";
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
@@ -955,14 +886,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
);
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (archetype === "duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
} else if (
motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
)) {
) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
} else if (motion.mode === "telegraph" || motion.mode === "charging") {
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
}
const difference = Math.atan2(
+96 -21
View File
@@ -1,18 +1,17 @@
import { useFrame } from "@react-three/fiber";
import { useRef, type ComponentType } from "react";
import { Html } from "@react-three/drei";
import { useLayoutEffect, useRef, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { useGameStore } from "../../game/store";
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
import type { BossMotionMode, MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const ACTIVE_LANE_MODES = new Set<BossMotionMode>(["charging"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
@@ -290,7 +289,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
/** Tall gold ward plus a lightweight spectral pursuer for the shared Soul Siphon mechanic. */
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
@@ -392,12 +391,49 @@ function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySym
);
}
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
const MEMORY_SYMBOL_GLYPHS: Record<MemorySymbolId, string> = {
triangle: "△",
cross: "+",
circle: "○",
square: "◇",
};
function MemoryTileIndicator({ tile, inputActive, completed }: { tile: MemoryTile; inputActive: boolean; completed: boolean }) {
const color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const;
const group = useRef<THREE.Group>(null);
const fade = useRef(1);
const materials = useRef<{ material: THREE.MeshBasicMaterial; baseOpacity: number }[]>([]);
useLayoutEffect(() => {
const nextMaterials: { material: THREE.MeshBasicMaterial; baseOpacity: number }[] = [];
group.current?.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const childMaterials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of childMaterials) {
if (material instanceof THREE.MeshBasicMaterial) {
nextMaterials.push({ material, baseOpacity: material.opacity });
}
}
});
materials.current = nextMaterials;
}, [inputActive]);
useFrame((_, delta) => {
if (!completed && fade.current >= 0.999) return;
if (completed && fade.current <= 0.01) {
fade.current = 0;
if (group.current) group.current.visible = false;
return;
}
if (group.current) group.current.visible = true;
fade.current = THREE.MathUtils.damp(fade.current, completed ? 0 : 1, 18, delta);
for (const entry of materials.current) entry.material.opacity = entry.baseOpacity * fade.current;
});
return (
<group position={[tile.center[0], 0.075, tile.center[1]]}>
<group ref={group} position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
@@ -440,21 +476,60 @@ function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph:
);
const flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center;
const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0;
return (
<>
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
{telegraph.tiles.map((tile) => {
const sequenceIndex = telegraph.sequence!.indexOf(tile.symbol);
return (
<MemoryTileIndicator
key={tile.symbol}
tile={tile}
inputActive={!showingSequence}
completed={sequenceIndex >= 0 && sequenceIndex < completedCount}
/>
);
})}
{showingSequence && (
<group position={[source[0], 2.5, source[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.88, 32]} />
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
</mesh>
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.8, 0.9, 32]} />
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
</mesh>
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
</group>
<Html position={[source[0], 3.1, source[1] + 0.3]} center zIndexRange={[30, 20]} style={{ pointerEvents: "none" }}>
<div style={{ display: "grid", justifyItems: "center", gap: 7 }}>
<div
style={{
width: 64,
height: 64,
display: "grid",
placeItems: "center",
borderRadius: "50%",
border: `4px solid ${MEMORY_SYMBOLS[flashSymbol].color}`,
background: "rgba(17, 24, 39, 0.96)",
boxShadow: "0 0 0 4px rgba(216, 184, 92, 0.92), 0 0 18px rgba(255, 244, 199, 0.8)",
color: MEMORY_SYMBOLS[flashSymbol].color,
fontSize: 47,
fontWeight: 900,
lineHeight: 1,
textShadow: "0 0 8px currentColor",
}}
>
{MEMORY_SYMBOL_GLYPHS[flashSymbol]}
</div>
<div style={{ display: "flex", gap: 6 }}>
{telegraph.sequence.map((_, index) => (
<span
key={index}
style={{
width: index === flashIndex ? 9 : 7,
height: index === flashIndex ? 9 : 7,
borderRadius: "50%",
background: index === flashIndex
? MEMORY_SYMBOLS[flashSymbol].color
: index < flashIndex ? "#fff4c7" : "#64748b",
boxShadow: index === flashIndex ? "0 0 7px currentColor" : "none",
}}
/>
))}
</div>
</div>
</Html>
)}
</>
);
+17 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { groupDrop } from "../game/progression/loot";
import { RUN_BUFF_ORDER } from "../game/roguelike";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
@@ -169,16 +170,29 @@ describe("SaveRepository", () => {
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
created.gearProgress.priest.passiveInfusionId = "restoring-grace" as never;
created.gearProgress.druid.passiveInfusionId = "mend-echo";
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
created.gearProgress.brann.passiveInfusionId = "deep-wells";
created.gearProgress.brann.passiveInfusionId = "deep-wells" as never;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
});
it("persists every new roguelike buff as a healer passive infusion", () => {
for (const passiveId of RUN_BUFF_ORDER) {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
created.gearProgress.priest.passiveInfusionId = passiveId;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
expect(repository.list(null)[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId);
}
});
});
+21 -2
View File
@@ -3,14 +3,14 @@ import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
import { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
import {
equipActiveInfusion,
equipPassiveInfusion,
infusionsForOwner,
} from "../game/progression/infusions";
import type { RunBuffId } from "../game/types";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
const repository = new SaveRepository();
@@ -59,6 +59,8 @@ export interface FrontendState {
selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string;
selectedPassiveAbilityId: AbilityId;
selectedPassiveInfusionId: RunBuffId;
recentRewards: BossRewardAward[];
settings: GameSettings;
notice: string;
@@ -81,6 +83,8 @@ export interface FrontendState {
selectGearSlot: (slotId: GearSlotId) => void;
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
selectInfusion: (infusionId: string) => void;
selectPassiveAbility: (abilityId: AbilityId) => void;
selectPassiveInfusion: (passiveId: RunBuffId) => void;
upgradeSelectedGear: () => boolean;
equipSelectedInfusion: () => boolean;
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
@@ -110,6 +114,8 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id,
selectedPassiveAbilityId: "mend",
selectedPassiveInfusionId: "mend-echo",
recentRewards: [],
settings: loadSettings(),
notice: "",
@@ -187,6 +193,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
selectPassiveAbility: (selectedPassiveAbilityId) => {
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo";
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
},
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId,
selectedPassiveInfusionId,
notice: "",
}),
upgradeSelectedGear: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
if (!activeSlotId) return false;
@@ -326,6 +341,8 @@ export type FrontendSnapshot = Omit<FrontendState,
| "selectGearSlot"
| "selectGearWorkshopMode"
| "selectInfusion"
| "selectPassiveAbility"
| "selectPassiveInfusion"
| "upgradeSelectedGear"
| "equipSelectedInfusion"
| "equipPassiveInfusion"
@@ -359,6 +376,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
selectGearSlot: _selectGearSlot,
selectGearWorkshopMode: _selectGearWorkshopMode,
selectInfusion: _selectInfusion,
selectPassiveAbility: _selectPassiveAbility,
selectPassiveInfusion: _selectPassiveInfusion,
upgradeSelectedGear: _upgradeSelectedGear,
equipSelectedInfusion: _equipSelectedInfusion,
equipPassiveInfusion: _equipPassiveInfusion,
+18
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
describe("boss catalog", () => {
it("derives the available roster from every catalog definition", () => {
@@ -42,4 +43,21 @@ describe("boss catalog", () => {
expect(state.hp).toBe(state.maxHp);
expect(motion.bossId).toBe(bossId);
});
it("resolves every boss loadout through the canonical mechanic registry", () => {
const assigned = new Set(Object.values(BOSS_DEFINITIONS).flatMap((boss) => boss.mechanicIds));
expect(assigned).toEqual(new Set(Object.keys(BOSS_MECHANIC_REGISTRY)));
expect(Object.keys(BOSS_MECHANIC_REGISTRY)).toEqual(BOSS_MECHANIC_POOL.map((mechanic) => mechanic.id));
for (const boss of Object.values(BOSS_DEFINITIONS)) {
expect(boss.mechanicIds).toContain("basic-melee");
for (const mechanicId of boss.mechanicIds) {
expect(BOSS_MECHANIC_REGISTRY[mechanicId].name).toBe(bossMechanicName(mechanicId));
}
}
});
it("gives every boss a distinct mechanic kit", () => {
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
});
});
+36 -31
View File
@@ -1,4 +1,5 @@
import type { BossId } from "./types";
import { bossMechanicName } from "./bosses/mechanicPool";
import type { BossId, BossMechanicId } from "./types";
export type BossArchetype =
| "bull"
@@ -46,7 +47,7 @@ export interface BossDefinition {
failure: string;
mapTitle: string;
mapCopy: string;
mechanics: readonly [string, string, ...string[]];
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
maxHp: number;
archetype: BossArchetype;
groupId: BossGroupId;
@@ -58,16 +59,20 @@ interface BossSeed {
icon: string;
accent: string;
summary: string;
mechanics: readonly [string, string, ...string[]];
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
maxHp: number;
archetype: BossArchetype;
}
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
const [first, second] = seed.mechanics;
const [firstId, secondId] = seed.mechanicIds;
const first = bossMechanicName(firstId);
const second = bossMechanicName(secondId);
const mechanicIds = [...seed.mechanicIds, "basic-melee"] as BossDefinition["mechanicIds"];
return {
id,
...seed,
mechanicIds,
groupId: BOSS_GROUP_BY_BOSS_ID[id],
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
briefing: `Read ${first}, then preserve open ground for ${second}.`,
@@ -80,111 +85,111 @@ function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
const BOSS_SEEDS: Record<BossId, BossSeed> = {
bulldrome: {
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
summary: "Charges marked lanes and crushes grouped targets.", mechanics: ["Bull Charge", "Crushing Pounce"], maxHp: 500, archetype: "bull",
summary: "Charges marked lanes and crushes grouped targets.", mechanicIds: ["bull-charge", "crushing-pounce", "cinder-nova", "ember-brand"], maxHp: 500, archetype: "bull",
},
"sandglass-scorpion": {
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanics: ["Burrow Rush", "Hourglass Eruption"], maxHp: 515, archetype: "burrower",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "memory-sequence"], maxHp: 515, archetype: "burrower",
},
"cragclaw-crab": {
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanics: ["Sidewinder Rush", "Crushing Tide"], maxHp: 505, archetype: "crab",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "aetheric-soak"], maxHp: 505, archetype: "crab",
},
"mournveil-ghost": {
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanics: ["Soul Scissors", "Haunting Rifts"], maxHp: 505, archetype: "ghost",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanicIds: ["vine-scissors", "haunting-rifts", "soul-siphon"], maxHp: 505, archetype: "ghost",
},
"crownshard-golem": {
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanics: ["Royal Shockwave", "Crownfall"], maxHp: 500, archetype: "golem",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanicIds: ["tri-burst", "ultimate-skyfall", "aetheric-soak"], maxHp: 500, archetype: "golem",
},
"crystal-bat-matriarch": {
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanics: ["Sonic Ring", "Mirror Shards"], maxHp: 480, archetype: "golem",
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanicIds: ["tri-burst", "prism-beam", "memory-sequence"], maxHp: 480, archetype: "golem",
},
"stormwool-alpaca": {
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanics: ["Storm Charge", "Cloudburst Pounce"], maxHp: 480, archetype: "bull",
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
},
"cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
},
"ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanics: ["Ash Scissors", "Cinder Rifts"], maxHp: 515, archetype: "ghost",
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "cinder-nova"], maxHp: 515, archetype: "ghost",
},
"riftclaw-demon": {
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanics: ["Rift Blade", "Abyss Cross"], maxHp: 495, archetype: "duelist",
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "soul-siphon"], maxHp: 495, archetype: "duelist",
},
"tempestscale-dragon": {
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanics: ["Tempest Breath", "Stormfall"], maxHp: 500, archetype: "sky-sweeper",
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanicIds: ["storm-breath", "stormfall", "prism-beam"], maxHp: 500, archetype: "sky-sweeper",
},
emberfox: {
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
summary: "Ricochets across the arena and leaves fire at every landing.", mechanics: ["Foxfire Rush", "Ember Pounce"], maxHp: 465, archetype: "ricochet",
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
},
"mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower",
summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
},
"stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanics: ["Crag Shockwave", "Boulderfall"], maxHp: 500, archetype: "golem",
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanicIds: ["tri-burst", "ruin-quake", "meteor-spread"], maxHp: 500, archetype: "golem",
},
"glub-sovereign": {
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
summary: "Links two allies with living slime before seeding toxic pools.", mechanics: ["Ooze Tether", "Caustic Brood"], maxHp: 495, archetype: "web-caster",
summary: "Links two allies with living slime before seeding toxic pools.", mechanicIds: ["binding-web", "venom-purge", "hollow-collapse"], maxHp: 495, archetype: "web-caster",
},
"scrapking-goblin": {
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
summary: "Repositions between attacks and fires improvised blade lanes.", mechanics: ["Scrap Blade", "Junkyard Cross"], maxHp: 485, archetype: "duelist",
summary: "Repositions between attacks and fires improvised blade lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "meteor-spread"], maxHp: 485, archetype: "duelist",
},
"warcaller-orc": {
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanics: ["Warpath Cleave", "Banner Cross"], maxHp: 500, archetype: "duelist",
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "aetheric-soak"], maxHp: 500, archetype: "duelist",
},
"tuskmaw-orc": {
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanics: ["Tusk Rush", "Groundbreaker"], maxHp: 525, archetype: "crab",
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "destruction-pulse"], maxHp: 525, archetype: "crab",
},
"broodfang-spider": {
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanics: ["Binding Web", "Venom Brood"], maxHp: 535, archetype: "web-caster",
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanicIds: ["binding-web", "venom-purge", "meteor-spread"], maxHp: 535, archetype: "web-caster",
},
"silkfang-spider": {
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanics: ["Silk Snare", "Venom Nest"], maxHp: 505, archetype: "web-caster",
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanicIds: ["binding-web", "venom-purge", "soul-siphon"], maxHp: 505, archetype: "web-caster",
},
"thorncrown-stag": {
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanics: ["Briar Charge", "Crown Pounce"], maxHp: 510, archetype: "bull",
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanicIds: ["bull-charge", "crushing-pounce", "hollow-collapse"], maxHp: 510, archetype: "bull",
},
"sky-totem": {
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanics: ["Spirit Scissors", "Wind Rifts"], maxHp: 505, archetype: "ghost",
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "prism-beam"], maxHp: 505, archetype: "ghost",
},
"razorcrest-raptor": {
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanics: ["Raptor Rush", "Talon Slam"], maxHp: 500, archetype: "ricochet",
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanicIds: ["ricochet-rush", "meteor-slam", "prism-beam"], maxHp: 500, archetype: "ricochet",
},
"bristlequake-boar": {
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanics: ["Tusk Charge", "Bristle Quake"], maxHp: 545, archetype: "ram",
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanicIds: ["destruction-rush", "ruin-quake", "destruction-pulse"], maxHp: 545, archetype: "ram",
},
"moonfang-wolf": {
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanics: ["Lunar Rush", "Moonfall"], maxHp: 495, archetype: "ricochet",
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanicIds: ["ricochet-rush", "meteor-slam", "soul-siphon"], maxHp: 495, archetype: "ricochet",
},
"frostmaw-yeti": {
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanics: ["Avalanche Rush", "Frost Crush"], maxHp: 550, archetype: "crab",
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "hollow-collapse"], maxHp: 550, archetype: "crab",
},
"rimeclaw-yeti": {
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanics: ["Rime Blade", "Glacier Cross"], maxHp: 500, archetype: "duelist",
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist",
},
};
-2
View File
@@ -17,9 +17,7 @@ describe("boss home positioning", () => {
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
const motion = createBossMotionState(bossId);
motion.position = [7, 5];
motion.nextChargeAt = Number.POSITIVE_INFINITY;
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
const before = Math.hypot(
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
motion.position[1] - ARENA_CENTER[1],
+23 -417
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 = {
return {
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,
nextMeleeAt: 2,
};
}
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
}
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 { ...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,
...createBaseMotion(bossId),
position: [0, -6.8],
chargeStart: [0, -6.8],
nextMechanicAt: 5,
};
});
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);
}
-113
View File
@@ -1,113 +0,0 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCragclawMechanics, CRAGCLAW, createCragclawMotion, createCragclawState } from "./cragclawCrab";
import { advanceCrownshardMechanics, CROWNSHARD, createCrownshardMotion, createCrownshardState } from "./crownshardGolem";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, MOURNVEIL } from "./mournveilGhost";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("ClaudeCraft boss trio mechanics", () => {
it("telegraphs and resolves Cragclaw Sidewinder Rush", () => {
const start = advanceCragclawMechanics(context(createCragclawState(), createCragclawMotion(), CRAGCLAW.firstAt));
expect(start.motion.mode).toBe("crab_scuttle_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceCragclawMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("crab_scuttling");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceCragclawMechanics(context(active.boss, active.motion, active.motion.phaseEndsAt, 2, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - CRAGCLAW.scuttleDamage);
});
it("places three Crushing Tide warnings on party positions", () => {
const motion = { ...createCragclawMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceCragclawMechanics(context(createCragclawState(), motion, 0));
expect(start.motion.mode).toBe("crab_tidal_burst");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "tidal_burst")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const hpBefore = start.party.find((member) => member.id === "aelia")!.hp;
const impact = advanceCragclawMechanics(context(start.boss, start.motion, CRAGCLAW.tidalWarning + 0.05, 0.1, positions, start.party));
expect(impact.party.find((member) => member.id === "aelia")!.hp).toBe(hpBefore - CRAGCLAW.tidalDamage);
});
it("rotates Mournveil Soul Scissors for a second crossing pattern", () => {
const start = advanceMournveilMechanics(context(createMournveilState(), createMournveilMotion(), MOURNVEIL.firstAt));
const firstLaneIds = start.motion.slashLanes.map((lane) => lane.id);
expect(start.motion.mode).toBe("ghost_soul_cross");
expect(start.motion.slashLanes).toHaveLength(2);
const followup = advanceMournveilMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(followup.motion.mode).toBe("ghost_soul_cross_followup");
expect(followup.motion.slashLanes).toHaveLength(2);
expect(followup.motion.slashLanes.map((lane) => lane.id)).not.toEqual(firstLaneIds);
const resolved = advanceMournveilMechanics(context(followup.boss, followup.motion, followup.motion.phaseEndsAt, 0.1, POSITIONS, followup.party));
expect(resolved.motion.mode).toBe("ghost_recover");
});
it("opens two persistent Haunting Rifts", () => {
const motion = { ...createMournveilMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceMournveilMechanics(context(createMournveilState(), motion, 0));
const rifts = start.motion.hazards.filter((hazard) => hazard.kind === "soul_rift");
expect(start.motion.mode).toBe("ghost_haunting");
expect(rifts).toHaveLength(2);
expect(rifts[0].expiresAt - rifts[0].activatesAt).toBe(MOURNVEIL.riftDuration);
});
it("builds three non-overlapping Crownshard shockwave bands", () => {
const start = advanceCrownshardMechanics(context(createCrownshardState(), createCrownshardMotion(), CROWNSHARD.firstAt));
const rings = start.motion.hazards.filter((hazard) => hazard.kind === "royal_shockwave");
expect(start.motion.mode).toBe("golem_shockwave");
expect(rings).toHaveLength(3);
expect(rings.map((ring) => [ring.innerRadius ?? 0, ring.radius])).toEqual([[0, 2.35], [2.35, 4.7], [4.7, 7.05]]);
const positions = structuredClone(POSITIONS);
positions.aelia = [...rings[0].center];
positions.nia = [rings[0].center[0] + 3, rings[0].center[1]];
const first = advanceCrownshardMechanics(context(start.boss, start.motion, rings[0].activatesAt + 0.05, 0.1, positions, start.party));
const aeliaAfterFirst = first.party.find((member) => member.id === "aelia")!.hp;
const niaAfterFirst = first.party.find((member) => member.id === "nia")!.hp;
expect(aeliaAfterFirst).toBe(start.party.find((member) => member.id === "aelia")!.hp - CROWNSHARD.shockwaveDamage);
expect(niaAfterFirst).toBe(start.party.find((member) => member.id === "nia")!.hp);
const second = advanceCrownshardMechanics(context(first.boss, first.motion, rings[1].activatesAt + 0.05, 0.1, positions, first.party));
expect(second.party.find((member) => member.id === "aelia")!.hp).toBe(aeliaAfterFirst);
expect(second.party.find((member) => member.id === "nia")!.hp).toBe(niaAfterFirst - CROWNSHARD.shockwaveDamage);
});
it("marks three allies with Crownfall", () => {
const motion = { ...createCrownshardMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const result = advanceCrownshardMechanics(context(createCrownshardState(), motion, 0));
expect(result.motion.mode).toBe("golem_crownfall");
expect(result.motion.hazards.filter((hazard) => hazard.kind === "crownfall")).toHaveLength(3);
});
});
-146
View File
@@ -1,146 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CRAGCLAW = {
firstAt: 5.1,
repeatDelay: 3.7,
scuttleWarning: 1.35,
scuttleSpeed: 11.8,
scuttleDistance: 13,
scuttleWidth: 2.35,
scuttleDamage: 24,
tidalWarning: 1.45,
tidalRadius: 1.7,
tidalDamage: 26,
recoverDuration: 0.72,
} as const;
const SCUTTLE_TARGETS: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const TIDAL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCragclawState(): BossState {
const definition = BOSS_DEFINITIONS["cragclaw-crab"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.3);
}
export function createCragclawMotion(): BossMotionState {
return { ...createBaseMotion("cragclaw-crab"), position: [0, -6.4], nextMechanicAt: CRAGCLAW.firstAt };
}
function scuttleEnd(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * CRAGCLAW.scuttleDistance,
start[1] + Math.cos(angle) * CRAGCLAW.scuttleDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(context.party, SCUTTLE_TARGETS, motion.mechanicCount);
const end = scuttleEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = {
id: `cragclaw-scuttle-${mechanicCount}`,
start: [...motion.position],
end,
width: CRAGCLAW.scuttleWidth,
damage: CRAGCLAW.scuttleDamage,
};
events.push({ at: context.time, message: `Cragclaw lines up Sidewinder Rush on ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "crab_scuttle_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [lane],
phaseStartedAt: context.time,
phaseEndsAt: context.time + CRAGCLAW.scuttleWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
const activatesAt = context.time + CRAGCLAW.tidalWarning;
const targetSet = TIDAL_TARGETS[Math.floor(motion.mechanicCount / 2) % TIDAL_TARGETS.length];
events.push({ at: context.time, message: "Crushing Tide marks three allies. Spread before the claws close.", tone: "danger", pulseKind: "skyfall" });
return {
...motion,
mode: "crab_tidal_burst" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.3,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `cragclaw-tide-${mechanicCount}-${index}`,
kind: "tidal_burst",
center: context.partyPositions[targetId],
radius: CRAGCLAW.tidalRadius,
activatesAt,
duration: 0.3,
damage: CRAGCLAW.tidalDamage,
})),
],
};
}
export function advanceCragclawMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2.05);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "crab_scuttling",
phaseStartedAt: context.time,
phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CRAGCLAW.scuttleSpeed,
};
events.push({ at: context.time, message: "Sidewinder Rush! Clear the surf lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "crab_scuttling") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CRAGCLAW.scuttleSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CRAGCLAW.scuttleWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is crushed by Sidewinder Rush.`, tone: "danger", pulseKind: "charge", targetId: member.id });
return { ...context.damageMember(member, CRAGCLAW.scuttleDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.42 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion = { ...motion, position: [...motion.chargeEnd], mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
}
} else if (motion.mode === "crab_tidal_burst" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
} else if (motion.mode === "crab_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CRAGCLAW.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCragclawMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "crab_scuttle_telegraph" || motion.mode === "crab_scuttling") return { name: "Sidewinder Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.scuttleWarning, urgent: true };
if (motion.mode === "crab_tidal_burst") return { name: "Crushing Tide — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.tidalWarning, urgent: true };
if (motion.mode === "crab_recover") return { name: "Cragclaw exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Sidewinder Rush" : "Crushing Tide", remaining, cycle: CRAGCLAW.repeatDelay + CRAGCLAW.scuttleWarning, urgent: remaining < 2.5 };
}
-117
View File
@@ -1,117 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CROWNSHARD = {
firstAt: 5.5,
repeatDelay: 4,
shockwaveWarning: 1.2,
shockwaveInterval: 0.65,
shockwaveDamage: 19,
crownfallWarning: 1.5,
crownfallRadius: 1.85,
crownfallDamage: 28,
recoverDuration: 0.78,
} as const;
const CROWNFALL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCrownshardState(): BossState {
const definition = BOSS_DEFINITIONS["crownshard-golem"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.4);
}
export function createCrownshardMotion(): BossMotionState {
return { ...createBaseMotion("crownshard-golem"), position: [0, -6.7], nextMechanicAt: CROWNSHARD.firstAt };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const bands = [
{ innerRadius: 0, radius: 2.35 },
{ innerRadius: 2.35, radius: 4.7 },
{ innerRadius: 4.7, radius: 7.05 },
];
const firstActivation = context.time + CROWNSHARD.shockwaveWarning;
events.push({ at: context.time, message: "Tri-Burst expands in three rings. Move with each head's safe band.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "golem_shockwave" as const,
phaseStartedAt: context.time,
phaseEndsAt: firstActivation + CROWNSHARD.shockwaveInterval * (bands.length - 1) + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...bands.map((band, index) => createCircleHazard({
id: `crownshard-shockwave-${mechanicCount}-${index}`,
kind: "royal_shockwave",
center: motion.position,
innerRadius: band.innerRadius,
radius: band.radius,
activatesAt: firstActivation + index * CROWNSHARD.shockwaveInterval,
duration: 0.3,
damage: CROWNSHARD.shockwaveDamage,
})),
],
};
}
const activatesAt = context.time + CROWNSHARD.crownfallWarning;
const targetSet = CROWNFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % CROWNFALL_TARGETS.length];
events.push({ at: context.time, message: "Ultimate Skyfall marks three allies. Break formation before impact.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "golem_crownfall" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `crownshard-fall-${mechanicCount}-${index}`,
kind: "crownfall",
center: context.partyPositions[targetId],
radius: CROWNSHARD.crownfallRadius,
activatesAt,
duration: 0.3,
damage: CROWNSHARD.crownfallDamage,
})),
],
};
}
export function advanceCrownshardMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.55);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
} else if (motion.mode === "golem_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CROWNSHARD.repeatDelay };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.3, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCrownshardMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "golem_shockwave") return { name: "Tri-Burst — follow rings", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.shockwaveWarning + CROWNSHARD.shockwaveInterval * 2, urgent: true };
if (motion.mode === "golem_crownfall") return { name: "Ultimate Skyfall — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.crownfallWarning, urgent: true };
if (motion.mode === "golem_recover") return { name: "Ultimate Dragon exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Tri-Burst" : "Ultimate Skyfall", remaining, cycle: CROWNSHARD.repeatDelay + CROWNSHARD.shockwaveWarning, urgent: remaining < 2.5 };
}
-97
View File
@@ -1,97 +0,0 @@
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("Warcaller mechanics", () => {
it("sidesteps, telegraphs Elemental Beam, 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("Elemental Beam"))).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);
});
});
-241
View File
@@ -1,241 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, returnBossToArenaCenter } 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(bossId: BossId = "warcaller-orc"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
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(bossId: BossId = "warcaller-orc"): BossMotionState {
return {
...createBaseMotion(bossId),
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 ? "Guardian Cross" : "Elemental Beam"}.`,
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") {
returnBossToArenaCenter(motion, context.delta, 2.5);
if (context.time >= motion.nextMechanicAt) {
motion = beginSidestep(motion, party, context.partyPositions, context.time);
events.push({
at: context.time,
message: `${boss.name} shifts toward ${memberName(party, motion.chargeTargetId)}. Track its attack.`,
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 ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! 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 {
if (motion.mode === "mantis_sidestep") {
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
}
if (motion.mode === "mantis_line_telegraph") {
return { name: "Elemental Beam — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_cross_telegraph") {
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_recover") {
return { name: `${boss.name} 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 ? "Guardian Cross" : "Elemental Beam",
remaining,
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
urgent: remaining < 2.5,
};
}
-85
View File
@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./ricochet";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("IWT2 boss trio mechanics", () => {
it("telegraphs and resolves Bristlequake Tusk Charge", () => {
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
expect(start.motion.mode).toBe("ram_charge_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceObsidianRamMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("ram_charging");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceObsidianRamMechanics(context(active.boss, active.motion, active.motion.phaseStartedAt + 1, 1, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - OBSIDIAN_RAM.chargeDamage);
});
it("builds three Armor Shatter fault lanes", () => {
const motion = { ...createObsidianRamMotion(), mechanicCount: 2, nextMechanicAt: 0 };
const result = advanceObsidianRamMechanics(context(createObsidianRamState(), motion, 0));
expect(result.motion.mode).toBe("ram_shatter");
expect(result.motion.slashLanes).toHaveLength(3);
});
it("executes both Cinderback rebounds and leaves lava at each impact", () => {
const start = advanceCinderbackMechanics(context(createCinderbackState(), createCinderbackMotion(), CINDERBACK.firstAt));
const firstRush = advanceCinderbackMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
const firstImpact = advanceCinderbackMechanics(context(firstRush.boss, firstRush.motion, firstRush.motion.phaseStartedAt + 2, 2, POSITIONS, firstRush.party));
expect(firstImpact.motion.mode).toBe("cinderback_ricochet");
expect(firstImpact.motion.chargeCount).toBe(1);
expect(firstImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(1);
const secondImpact = advanceCinderbackMechanics(context(firstImpact.boss, firstImpact.motion, firstImpact.motion.phaseEndsAt, 2, POSITIONS, firstImpact.party));
expect(secondImpact.motion.mode).toBe("cinderback_recover");
expect(secondImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(2);
});
it("turns Sandglass stinger warnings into an active hourglass zone", () => {
const motion = { ...createSandglassMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceSandglassMechanics(context(createSandglassState(), motion, 0));
expect(start.motion.mode).toBe("sandglass_eruption");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "stinger_eruption")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const aeliaBefore = start.party[0].hp;
const eruption = advanceSandglassMechanics(context(start.boss, start.motion, SANDGLASS.eruptionWarning + 0.05, 0.1, positions, start.party));
expect(eruption.party[0].hp).toBe(aeliaBefore - SANDGLASS.eruptionDamage);
const hourglass = advanceSandglassMechanics(context(eruption.boss, eruption.motion, eruption.motion.phaseEndsAt + 0.01, 0.1, POSITIONS, eruption.party));
expect(hourglass.motion.mode).toBe("sandglass_hourglass");
expect(hourglass.motion.hazards.some((hazard) => hazard.kind === "hourglass")).toBe(true);
});
});
+25 -22
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { createBaseMotion } from "./shared";
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext, BossMechanicResult } from "./types";
import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext } from "./types";
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
@@ -20,9 +20,6 @@ function state(): BossState {
maxHp: 500,
hp: 500,
nextMeleeAt: Number.POSITIVE_INFINITY,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
@@ -38,10 +35,6 @@ function context(time: number, positions = POSITIONS): BossMechanicContext {
};
}
function result(contextValue: BossMechanicContext): BossMechanicResult {
return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] };
}
function memoryTelegraph(): PoolTelegraph {
return {
id: "test-memory",
@@ -79,10 +72,10 @@ function advanceMemory(
source.party = party;
source.motion = {
...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
activeMechanicId: "memory-sequence",
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]);
}
function soulSiphonTelegraph(): PoolTelegraph {
@@ -121,21 +114,31 @@ function advanceSoulSiphon(
source.party = party;
source.motion = {
...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
activeMechanicId: "soul-siphon",
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]);
}
describe("shared boss mechanic pool", () => {
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
const source = context(POOLED_MECHANIC_TIMING.firstAt);
const started = advancePooledBossMechanics(source, result(source));
it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, [id, id]);
expect(started.motion.mode).toBe("holding");
expect(started.motion.activeMechanicId).toBe(id);
expect(["idle", "move", "attack", "special"]).toContain(bossAnimationCue(started.motion));
});
it("schedules the exact mechanic ID selected by a boss loadout", () => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, ["meteor-spread", "bull-charge"]);
expect(started.motion.activeMechanicId).toBe("meteor-spread");
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
expect(started.events[0].message).toContain(":");
expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY);
expect(bossAnimationCue(started.motion)).toBe("attack");
});
it("splits soak damage across allies inside its indicator", () => {
@@ -156,14 +159,14 @@ describe("shared boss mechanic pool", () => {
resolved: false,
hitIds: [],
};
const motion = { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [soak] };
const motion = { ...source.motion, activeMechanicId: "aetheric-soak" as const, poolTelegraphs: [soak] };
const positions = structuredClone(POSITIONS);
positions.aelia = [0.2, 0];
positions.brann = [0, 0];
positions.nia = [-0.2, 0];
positions.vale = [0, -4];
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
const resolved = advanceMechanicLoadout(atImpact, ["aetheric-soak", "bull-charge"]);
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30);
@@ -192,11 +195,11 @@ describe("shared boss mechanic pool", () => {
positions.brann = [3, 0];
const atImpact = {
...source,
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] },
partyPositions: positions,
time: activatesAt,
};
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
const resolved = advanceMechanicLoadout(atImpact, ["hollow-collapse", "bull-charge"]);
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
+775 -69
View File
@@ -1,9 +1,34 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const BOSS_MECHANIC_POOL = [
{ id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." },
{ id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." },
{ id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." },
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." },
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
{ id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." },
{ id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." },
{ id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." },
{ id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." },
{ id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." },
{ id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." },
{ id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." },
{ id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." },
{ id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." },
{ id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." },
{ id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." },
{ id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." },
{ id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." },
{ id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." },
{
id: "meteor-spread",
name: "Meteor Spread",
@@ -36,18 +61,62 @@ export const BOSS_MECHANIC_POOL = [
},
] as const;
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
const MECHANIC_COPY_BY_ID = Object.fromEntries(
BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]),
) as Record<BossMechanicId, (typeof BOSS_MECHANIC_POOL)[number]>;
export function bossMechanicName(id: BossMechanicId) {
return MECHANIC_COPY_BY_ID[id].name;
}
export const POOLED_MECHANIC_TIMING = {
firstAt: 15,
repeatDelay: 26,
activeDuration: 0.42,
warningDuration: 1.4,
} as const;
export const BULL_CHARGE = {
warning: 1.8,
speed: 10.5,
distance: 13.5,
hitRadius: 1.35,
damage: 18,
knockdown: 0.75,
cooldown: 4,
aiClearance: 1.9,
aiEvadeSpeed: 3.4,
} as const;
export const BULL_POUNCE = {
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 200,
leapDuration: 0.55,
cooldown: 4,
} as const;
export const SKY_SWEEPER_BREATH = {
telegraphDuration: 2,
sweepDuration: 3.2,
range: 10.5,
halfAngle: Math.PI / 7,
sweepArc: Math.PI * 0.95,
tickDamage: 9,
tickInterval: 0.45,
cooldown: 4,
} as const;
export const VENOM_PURGE = {
duration: 10,
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
} as const;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.68,
flashDuration: 0.9,
inputDuration: 7,
tileSize: 2.15,
raidwideDamage: 15,
@@ -84,13 +153,6 @@ const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function bossPoolOffset(bossId: string) {
let value = 0;
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
return value;
}
function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0)
? targetId
@@ -220,9 +282,10 @@ function beginPoolMechanic(
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
time: number,
requestedId: BossMechanicId,
) {
const count = motion.poolMechanicCount + 1;
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
const entry = MECHANIC_COPY_BY_ID[requestedId];
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined;
@@ -281,7 +344,6 @@ function beginPoolMechanic(
motion: {
...motion,
poolMechanicCount: count,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: telegraphs,
},
event: {
@@ -498,61 +560,6 @@ function resolveTelegraph(
return next;
}
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
export function advancePooledBossMechanics(
context: BossMechanicContext,
result: BossMechanicResult,
): BossMechanicResult {
if (context.allowPooledMechanics === false) return result;
const source = result.motion;
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
let motion: BossMotionState = {
...source,
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
...telegraph,
center: [telegraph.center[0], telegraph.center[1]],
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
soulSiphon: telegraph.soulSiphon && {
...telegraph.soulSiphon,
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
},
hitIds: [...telegraph.hitIds],
})),
};
let party = result.party;
const events = [...result.events];
for (const telegraph of motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
telegraph.resolved = true;
}
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
motion = started.motion;
events.push(started.event);
}
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
}
return { ...result, motion, party, events };
}
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
if (!telegraphs.length) return null;
@@ -582,3 +589,702 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U
urgent: true,
};
}
interface MechanicRuntime {
readonly context: BossMechanicContext;
boss: BossMechanicResult["boss"];
motion: BossMotionState;
party: PartyMember[];
events: BossMechanicResult["events"];
}
export interface BossMechanicDefinition {
readonly id: BossMechanicId;
readonly name: string;
readonly instruction: string;
readonly cooldown: number;
readonly passive?: boolean;
readonly start: (runtime: MechanicRuntime) => void;
readonly advance: (runtime: MechanicRuntime) => void;
upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic;
readonly animationCue: (motion: BossMotionState) => BossAnimationCue;
}
function finishMechanic(runtime: MechanicRuntime, cooldown: number) {
runtime.motion.activeMechanicId = null;
runtime.motion.mode = "holding";
runtime.motion.phaseEndsAt = 0;
runtime.motion.nextMechanicAt = runtime.context.time + cooldown;
runtime.motion.chargeHitIds = [];
runtime.motion.mechanicHitIds = [];
runtime.motion.tetherIds = [];
runtime.motion.slashLanes = [];
}
function timedAdvance(runtime: MechanicRuntime, cooldown: number) {
if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown);
}
function defaultUpcoming(definition: Pick<BossMechanicDefinition, "name" | "cooldown">, motion: BossMotionState, time: number): UpcomingMechanic {
const remaining = Math.max(0, (motion.activeMechanicId ? motion.phaseEndsAt : motion.nextMechanicAt) - time);
return { name: definition.name, remaining, cycle: definition.cooldown, urgent: motion.activeMechanicId !== null || remaining < 2.5 };
}
function mechanicCopy(id: BossMechanicId) {
return MECHANIC_COPY_BY_ID[id];
}
export function bossMechanicIsPassive(id: BossMechanicId) {
return BOSS_MECHANIC_REGISTRY[id].passive === true;
}
interface LaneChargeConfig {
id: BossMechanicId;
warning: number;
speed: number;
distance: number;
width: number;
damage: number;
knockdown: number;
cooldown: number;
targetOrder: readonly MemberId[];
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition, travelDistance: number): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * travelDistance,
start[1] + Math.cos(angle) * travelDistance,
]);
}
function laneChargeDefinition(config: LaneChargeConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
const targetId = chooseLivingTarget(runtime.party, config.targetOrder, runtime.motion.mechanicCount);
const end = chargeEndpoint(runtime.motion.position, runtime.context.partyPositions[targetId], config.distance);
runtime.motion.mode = "telegraph";
runtime.motion.chargeTargetId = targetId;
runtime.motion.chargeStart = [...runtime.motion.position];
runtime.motion.chargeEnd = end;
runtime.motion.chargeHitIds = [];
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
runtime.motion.slashLanes = [{
id: `${config.id}-${runtime.motion.mechanicCount}`,
start: [...runtime.motion.position],
end,
width: config.width,
damage: config.damage,
}];
runtime.events.push({
at: runtime.context.time,
message: `${copy.name} targets ${memberName(runtime.party, targetId)}. ${copy.instruction}`,
tone: "danger",
pulseKind: "charge",
targetId,
});
},
advance(runtime) {
const { context, motion } = runtime;
if (motion.mode === "telegraph" && context.time >= motion.phaseEndsAt) {
motion.mode = "charging";
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + distance(motion.position, motion.chargeEnd) / config.speed;
return;
}
if (motion.mode !== "charging") return;
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, config.speed * context.delta);
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > config.width * 0.5) return member;
motion.chargeHitIds.push(member.id);
return {
...context.damageMember(member, config.damage, context.partyPositions[member.id], context.time),
knockedUntil: context.time + config.knockdown,
};
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.position = [...motion.chargeEnd];
finishMechanic(runtime, config.cooldown);
}
},
animationCue: (motion) => motion.mode === "charging" ? "move" : "attack",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
interface CircleAttackConfig {
id: BossMechanicId;
warning: number;
radius: number;
damage: number;
cooldown: number;
kind: CircleHazard["kind"];
targets: number;
duration?: number;
tickInterval?: number;
centeredOnBoss?: boolean;
stagger?: number;
}
function circleAttackDefinition(config: CircleAttackConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
const activatesAt = runtime.context.time + config.warning;
const targetIds = Array.from({ length: config.targets }, (_, offset) =>
chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount + offset));
const centers = config.centeredOnBoss
? [[...runtime.motion.position] as WorldPosition]
: targetIds.map((targetId) => [...runtime.context.partyPositions[targetId]] as WorldPosition);
runtime.motion.mode = config.id === "stormfall" ? "skyfall" : "golem_crownfall";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + (centers.length - 1) * (config.stagger ?? 0) + Math.max(0.32, config.duration ?? 0.32);
runtime.motion.hazards.push(...centers.map((center, index) => createCircleHazard({
id: `${config.id}-${runtime.motion.mechanicCount}-${index}`,
kind: config.kind,
center,
radius: config.radius,
activatesAt: activatesAt + index * (config.stagger ?? 0),
duration: config.duration ?? 0.32,
damage: config.damage,
tickInterval: config.tickInterval,
})));
runtime.events.push({
at: runtime.context.time,
message: `${copy.name}: ${copy.instruction}`,
tone: "danger",
pulseKind: "skyfall",
targetId: targetIds[0],
});
},
advance(runtime) { timedAdvance(runtime, config.cooldown); },
animationCue: () => "special",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
interface LaneAttackConfig {
id: BossMechanicId;
warning: number;
width: number;
damage: number;
cooldown: number;
angles: readonly number[];
rotateFollowup?: number;
}
function laneFromAngle(id: string, center: WorldPosition, angle: number, width: number, damage: number): SlashLane {
const dx = Math.sin(angle) * 9;
const dz = Math.cos(angle) * 9;
return { id, start: [center[0] - dx, center[1] - dz], end: [center[0] + dx, center[1] + dz], width, damage };
}
function resolveLanes(runtime: MechanicRuntime, name: string) {
const hitIds: MemberId[] = [];
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0) return member;
const lane = runtime.motion.slashLanes.find((entry) =>
pointToSegmentDistance(runtime.context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (!lane) return member;
hitIds.push(member.id);
runtime.events.push({ at: runtime.context.time, message: `${member.name} is struck by ${name}.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return runtime.context.damageMember(member, lane.damage, runtime.context.partyPositions[member.id], runtime.context.time);
});
runtime.motion.mechanicHitIds.push(...hitIds);
}
function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const startLanes = (runtime: MechanicRuntime, rotation = 0) => {
const targetId = chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount);
const center = runtime.context.partyPositions[targetId];
const aimed = angleTo(runtime.motion.position, center) + rotation;
runtime.motion.slashLanes = config.angles.map((offset, index) =>
laneFromAngle(`${config.id}-${runtime.motion.mechanicCount}-${index}-${rotation}`, center, aimed + offset, config.width, config.damage));
runtime.motion.chargeTargetId = targetId;
};
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
runtime.motion.mode = "mantis_line_telegraph";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
runtime.motion.chargeCount = 0;
runtime.motion.mechanicHitIds = [];
startLanes(runtime);
runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "slash", targetId: runtime.motion.chargeTargetId });
},
advance(runtime) {
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
resolveLanes(runtime, copy.name);
if (config.rotateFollowup && runtime.motion.chargeCount === 0) {
runtime.motion.chargeCount = 1;
runtime.motion.mode = "mantis_cross_telegraph";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
startLanes(runtime, config.rotateFollowup);
runtime.events.push({ at: runtime.context.time, message: `${copy.name} rotates. Find new safe ground.`, tone: "danger", pulseKind: "slash" });
return;
}
finishMechanic(runtime, config.cooldown);
},
animationCue: () => "attack",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS });
const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS });
const burrowRush = laneChargeDefinition({ id: "burrow-rush", warning: 1.3, speed: 10.8, distance: 13, width: 2, damage: 25, knockdown: 0.35, cooldown: 3.8, targetOrder: ["aelia", "nia", "orin", "vale", "brann"] });
const sidewinderRush = laneChargeDefinition({ id: "sidewinder-rush", warning: 1.25, speed: 11, distance: 13.2, width: 2.3, damage: 24, knockdown: 0.42, cooldown: 3.7, targetOrder: BULL_TARGETS });
const crushingPounce: BossMechanicDefinition = {
id: "crushing-pounce",
name: bossMechanicName("crushing-pounce"),
instruction: mechanicCopy("crushing-pounce").instruction,
cooldown: 4,
start(runtime) {
const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount);
runtime.motion.mode = "stacking";
runtime.motion.pounceTargetId = targetId;
runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]];
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + BULL_POUNCE.stackDuration;
runtime.events.push({ at: runtime.context.time, message: `Crushing Pounce marks ${memberName(runtime.party, targetId)}. Stack to split the impact.`, tone: "danger", pulseKind: "pounce", targetId });
},
advance(runtime) {
const { motion, context } = runtime;
if (motion.mode === "stacking") {
motion.pounceCenter = [...context.partyPositions[motion.pounceTargetId]];
if (context.time < motion.phaseEndsAt) return;
motion.mode = "pouncing";
motion.chargeStart = [...motion.position];
motion.chargeEnd = [...motion.pounceCenter];
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + BULL_POUNCE.leapDuration;
return;
}
if (motion.mode !== "pouncing") return;
motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta);
if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return;
const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id);
const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
runtime.party = runtime.party.map((member) => stackedIds.includes(member.id)
? context.damageMember(member, damage, context.partyPositions[member.id], context.time)
: member);
runtime.events.push({ at: context.time, message: `Crushing Pounce deals ${Math.round(damage)} damage across ${stackedIds.length} stacked allies.`, tone: "danger", pulseKind: "pounce", targetId: motion.pounceTargetId });
finishMechanic(runtime, BULL_POUNCE.cooldown);
},
animationCue: (motion) => motion.mode === "pouncing" ? "special" : "attack",
};
crushingPounce.upcoming = (motion, time) => defaultUpcoming(crushingPounce, motion, time);
function instantTimedDefinition(id: BossMechanicId, cooldown: number, start: (runtime: MechanicRuntime) => void, cue: BossAnimationCue = "attack"): BossMechanicDefinition {
const copy = mechanicCopy(id);
const definition: BossMechanicDefinition = {
id, name: copy.name, instruction: copy.instruction, cooldown,
start(runtime) {
runtime.motion.mode = "golem_shockwave";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 0.55;
start(runtime);
},
advance(runtime) { timedAdvance(runtime, cooldown); },
animationCue: () => cue,
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
const cinderNova = instantTimedDefinition("cinder-nova", 5, (runtime) => {
runtime.party = runtime.party.map((member) => member.hp > 0
? runtime.context.damageMember(member, 13, runtime.context.partyPositions[member.id], runtime.context.time)
: member);
runtime.events.push({ at: runtime.context.time, message: "Cinder Nova strikes the party.", tone: "danger", pulseKind: "boss" });
}, "special");
const emberBrand = instantTimedDefinition("ember-brand", 5, (runtime) => {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount);
runtime.party = runtime.party.map((member) => member.id === targetId ? {
...member,
debuffs: [...member.debuffs, { id: `ember-brand-${runtime.motion.mechanicCount}`, name: "Ember Brand", expiresAt: runtime.context.time + 7, nextTickAt: runtime.context.time + 1, tickDamage: 6 }],
} : member);
runtime.events.push({ at: runtime.context.time, message: `Ember Brand afflicts ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "debuff", targetId });
});
const bindingWeb: BossMechanicDefinition = {
id: "binding-web", name: bossMechanicName("binding-web"), instruction: mechanicCopy("binding-web").instruction, cooldown: 4,
start(runtime) {
const pairs: readonly (readonly [MemberId, MemberId])[] = [["brann", "vale"], ["nia", "orin"], ["aelia", "nia"]];
const pair = pairs[(runtime.motion.mechanicCount - 1) % pairs.length];
const first = chooseLivingTarget(runtime.party, pair, 0);
const second = chooseLivingTarget(runtime.party, pair.filter((id) => id !== first), 0);
runtime.motion.mode = "tethering";
runtime.motion.tetherIds = [first, second];
runtime.motion.tetherBreakDistance = 6.8;
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
},
advance(runtime) {
const [first, second] = runtime.motion.tetherIds;
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
finishMechanic(runtime, 4);
return;
}
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
runtime.party = runtime.party.map((member) => runtime.motion.tetherIds.includes(member.id)
? { ...runtime.context.damageMember(member, 24, runtime.context.partyPositions[member.id], runtime.context.time), knockedUntil: runtime.context.time + 1.4 }
: member);
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
finishMechanic(runtime, 4);
},
animationCue: () => "attack",
};
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
runtime.motion.mode = "venom_cast";
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
...member,
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }],
} : member);
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
});
const stormBreath: BossMechanicDefinition = {
id: "storm-breath", name: bossMechanicName("storm-breath"), instruction: mechanicCopy("storm-breath").instruction, cooldown: 4,
start(runtime) {
const aimed = angleTo(runtime.motion.position, runtime.context.partyPositions.brann);
const direction = runtime.motion.mechanicCount % 2 === 0 ? 1 : -1;
runtime.motion.mode = "breath_telegraph";
runtime.motion.breathStartAngle = aimed - direction * Math.PI * 0.475;
runtime.motion.breathEndAngle = aimed + direction * Math.PI * 0.475;
runtime.motion.breathAngle = runtime.motion.breathStartAngle;
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + SKY_SWEEPER_BREATH.telegraphDuration;
runtime.motion.mechanicNextDamageAt = {};
runtime.events.push({ at: runtime.context.time, message: "Storm Breath gathers. Rotate behind the sweep.", tone: "danger", pulseKind: "breath" });
},
advance(runtime) {
const { motion, context } = runtime;
if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion.mode = "breath_sweeping";
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + SKY_SWEEPER_BREATH.sweepDuration;
return;
}
if (motion.mode !== "breath_sweeping") return;
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0) return member;
const dx = context.partyPositions[member.id][0] - motion.position[0];
const dz = context.partyPositions[member.id][1] - motion.position[1];
const memberAngle = Math.atan2(dx, dz);
const exposed = Math.hypot(dx, dz) <= SKY_SWEEPER_BREATH.range && Math.abs(Math.atan2(Math.sin(memberAngle - motion.breathAngle), Math.cos(memberAngle - motion.breathAngle))) <= SKY_SWEEPER_BREATH.halfAngle;
if (!exposed) return member;
const nextAt = motion.mechanicNextDamageAt[member.id] ?? context.time;
if (nextAt > context.time) return member;
motion.mechanicNextDamageAt[member.id] = context.time + SKY_SWEEPER_BREATH.tickInterval;
return context.damageMember(member, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], context.time);
});
if (context.time >= motion.phaseEndsAt) finishMechanic(runtime, SKY_SWEEPER_BREATH.cooldown);
},
animationCue: () => "attack",
};
stormBreath.upcoming = (motion, time) => defaultUpcoming(stormBreath, motion, time);
const stormfall = circleAttackDefinition({ id: "stormfall", warning: 2, radius: 1.8, damage: 30, cooldown: 4, kind: "skyfall", targets: 3, duration: 5, tickInterval: 1, stagger: 0.9 });
const crushingTide = circleAttackDefinition({ id: "crushing-tide", warning: 1.35, radius: 1.7, damage: 26, cooldown: 3.7, kind: "tidal_burst", targets: 3 });
const hauntingRifts = circleAttackDefinition({ id: "haunting-rifts", warning: 1.45, radius: 1.75, damage: 5, cooldown: 3.9, kind: "soul_rift", targets: 2, duration: 4.2, tickInterval: 0.8 });
const ultimateSkyfall = circleAttackDefinition({ id: "ultimate-skyfall", warning: 1.5, radius: 1.85, damage: 28, cooldown: 4, kind: "crownfall", targets: 3 });
const ruinQuake = circleAttackDefinition({ id: "ruin-quake", warning: 1.35, radius: 3.6, damage: 31, cooldown: 3.8, kind: "quake", targets: 1, centeredOnBoss: true });
const elementalBeam = laneAttackDefinition({ id: "elemental-beam", warning: 0.9, width: 1.65, damage: 32, cooldown: 3.4, angles: [0] });
const guardianCross = laneAttackDefinition({ id: "guardian-cross", warning: 0.9, width: 1.45, damage: 25, cooldown: 3.4, angles: [-Math.PI * 0.18, Math.PI * 0.18] });
const destructionPulse = laneAttackDefinition({ id: "destruction-pulse", warning: 1.2, width: 1.25, damage: 24, cooldown: 3.8, angles: [0, Math.PI / 3, -Math.PI / 3] });
const vineScissors = laneAttackDefinition({ id: "vine-scissors", warning: 1.25, width: 1.55, damage: 22, cooldown: 3.9, angles: [0, Math.PI / 2], rotateFollowup: Math.PI / 4 });
const ricochetRush: BossMechanicDefinition = {
...laneChargeDefinition({ id: "ricochet-rush", warning: 1.25, speed: 12.5, distance: 13, width: 2.25, damage: 22, knockdown: 0.4, cooldown: 3.6, targetOrder: ["orin", "nia", "aelia", "vale", "brann"] }),
advance(runtime) {
const motion = runtime.motion;
if (motion.mode === "telegraph" && runtime.context.time >= motion.phaseEndsAt) {
motion.mode = "charging";
motion.chargeCount = 0;
motion.phaseStartedAt = runtime.context.time;
motion.phaseEndsAt = runtime.context.time + distance(motion.position, motion.chargeEnd) / 12.5;
return;
}
if (motion.mode !== "charging") return;
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, 12.5 * runtime.context.delta);
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(runtime.context.partyPositions[member.id], previous, motion.position) > 1.125) return member;
motion.chargeHitIds.push(member.id);
return runtime.context.damageMember(member, 22, runtime.context.partyPositions[member.id], runtime.context.time);
});
if (distance(motion.position, motion.chargeEnd) >= 0.08 && runtime.context.time < motion.phaseEndsAt) return;
motion.hazards.push(createCircleHazard({ id: `ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, kind: "lava_pool", center: motion.chargeEnd, radius: 1.5, activatesAt: runtime.context.time, duration: 4.5, damage: 5, tickInterval: 0.8 }));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = chargeEndpoint(start, runtime.context.partyPositions[targetId], 13);
motion.position = start;
motion.chargeStart = start;
motion.chargeEnd = end;
motion.chargeTargetId = targetId;
motion.chargeHitIds = [];
motion.chargeCount = 1;
motion.phaseEndsAt = runtime.context.time + distance(start, end) / 12.5;
motion.slashLanes = [{ id: `ricochet-${motion.mechanicCount}-1`, start, end, width: 2.25, damage: 22 }];
runtime.events.push({ at: runtime.context.time, message: `Ricochet Rush rebounds toward ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return;
}
finishMechanic(runtime, 3.6);
},
};
const meteorSlam: BossMechanicDefinition = {
id: "meteor-slam", name: bossMechanicName("meteor-slam"), instruction: mechanicCopy("meteor-slam").instruction, cooldown: 3.6,
start(runtime) {
const activatesAt = runtime.context.time + 1.3;
runtime.motion.mode = "cinderback_slam";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + 0.3;
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-slam-${runtime.motion.mechanicCount}`, kind: "quake", center: runtime.motion.position, radius: 3.1, activatesAt, duration: 0.3, damage: 29 }));
for (let index = 0; index < 3; index += 1) {
const angle = index / 3 * Math.PI * 2;
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-flame-${runtime.motion.mechanicCount}-${index}`, kind: "lava_pool", center: clampToArena([runtime.motion.position[0] + Math.sin(angle) * 3.7, runtime.motion.position[1] + Math.cos(angle) * 3.7]), radius: 1.5, activatesAt, duration: 4.5, damage: 5, tickInterval: 0.8 }));
}
runtime.events.push({ at: runtime.context.time, message: "Meteor Slam: leave the impact and spreading flame.", tone: "danger", pulseKind: "boss" });
},
advance(runtime) { timedAdvance(runtime, 3.6); },
animationCue: () => "special",
};
meteorSlam.upcoming = (motion, time) => defaultUpcoming(meteorSlam, motion, time);
const hourglassEruption: BossMechanicDefinition = {
id: "hourglass-eruption", name: bossMechanicName("hourglass-eruption"), instruction: mechanicCopy("hourglass-eruption").instruction, cooldown: 3.8,
start(runtime) {
const activatesAt = runtime.context.time + 1.55;
for (let index = 0; index < 3; index += 1) {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + index);
runtime.motion.hazards.push(createCircleHazard({ id: `stinger-${runtime.motion.mechanicCount}-${index}`, kind: "stinger_eruption", center: runtime.context.partyPositions[targetId], radius: 1.75, activatesAt, duration: 0.32, damage: 27 }));
}
runtime.motion.hazards.push(createCircleHazard({ id: `hourglass-${runtime.motion.mechanicCount}`, kind: "hourglass", center: clampToArena([runtime.motion.position[0], runtime.motion.position[1] + 3]), radius: 2.55, activatesAt: activatesAt + 0.9, duration: 3.6, damage: 6, tickInterval: 0.75 }));
runtime.motion.mode = "sandglass_hourglass";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + 4.5;
runtime.events.push({ at: runtime.context.time, message: "Hourglass Eruption: leave the eruptions and moving zone.", tone: "danger", pulseKind: "skyfall" });
},
advance(runtime) { timedAdvance(runtime, 3.8); },
animationCue: () => "special",
};
hourglassEruption.upcoming = (motion, time) => defaultUpcoming(hourglassEruption, motion, time);
const triBurst: BossMechanicDefinition = {
id: "tri-burst", name: bossMechanicName("tri-burst"), instruction: mechanicCopy("tri-burst").instruction, cooldown: 4,
start(runtime) {
const firstActivation = runtime.context.time + 1.2;
const bands = [{ innerRadius: 0, radius: 2.35 }, { innerRadius: 2.35, radius: 4.7 }, { innerRadius: 4.7, radius: 7.05 }];
runtime.motion.mode = "golem_shockwave";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = firstActivation + 1.62;
runtime.motion.hazards.push(...bands.map((band, index) => createCircleHazard({ id: `tri-burst-${runtime.motion.mechanicCount}-${index}`, kind: "royal_shockwave", center: runtime.motion.position, innerRadius: band.innerRadius, radius: band.radius, activatesAt: firstActivation + index * 0.65, duration: 0.3, damage: 19 })));
runtime.events.push({ at: runtime.context.time, message: "Tri-Burst expands in three rings. Follow the safe bands.", tone: "danger", pulseKind: "boss" });
},
advance(runtime) { timedAdvance(runtime, 4); },
animationCue: () => "special",
};
triBurst.upcoming = (motion, time) => defaultUpcoming(triBurst, motion, time);
function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition {
const copy = mechanicCopy(id);
const definition: BossMechanicDefinition = {
id, name: copy.name, instruction: copy.instruction, cooldown: 5,
start(runtime) {
const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id);
runtime.motion = started.motion;
runtime.motion.mode = "golem_crownfall";
runtime.events.push(started.event);
},
advance(runtime) {
for (const telegraph of runtime.motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) runtime.party = resolveMemorySequence(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) runtime.party = resolveSoulSiphon(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
continue;
}
if (telegraph.resolved || runtime.context.time < telegraph.activatesAt) continue;
runtime.party = resolveTelegraph(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
telegraph.resolved = true;
}
runtime.motion.poolTelegraphs = runtime.motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > runtime.context.time);
if (!runtime.motion.poolTelegraphs.length) finishMechanic(runtime, 5);
},
upcoming(motion, time) { return upcomingPooledMechanic(motion, time) ?? defaultUpcoming(definition, motion, time); },
animationCue: () => id === "soul-siphon" || id === "memory-sequence" ? "special" : "attack",
};
return definition;
}
const basicMelee: BossMechanicDefinition = {
id: "basic-melee",
name: bossMechanicName("basic-melee"),
instruction: mechanicCopy("basic-melee").instruction,
cooldown: 0,
passive: true,
start() {},
advance(runtime) {
applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember);
},
animationCue: () => "idle",
};
export const BOSS_MECHANIC_REGISTRY: Record<BossMechanicId, BossMechanicDefinition> = {
"basic-melee": basicMelee,
"bull-charge": bullCharge,
"crushing-pounce": crushingPounce,
"cinder-nova": cinderNova,
"ember-brand": emberBrand,
"binding-web": bindingWeb,
"venom-purge": venomPurge,
"storm-breath": stormBreath,
stormfall,
"elemental-beam": elementalBeam,
"guardian-cross": guardianCross,
"destruction-rush": destructionRush,
"ruin-quake": ruinQuake,
"destruction-pulse": destructionPulse,
"ricochet-rush": ricochetRush,
"meteor-slam": meteorSlam,
"burrow-rush": burrowRush,
"hourglass-eruption": hourglassEruption,
"sidewinder-rush": sidewinderRush,
"crushing-tide": crushingTide,
"vine-scissors": vineScissors,
"haunting-rifts": hauntingRifts,
"tri-burst": triBurst,
"ultimate-skyfall": ultimateSkyfall,
"meteor-spread": telegraphDefinition("meteor-spread"),
"hollow-collapse": telegraphDefinition("hollow-collapse"),
"aetheric-soak": telegraphDefinition("aetheric-soak"),
"prism-beam": telegraphDefinition("prism-beam"),
"memory-sequence": telegraphDefinition("memory-sequence"),
"soul-siphon": telegraphDefinition("soul-siphon"),
};
function scheduledMechanicId(
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
mechanicCount: number,
) {
let activeCount = 0;
for (const id of loadout) if (!BOSS_MECHANIC_REGISTRY[id].passive) activeCount += 1;
if (!activeCount) throw new Error("Boss loadout requires at least one active mechanic.");
let targetIndex = mechanicCount % activeCount;
for (const id of loadout) {
if (BOSS_MECHANIC_REGISTRY[id].passive) continue;
if (targetIndex === 0) return id;
targetIndex -= 1;
}
return loadout[0];
}
export function advanceMechanicLoadout(
context: BossMechanicContext,
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
): BossMechanicResult {
const runtime: MechanicRuntime = {
context,
boss: { ...context.boss },
motion: cloneMotion(context.motion),
party: context.party,
events: [],
};
if (runtime.motion.mode === "holding") returnBossToArenaCenter(runtime.motion, context.delta, 2);
for (const mechanicId of loadout) {
const definition = BOSS_MECHANIC_REGISTRY[mechanicId];
if (definition.passive) definition.advance(runtime);
}
if (runtime.motion.activeMechanicId) {
BOSS_MECHANIC_REGISTRY[runtime.motion.activeMechanicId].advance(runtime);
}
if (!runtime.motion.activeMechanicId && context.time >= runtime.motion.nextMechanicAt) {
const mechanicId = scheduledMechanicId(loadout, runtime.motion.mechanicCount);
runtime.motion.mechanicCount += 1;
runtime.motion.activeMechanicId = mechanicId;
runtime.motion.nextMechanicAt = Number.POSITIVE_INFINITY;
BOSS_MECHANIC_REGISTRY[mechanicId].start(runtime);
}
runtime.party = resolveCircleHazards(runtime.motion, runtime.party, context.partyPositions, context.time, context.damageMember, runtime.events);
return { boss: runtime.boss, motion: runtime.motion, party: runtime.party, events: runtime.events };
}
export function upcomingLoadoutMechanic(
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
motion: BossMotionState,
time: number,
): UpcomingMechanic {
const id = motion.activeMechanicId ?? scheduledMechanicId(loadout, motion.mechanicCount);
const definition = BOSS_MECHANIC_REGISTRY[id];
return definition.upcoming?.(motion, time) ?? defaultUpcoming(definition, motion, time);
}
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle";
}
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
const next = cloneMotion(motion);
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
return next;
}
export function handleMechanicDispel(
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
) {
if (debuffNames.includes("Widow Venom")) {
return {
motion: dropVenomPool(motion, memberId, position, time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
}
return { motion, message: "Harmful magic removed." };
}
-154
View File
@@ -1,154 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const MOURNVEIL = {
firstAt: 5.3,
repeatDelay: 3.9,
crossWarning: 1.35,
followupWarning: 1.05,
laneWidth: 1.55,
laneDamage: 22,
riftWarning: 1.45,
riftRadius: 1.75,
riftDamage: 5,
riftDuration: 4.2,
recoverDuration: 0.75,
} as const;
const RIFT_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia"],
["orin", "vale"],
["brann", "aelia"],
];
export function createMournveilState(): BossState {
const definition = BOSS_DEFINITIONS["mournveil-ghost"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.35);
}
export function createMournveilMotion(): BossMotionState {
return { ...createBaseMotion("mournveil-ghost"), position: [0, -6.6], nextMechanicAt: MOURNVEIL.firstAt };
}
function crossLanes(center: WorldPosition, angle: number, mechanicCount: number, phase: number): SlashLane[] {
return [angle, angle + Math.PI / 2].map((laneAngle, index) => {
const dx = Math.sin(laneAngle) * 9;
const dz = Math.cos(laneAngle) * 9;
return {
id: `mournveil-cross-${mechanicCount}-${phase}-${index}`,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: MOURNVEIL.laneWidth,
damage: MOURNVEIL.laneDamage,
};
});
}
function resolveCross(motion: BossMotionState, context: BossMechanicContext, party: BossMechanicContext["party"], events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const nextParty = party.map((member) => {
if (member.hp <= 0) return member;
const hit = motion.slashLanes.some((lane) => pointToSegmentDistance(context.partyPositions[member.id], lane.start, lane.end) <= lane.width * 0.5);
if (!hit) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is cut by Vine Scissors.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, MOURNVEIL.laneDamage, context.partyPositions[member.id], context.time);
});
return { party: nextParty, hitIds };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = (["aelia", "nia", "orin", "vale", "brann"] as const)[motion.mechanicCount % 5];
const angle = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Vine Scissors carve a spectral cross. A second cut will rotate.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ghost_soul_cross" as const,
breathStartAngle: angle,
chargeCount: 0,
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.crossWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: crossLanes(motion.position, angle, mechanicCount, 0),
};
}
const activatesAt = context.time + MOURNVEIL.riftWarning;
const targetSet = RIFT_TARGETS[Math.floor(motion.mechanicCount / 2) % RIFT_TARGETS.length];
events.push({ at: context.time, message: "Haunting Rifts follow two allies. Carry them away from formation.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "ghost_haunting" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.35,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `mournveil-rift-${mechanicCount}-${index}`,
kind: "soul_rift",
center: context.partyPositions[targetId],
radius: MOURNVEIL.riftRadius,
activatesAt,
duration: MOURNVEIL.riftDuration,
damage: MOURNVEIL.riftDamage,
tickInterval: 0.8,
})),
],
};
}
export function advanceMournveilMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.7);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
const followupAngle = motion.breathStartAngle + Math.PI / 4;
motion = {
...motion,
mode: "ghost_soul_cross_followup",
chargeCount: 1,
mechanicHitIds: resolved.hitIds,
slashLanes: crossLanes(motion.position, followupAngle, motion.mechanicCount, 1),
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.followupWarning,
};
events.push({ at: context.time, message: "Vine Scissors rotate. Find the new safe quadrant.", tone: "danger", pulseKind: "slash" });
} else if (motion.mode === "ghost_soul_cross_followup" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
motion = { ...motion, mode: "ghost_recover", mechanicHitIds: [...new Set([...motion.mechanicHitIds, ...resolved.hitIds])], phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_haunting" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ghost_recover", phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + MOURNVEIL.repeatDelay, slashLanes: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.25, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingMournveilMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
if (motion.mode === "ghost_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
}
-182
View File
@@ -1,182 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const OBSIDIAN_RAM = {
firstAt: 5.5,
repeatDelay: 3.8,
chargeWarning: 1.45,
chargeSpeed: 11.5,
chargeDistance: 14,
chargeWidth: 2.5,
chargeDamage: 27,
quakeWarning: 1.35,
quakeRadius: 3.6,
quakeDamage: 31,
shatterWarning: 1.2,
shatterWidth: 1.25,
shatterDamage: 24,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
export function createObsidianRamState(bossId: BossId = "bristlequake-boar"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.3,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createObsidianRamMotion(bossId: BossId = "bristlequake-boar"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
}
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
const half = 9;
const dx = Math.sin(angle) * half;
const dz = Math.cos(angle) * half;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: OBSIDIAN_RAM.shatterWidth,
damage: OBSIDIAN_RAM.shatterDamage,
};
}
function endpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * OBSIDIAN_RAM.chargeDistance,
start[1] + Math.cos(angle) * OBSIDIAN_RAM.chargeDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const index = motion.mechanicCount % 3;
const mechanicCount = motion.mechanicCount + 1;
if (index === 0) {
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const end = endpoint(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: `Destruction Rush locks onto ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "ram_charge_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [{ id: `ram-charge-${mechanicCount}`, start: [...motion.position] as WorldPosition, end, width: OBSIDIAN_RAM.chargeWidth, damage: OBSIDIAN_RAM.chargeDamage }],
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.chargeWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
if (index === 1) {
const activatesAt = context.time + OBSIDIAN_RAM.quakeWarning;
events.push({ at: context.time, message: "Ruin Quake! Leave the destruction circle.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "ram_quake" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.25,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [...motion.hazards, {
id: `ram-quake-${mechanicCount}`,
kind: "quake" as const,
center: [...motion.position] as WorldPosition,
radius: OBSIDIAN_RAM.quakeRadius,
activatesAt,
expiresAt: activatesAt + 0.3,
damage: OBSIDIAN_RAM.quakeDamage,
nextDamageAt: {},
resolved: false,
hitIds: [],
}],
};
}
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const aimed = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Destruction Pulse! Step between the radial beams.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ram_shatter" as const,
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.shatterWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: [0, Math.PI / 3, -Math.PI / 3].map((offset, laneIndex) => laneAt(motion.position, aimed + offset, `ram-shatter-${mechanicCount}-${laneIndex}`)),
};
}
function resolveShatter(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
const lane = motion.slashLanes.find((entry) => pointToSegmentDistance(context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (member.hp <= 0 || !lane) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is struck by Destruction Pulse.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time);
});
return { party, hitIds };
}
export function advanceObsidianRamMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 1.8);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
events.push({ at: context.time, message: "Destruction Rush! Clear the lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "ram_charging") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, OBSIDIAN_RAM.chargeSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > OBSIDIAN_RAM.chargeWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, OBSIDIAN_RAM.chargeDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.55 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_shatter" && context.time >= motion.phaseEndsAt) {
const resolved = resolveShatter(motion, { ...context, party }, events);
party = resolved.party;
motion = { ...motion, mode: "ram_recover", mechanicHitIds: resolved.hitIds, phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_quake" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + OBSIDIAN_RAM.repeatDelay, slashLanes: [], chargeHitIds: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingObsidianRamMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
if (motion.mode === "ram_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
}
-114
View File
@@ -1,114 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossId, BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDERBACK = {
firstAt: 5,
repeatDelay: 3.6,
curlWarning: 1.25,
speed: 12.5,
distance: 13,
laneWidth: 2.25,
rushDamage: 22,
slamWarning: 1.3,
slamRadius: 3.1,
slamDamage: 29,
lavaRadius: 1.5,
lavaDamage: 5,
lavaDuration: 4.5,
recoverDuration: 0.75,
} as const;
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
export function createCinderbackState(bossId: BossId = "emberfox"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createCinderbackMotion(bossId: BossId = "emberfox"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
}
function rushEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * CINDERBACK.distance, start[1] + Math.cos(angle) * CINDERBACK.distance] as WorldPosition);
}
function rushLane(id: string, start: WorldPosition, end: WorldPosition): SlashLane {
return { id, start: [...start], end: [...end], width: CINDERBACK.laneWidth, damage: CINDERBACK.rushDamage };
}
function lavaPool(id: string, center: WorldPosition, at: number): CircleHazard {
return { id, kind: "lava_pool", center: [...center], radius: CINDERBACK.lavaRadius, activatesAt: at, expiresAt: at + CINDERBACK.lavaDuration, damage: CINDERBACK.lavaDamage, tickInterval: 0.8, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceCinderbackMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = rushEnd(motion.position, context.partyPositions[targetId]);
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
events.push({ at: context.time, message: `${boss.name} dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + CINDERBACK.slamWarning;
const pools = [0, 1, 2].map((index) => {
const angle = (index / 3) * Math.PI * 2;
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
});
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
events.push({ at: context.time, message: "Meteor slam! Clear the spreading flame.", tone: "danger", pulseKind: "boss" });
}
}
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_ricochet", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CINDERBACK.speed };
} else if (motion.mode === "cinderback_ricochet") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CINDERBACK.speed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CINDERBACK.laneWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, CINDERBACK.rushDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.4 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.hazards.push(lavaPool(`ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, motion.chargeEnd, context.time));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = rushEnd(start, context.partyPositions[targetId]);
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
events.push({ at: context.time, message: `Ricochet rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
} else {
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
}
}
} else if (motion.mode === "cinderback_slam" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
} else if (motion.mode === "cinderback_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", nextMechanicAt: context.time + CINDERBACK.repeatDelay, phaseEndsAt: 0, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.1, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCinderbackMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "cinderback_curl") return { name: "Ricochet Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
if (motion.mode === "cinderback_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Ricochet Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
}
-103
View File
@@ -1,103 +0,0 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SANDGLASS = {
firstAt: 5.4,
repeatDelay: 3.8,
burrowWarning: 1.3,
burrowSpeed: 10.8,
burrowDistance: 13,
burrowWidth: 2,
burrowDamage: 25,
eruptionWarning: 1.55,
eruptionRadius: 1.75,
eruptionDamage: 27,
hourglassRadius: 2.55,
hourglassDamage: 6,
hourglassDuration: 3.6,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
const ERUPTION_TARGETS: readonly MemberId[][] = [["aelia", "nia", "orin"], ["brann", "vale", "aelia"], ["nia", "orin", "vale"]];
export function createSandglassState(): BossState {
const definition = BOSS_DEFINITIONS["sandglass-scorpion"];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.2, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createSandglassMotion(): BossMotionState {
return { ...createBaseMotion("sandglass-scorpion"), position: [0, -6.5], nextMechanicAt: SANDGLASS.firstAt };
}
function burrowEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * SANDGLASS.burrowDistance, start[1] + Math.cos(angle) * SANDGLASS.burrowDistance] as WorldPosition);
}
function eruption(id: string, center: WorldPosition, activatesAt: number): CircleHazard {
return { id, kind: "stinger_eruption", center: [...center], radius: SANDGLASS.eruptionRadius, activatesAt, expiresAt: activatesAt + 0.32, damage: SANDGLASS.eruptionDamage, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceSandglassMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
returnBossToArenaCenter(motion, context.delta, 2.1);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = burrowEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = { id: `burrow-${count}`, start: [...motion.position], end, width: SANDGLASS.burrowWidth, damage: SANDGLASS.burrowDamage };
motion = { ...motion, mode: "sandglass_burrow_telegraph", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], phaseStartedAt: context.time, phaseEndsAt: context.time + SANDGLASS.burrowWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [lane] };
events.push({ at: context.time, message: `Burrow Rush tracks ${memberName(party, targetId)}. Cross the sand trail.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + SANDGLASS.eruptionWarning;
const targets = ERUPTION_TARGETS[Math.floor(motion.mechanicCount / 2) % ERUPTION_TARGETS.length];
motion = { ...motion, mode: "sandglass_eruption", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.3, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, ...targets.map((targetId, index) => eruption(`stinger-${count}-${index}`, context.partyPositions[targetId], activatesAt))] };
events.push({ at: context.time, message: "Stinger Eruption! Leave the timed sand circles.", tone: "danger", pulseKind: "skyfall" });
}
}
} else if (motion.mode === "sandglass_burrow_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_burrowing", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / SANDGLASS.burrowSpeed };
} else if (motion.mode === "sandglass_burrowing") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, SANDGLASS.burrowSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > SANDGLASS.burrowWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, SANDGLASS.burrowDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.35 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_eruption" && context.time >= motion.phaseEndsAt) {
const center = clampToArena([motion.position[0], motion.position[1] + 3]);
const activatesAt = context.time + 0.9;
motion = { ...motion, mode: "sandglass_hourglass", phaseStartedAt: context.time, phaseEndsAt: activatesAt + SANDGLASS.hourglassDuration, hazards: [...motion.hazards, { id: `hourglass-${motion.mechanicCount}`, kind: "hourglass", center, radius: SANDGLASS.hourglassRadius, activatesAt, expiresAt: activatesAt + SANDGLASS.hourglassDuration, damage: SANDGLASS.hourglassDamage, tickInterval: 0.75, nextDamageAt: {}, resolved: false, hitIds: [] }] };
events.push({ at: context.time, message: "Hourglass zone turns active. Keep moving.", tone: "danger", pulseKind: "skyfall" });
} else if (motion.mode === "sandglass_hourglass" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + SANDGLASS.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.15, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingSandglassMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "sandglass_burrow_telegraph" || motion.mode === "sandglass_burrowing") return { name: "Burrow Rush — clear trail", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.burrowWarning, urgent: true };
if (motion.mode === "sandglass_eruption") return { name: "Stinger Eruption — move", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.eruptionWarning, urgent: true };
if (motion.mode === "sandglass_hourglass") return { name: "Hourglass zone active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.hourglassDuration, urgent: true };
if (motion.mode === "sandglass_recover") return { name: "Chronarch exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Burrow Rush" : "Hourglass Eruption", remaining, cycle: SANDGLASS.repeatDelay + SANDGLASS.burrowWarning, urgent: remaining < 2.5 };
}
+1 -17
View File
@@ -39,19 +39,6 @@ export function returnBossToArenaCenter(motion: BossMotionState, delta: number,
return false;
}
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
return {
id: bossId,
name,
maxHp,
hp: maxHp,
nextMeleeAt,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createCircleHazard({
id,
kind,
@@ -92,6 +79,7 @@ export function createCircleHazard({
export function createBaseMotion(bossId: BossId): BossMotionState {
return {
bossId,
activeMechanicId: null,
formationOffsetX: 0,
mode: "holding",
position: [0, -8.2],
@@ -100,12 +88,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
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,
@@ -118,7 +103,6 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathEndAngle: 0,
hazards: [],
slashLanes: [],
nextPoolMechanicAt: 15,
poolMechanicCount: 0,
poolTelegraphs: [],
};
-160
View File
@@ -1,160 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, pointInCone } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SKY_SWEEPER_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 SKY_SWEEPER_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 createSkySweeperState(bossId: BossId): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createSkySweeperMotion(bossId: BossId): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -2.8], nextMechanicAt: SKY_SWEEPER_BREATH.firstAt };
}
export function advanceSkySweeperMechanics(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") {
returnBossToArenaCenter(motion, context.delta, 1.9);
}
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 * SKY_SWEEPER_BREATH.sweepArc * 0.5;
motion = {
...motion,
mode: "breath_telegraph",
phaseStartedAt: context.time,
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.telegraphDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
breathAngle: startAngle,
breathStartAngle: startAngle,
breathEndAngle: startAngle + direction * SKY_SWEEPER_BREATH.sweepArc,
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: `${boss.name} gathers a sweeping storm 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 + SKY_SWEEPER_SKYFALL.warning + index * SKY_SWEEPER_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: SKY_SWEEPER_SKYFALL.radius,
activatesAt,
expiresAt: activatesAt + SKY_SWEEPER_SKYFALL.fireDuration,
damage: SKY_SWEEPER_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: `${boss.name} 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 + SKY_SWEEPER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Storm breath 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) / SKY_SWEEPER_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, SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_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, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
tickAt += SKY_SWEEPER_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 struck by storm breath.`, 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 upcomingSkySweeperMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "breath_telegraph") return { name: "Storm Breath", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "Stormfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_SKYFALL.warning + SKY_SWEEPER_SKYFALL.stagger * 2, urgent: true };
const nextIsBreath = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsBreath ? "Storm Breath" : "Stormfall", remaining, cycle: 8, urgent: remaining < 2.5 };
}
-2
View File
@@ -22,8 +22,6 @@ export interface BossMechanicContext {
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
/** Shared pool mechanics are reserved for solo encounters to avoid unreadable overlap in multi-boss fights. */
allowPooledMechanics?: boolean;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
}
-160
View File
@@ -1,160 +0,0 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } 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(bossId: BossId = "broodfang-spider"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: bossId,
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(bossId: BossId = "broodfang-spider"): BossMotionState {
return { ...createBaseMotion(bossId), 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") {
returnBossToArenaCenter(motion, context.delta, 2.1);
}
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: `${boss.name} 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: `${boss.name} 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 };
}
+3 -17
View File
@@ -1,6 +1,5 @@
import { BULL_CHARGE } from "./bossMechanics";
import { clampToArena } from "./arena";
import { SKY_SWEEPER_BREATH } from "./bosses/skySweeper";
import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
@@ -37,24 +36,11 @@ const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_charging",
"ram_shatter",
"cinderback_curl",
"cinderback_ricochet",
"sandglass_burrow_telegraph",
"sandglass_burrowing",
"crab_scuttle_telegraph",
"crab_scuttling",
"ghost_soul_cross",
"ghost_soul_cross_followup",
];
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover",
"ram_quake", "ram_recover", "cinderback_slam", "cinderback_recover", "sandglass_eruption", "sandglass_hourglass", "sandglass_recover",
"crab_tidal_burst", "crab_recover", "ghost_haunting", "ghost_recover", "golem_shockwave", "golem_crownfall", "golem_recover",
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "cinderback_slam", "sandglass_hourglass", "golem_shockwave", "golem_crownfall",
];
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging", "ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"];
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging"];
const FORMATION_SLOTS: Record<AiMemberId, WorldPosition> = {
// Bosses face Brann during normal uptime, making positive Z their front.
+9 -3
View File
@@ -7,15 +7,20 @@ import {
equipActiveInfusion,
equipPassiveInfusion,
infusionCosts,
PASSIVE_INFUSIONS,
passiveInfusionUnlocked,
} from "./infusions";
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
import { RUN_BUFF_ORDER } from "../roguelike";
function stack(item: ReturnType<typeof groupDrop>, quantity: number): MaterialStack {
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
}
describe("IWT2-style gear infusions", () => {
it("offers all 18 roguelike buffs as passive infusion choices", () => {
expect(PASSIVE_INFUSIONS.map((passive) => passive.id)).toEqual(RUN_BUFF_ORDER);
});
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => {
const progress = createDefaultGearProgress();
progress.brann.slots.weapon.level = 5;
@@ -54,9 +59,10 @@ describe("IWT2-style gear infusions", () => {
const progress = createDefaultGearProgress();
progress.vale.slots.feet.level = 10;
expect(passiveInfusionUnlocked(progress)).toBe(true);
const infused = equipPassiveInfusion(progress, "priest", "deep-wells");
const infused = equipPassiveInfusion(progress, "priest", "mend-efficiency");
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused);
expect(useGameStore.getState().maxMana).toBe(120);
expect(useGameStore.getState().runBuffs).toEqual([]);
expect(useGameStore.getState().runModifiers.mendManaMultiplier).toBe(0.75);
expect(useGameStore.getState().runBuffRanks).toEqual({});
expect(useGameStore.getState().passiveRunBuffId).toBe("mend-efficiency");
});
});
+52 -11
View File
@@ -1,12 +1,16 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "./data";
import {
applyRunBuffsToParty,
RUN_BUFF_ORDER,
RUN_BUFFS,
bossHealthMultiplier,
runHealingMultiplier,
runMaxMana,
compileRunModifiers,
effectiveRunBuffRank,
formatRunBuffEffect,
increaseRunBuffRank,
selectRandomBossPair,
selectRunBuffDraft,
} from "./roguelike";
import type { RunBuffRanks } from "./types";
describe("roguelike progression", () => {
it("adds 10% base boss HP per completed round", () => {
@@ -15,14 +19,51 @@ describe("roguelike progression", () => {
expect(bossHealthMultiplier(5)).toBe(1.4);
});
it("stacks each persistent buff independently", () => {
const buffs = ["vital-bloom", "vital-bloom", "deep-wells", "restoring-grace"] as const;
const party = applyRunBuffsToParty(freshParty("priest", "Aelia"), buffs);
it("defines 18 unique infusion-eligible ability buffs without legacy ids", () => {
expect(RUN_BUFF_ORDER).toHaveLength(18);
expect(new Set(RUN_BUFF_ORDER)).toHaveLength(18);
expect(RUN_BUFF_ORDER.every((id) => RUN_BUFFS[id].infusionEligible)).toBe(true);
expect(RUN_BUFF_ORDER).not.toContain("vital-bloom");
expect(RUN_BUFF_ORDER).not.toContain("deep-wells");
expect(RUN_BUFF_ORDER).not.toContain("restoring-grace");
});
expect(party[0].maxHp).toBe(124);
expect(party[1].maxHp).toBe(186);
expect(runMaxMana(buffs)).toBe(120);
expect(runHealingMultiplier(buffs)).toBe(1.15);
it("compiles capped ranks and overlays one passive infusion rank", () => {
const ranks: RunBuffRanks = {
"mend-echo": 9,
"mend-efficiency": 2,
"renew-duration": 2,
"shield-guard": 3,
"purify-renew": 1,
"radiance-shield": 2,
"barrier-regen": 3,
};
const modifiers = compileRunModifiers(ranks, "mend-efficiency");
expect(modifiers.mendExtraTargets).toBe(3);
expect(modifiers.mendManaMultiplier).toBeCloseTo(0.75 ** 3);
expect(modifiers.renewDurationBonus).toBe(4);
expect(modifiers.shieldDamageTakenMultiplier).toBeCloseTo(0.76);
expect(modifiers.purifyAppliesRenew).toBe(true);
expect(modifiers.radianceAbsorb).toBe(18);
expect(modifiers.barrierHealingPerSecond).toBe(9);
expect(effectiveRunBuffRank(ranks, "mend-efficiency", "mend-efficiency")).toBe(3);
expect(formatRunBuffEffect("mend-efficiency", 3)).toBe("58% less Mend mana cost");
});
it("increments earned ranks without exceeding each buff cap", () => {
const first = increaseRunBuffRank({}, "purify-renew");
const capped = increaseRunBuffRank(first, "purify-renew");
expect(first["purify-renew"]).toBe(1);
expect(capped["purify-renew"]).toBe(1);
});
it("draws unique eligible choices and handles one or zero remaining buffs", () => {
expect(selectRunBuffDraft({}, null, () => 0)).toEqual(RUN_BUFF_ORDER.slice(0, 3));
const maxed = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
maxed["mend-efficiency"] = 2;
expect(selectRunBuffDraft(maxed, null, () => 0)).toEqual(["mend-efficiency"]);
expect(selectRunBuffDraft(maxed, "mend-efficiency", () => 0)).toEqual([]);
});
it("selects a distinct pair that excludes both bosses from the prior round", () => {
+179 -38
View File
@@ -1,62 +1,203 @@
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import type { BossId, PartyMember, RunBuffId } from "./types";
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
export type RunBuffEffectKind =
| "extra-target"
| "mana-cost"
| "cast-time"
| "duration"
| "healing"
| "absorb"
| "damage-reduction"
| "trigger-renew"
| "trigger-shield"
| "chain-cleanse"
| "cooldown"
| "barrier-healing";
export interface RunBuffDefinition {
id: RunBuffId;
abilityId: AbilityId;
effectKind: RunBuffEffectKind;
name: string;
icon: string;
summary: string;
detail: string;
accent: string;
maxRank: 1 | 3;
infusionEligible: true;
}
export const RUN_BUFF_ORDER: readonly RunBuffId[] = ["vital-bloom", "deep-wells", "restoring-grace"];
export interface CompiledRunModifiers {
mendExtraTargets: number;
mendManaMultiplier: number;
mendCastTimeMultiplier: number;
renewExtraTargets: number;
renewDurationBonus: number;
renewHealingMultiplier: number;
shieldExtraTargets: number;
shieldAbsorbMultiplier: number;
shieldDamageTakenMultiplier: number;
purifyAppliesRenew: boolean;
purifyAppliesShield: boolean;
purifyExtraTargets: number;
radianceCooldownMultiplier: number;
radianceAppliesRenew: boolean;
radianceAbsorb: number;
barrierCooldownMultiplier: number;
barrierDurationBonus: number;
barrierHealingPerSecond: number;
}
export const RUN_BUFF_ORDER: readonly RunBuffId[] = [
"mend-echo",
"mend-efficiency",
"mend-cast-speed",
"renew-spread",
"renew-duration",
"renew-potency",
"shield-echo",
"shield-potency",
"shield-guard",
"purify-renew",
"purify-shield",
"purify-chain",
"radiance-cooldown",
"radiance-renew",
"radiance-shield",
"barrier-cooldown",
"barrier-duration",
"barrier-regen",
];
const buff = (
id: RunBuffId,
abilityId: AbilityId,
effectKind: RunBuffEffectKind,
name: string,
icon: string,
summary: string,
detail: string,
accent: string,
maxRank: 1 | 3 = 3,
): RunBuffDefinition => ({ id, abilityId, effectKind, name, icon, summary, detail, accent, maxRank, infusionEligible: true });
export const RUN_BUFFS: Record<RunBuffId, RunBuffDefinition> = {
"vital-bloom": {
id: "vital-bloom",
name: "Vital Bloom",
icon: "♧",
summary: "+12% party max HP",
detail: "Stacks each time chosen. New round starts at full health.",
accent: "#74d18c",
},
"deep-wells": {
id: "deep-wells",
name: "Deep Wells",
icon: "◇",
summary: "+20 maximum mana",
detail: "Stacks each time chosen. New round starts with full mana.",
accent: "#69baff",
},
"restoring-grace": {
id: "restoring-grace",
name: "Restoring Grace",
icon: "✦",
summary: "+15% healing done",
detail: "Strengthens Mend, Renew, and Radiance. Stacks additively.",
accent: "#f1d479",
},
"mend-echo": buff("mend-echo", "mend", "extra-target", "Echoing", "+", "+1 secondary ally", "Mend heals another injured ally for 50% power per rank.", "#f2d690"),
"mend-efficiency": buff("mend-efficiency", "mend", "mana-cost", "Efficient", "▽", "25% mana cost", "Mend mana cost is multiplied by 0.75 per rank, rounded up.", "#72c8ef"),
"mend-cast-speed": buff("mend-cast-speed", "mend", "cast-time", "Swift", "»", "25% cast time", "Mend cast time is multiplied by 0.75 per rank.", "#d8b4ff"),
"renew-spread": buff("renew-spread", "renew", "extra-target", "Spreading", "✣", "+1 injured ally", "Direct Renew casts affect another injured ally per rank.", "#71df9c"),
"renew-duration": buff("renew-duration", "renew", "duration", "Enduring", "◷", "+2s duration", "Every Renew effect lasts 2 seconds longer per rank.", "#83d9aa"),
"renew-potency": buff("renew-potency", "renew", "healing", "Potent", "↑", "+20% tick healing", "Every Renew tick heals 20% more per rank.", "#a8e875"),
"shield-echo": buff("shield-echo", "shield", "extra-target", "Echoing Aegis", "◇", "+1 secondary ally", "Shield another injured ally for 50% power per rank.", "#75c9ff"),
"shield-potency": buff("shield-potency", "shield", "absorb", "Reinforced Aegis", "⬡", "+25% absorption", "All healer-created absorption is 25% stronger per rank.", "#61b9ee"),
"shield-guard": buff("shield-guard", "shield", "damage-reduction", "Guardian Aegis", "▣", "8% shielded damage", "Targets with absorption take 8% less incoming damage per rank.", "#8baeff"),
"purify-renew": buff("purify-renew", "purify", "trigger-renew", "Cleansing Renewal", "✧", "Purify applies Renew", "Every ally cleansed by Purify also gains Renew.", "#b58cff", 1),
"purify-shield": buff("purify-shield", "purify", "trigger-shield", "Purifying Ward", "◈", "Purify grants 50% Shield", "Every ally cleansed by Purify gains half-strength absorption.", "#9f9aff", 1),
"purify-chain": buff("purify-chain", "purify", "chain-cleanse", "Mass Purification", "✦", "+1 cleansed ally", "Purify also cleanses the most injured other debuffed ally.", "#d4a7ff", 1),
"radiance-cooldown": buff("radiance-cooldown", "radiance", "cooldown", "Quickened Radiance", "☀", "20% cooldown", "Radiance cooldown is multiplied by 0.8 per rank.", "#ffd66b"),
"radiance-renew": buff("radiance-renew", "radiance", "trigger-renew", "Radiant Renewal", "❈", "Radiance applies Renew", "Radiance applies Renew to every living party member.", "#d4e978", 1),
"radiance-shield": buff("radiance-shield", "radiance", "absorb", "Radiant Aegis", "◎", "+9 party absorption", "Radiance grants 9 base absorption to every living ally per rank.", "#ffe58c"),
"barrier-cooldown": buff("barrier-cooldown", "barrier", "cooldown", "Hallowed Ground", "◉", "20% cooldown", "Barrier cooldown is multiplied by 0.8 per rank.", "#e7cb62"),
"barrier-duration": buff("barrier-duration", "barrier", "duration", "Lingering Barrier", "⌛", "+2s duration", "Barrier remains active 2 seconds longer per rank.", "#cdbd69"),
"barrier-regen": buff("barrier-regen", "barrier", "barrier-healing", "Restorative Ground", "✚", "+3 healing per second", "Living allies inside Barrier heal every second per rank.", "#84d69a"),
};
export function countRunBuff(buffs: readonly RunBuffId[], buffId: RunBuffId) {
return buffs.reduce((count, current) => count + Number(current === buffId), 0);
export function runBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): number {
return Math.max(0, Math.min(RUN_BUFFS[buffId].maxRank, Math.floor(ranks[buffId] ?? 0)));
}
export function applyRunBuffsToParty(party: PartyMember[], buffs: readonly RunBuffId[]) {
const vitalityMultiplier = 1 + countRunBuff(buffs, "vital-bloom") * 0.12;
return party.map((member) => {
const maxHp = Math.round(member.maxHp * vitalityMultiplier);
return { ...member, maxHp, hp: maxHp };
});
export function effectiveRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId, passiveInfusionId: RunBuffId | null = null): number {
return Math.min(RUN_BUFFS[buffId].maxRank, runBuffRank(ranks, buffId) + Number(passiveInfusionId === buffId));
}
export function runMaxMana(buffs: readonly RunBuffId[]) {
return 100 + countRunBuff(buffs, "deep-wells") * 20;
export function increaseRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): RunBuffRanks {
const current = runBuffRank(ranks, buffId);
if (current >= RUN_BUFFS[buffId].maxRank) return { ...ranks };
return { ...ranks, [buffId]: current + 1 };
}
export function runHealingMultiplier(buffs: readonly RunBuffId[]) {
return 1 + countRunBuff(buffs, "restoring-grace") * 0.15;
export function selectRunBuffDraft(
ranks: RunBuffRanks,
passiveInfusionId: RunBuffId | null = null,
random: () => number = Math.random,
count = 3,
): RunBuffId[] {
const pool = RUN_BUFF_ORDER.filter((id) => effectiveRunBuffRank(ranks, id, passiveInfusionId) < RUN_BUFFS[id].maxRank);
const choices: RunBuffId[] = [];
while (choices.length < count && pool.length > 0) {
const sample = random();
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
const index = Math.floor(randomValue * pool.length);
choices.push(pool[index]);
pool.splice(index, 1);
}
return choices;
}
export function compileRunModifiers(ranks: RunBuffRanks, passiveInfusionId: RunBuffId | null = null): CompiledRunModifiers {
const rank = (id: RunBuffId) => effectiveRunBuffRank(ranks, id, passiveInfusionId);
return {
mendExtraTargets: rank("mend-echo"),
mendManaMultiplier: 0.75 ** rank("mend-efficiency"),
mendCastTimeMultiplier: 0.75 ** rank("mend-cast-speed"),
renewExtraTargets: rank("renew-spread"),
renewDurationBonus: rank("renew-duration") * 2,
renewHealingMultiplier: 1 + rank("renew-potency") * 0.2,
shieldExtraTargets: rank("shield-echo"),
shieldAbsorbMultiplier: 1 + rank("shield-potency") * 0.25,
shieldDamageTakenMultiplier: 1 - rank("shield-guard") * 0.08,
purifyAppliesRenew: rank("purify-renew") > 0,
purifyAppliesShield: rank("purify-shield") > 0,
purifyExtraTargets: rank("purify-chain"),
radianceCooldownMultiplier: 0.8 ** rank("radiance-cooldown"),
radianceAppliesRenew: rank("radiance-renew") > 0,
radianceAbsorb: rank("radiance-shield") * 9,
barrierCooldownMultiplier: 0.8 ** rank("barrier-cooldown"),
barrierDurationBonus: rank("barrier-duration") * 2,
barrierHealingPerSecond: rank("barrier-regen") * 3,
};
}
export function runAbilityManaCost(abilityId: AbilityId, baseCost: number, modifiers: CompiledRunModifiers): number {
if (baseCost <= 0) return 0;
const multiplier = abilityId === "mend" ? modifiers.mendManaMultiplier : 1;
return Math.max(1, Math.ceil(baseCost * multiplier));
}
export function runAbilityCastTime(abilityId: AbilityId, baseCastTime: number, modifiers: CompiledRunModifiers): number {
return abilityId === "mend" ? baseCastTime * modifiers.mendCastTimeMultiplier : baseCastTime;
}
export function runAbilityCooldown(abilityId: AbilityId, baseCooldown: number, modifiers: CompiledRunModifiers): number {
if (abilityId === "radiance") return baseCooldown * modifiers.radianceCooldownMultiplier;
if (abilityId === "barrier") return baseCooldown * modifiers.barrierCooldownMultiplier;
return baseCooldown;
}
export function formatRunBuffEffect(buffId: RunBuffId, requestedRank: number): string {
const rank = Math.max(1, Math.min(RUN_BUFFS[buffId].maxRank, requestedRank));
const reduced = (multiplier: number) => `${Math.round((1 - multiplier ** rank) * 100)}% less`;
switch (buffId) {
case "mend-echo": return `${rank} secondary ${rank === 1 ? "ally" : "allies"} at 50% healing`;
case "mend-efficiency": return `${reduced(0.75)} Mend mana cost`;
case "mend-cast-speed": return `${reduced(0.75)} Mend cast time`;
case "renew-spread": return `${rank} additional Renew ${rank === 1 ? "target" : "targets"}`;
case "renew-duration": return `+${rank * 2}s Renew duration`;
case "renew-potency": return `+${rank * 20}% Renew tick healing`;
case "shield-echo": return `${rank} secondary Shield ${rank === 1 ? "target" : "targets"} at 50% power`;
case "shield-potency": return `+${rank * 25}% healer absorption`;
case "shield-guard": return `${rank * 8}% less damage while shielded`;
case "purify-renew": return "Purify applies Renew";
case "purify-shield": return "Purify grants 50% Shield";
case "purify-chain": return "Purify cleanses one additional ally";
case "radiance-cooldown": return `${reduced(0.8)} Radiance cooldown`;
case "radiance-renew": return "Radiance applies Renew party-wide";
case "radiance-shield": return `+${rank * 9} base party absorption`;
case "barrier-cooldown": return `${reduced(0.8)} Barrier cooldown`;
case "barrier-duration": return `+${rank * 2}s Barrier duration`;
case "barrier-regen": return `${rank * 3} Barrier healing per second`;
}
}
export function bossHealthMultiplier(round: number) {
+191 -36
View File
@@ -3,9 +3,25 @@ import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
import type { RunBuffRanks } from "./types";
function startBuffedEncounter(runBuffRanks: RunBuffRanks) {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
useGameStore.setState({ runBuffRanks, runModifiers: compileRunModifiers(runBuffRanks) });
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
}
function testDebuff(id: string) {
return { id, name: id, expiresAt: 10, nextTickAt: 9, tickDamage: 1 };
}
describe("Disc Priest combat simulation", () => {
beforeEach(() => {
@@ -106,15 +122,16 @@ describe("Disc Priest combat simulation", () => {
});
it("Purify removes Ember Brand from selected ally", () => {
useGameStore.getState().tick(2);
useGameStore.getState().tick(2);
useGameStore.getState().tick(1.1);
const branded = useGameStore.getState().party.find((member) => member.id === "nia")!;
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, mechanicCount: 3, nextMechanicAt: state.time },
}));
useGameStore.getState().tick(0.1);
const branded = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Ember Brand"))!;
expect(branded.debuffs).toHaveLength(1);
useGameStore.getState().selectMember("nia");
useGameStore.getState().selectMember(branded.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.debuffs).toHaveLength(0);
expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0);
});
it("Radiance heals every living party member", () => {
@@ -131,8 +148,7 @@ describe("Disc Priest combat simulation", () => {
it("reduces damage by 30% for party members inside Barrier", () => {
useGameStore.getState().setPlayerPosition([0, -3.7]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextNovaAt: 999, nextBrandAt: 999 },
bossMotion: { ...state.bossMotion, nextChargeAt: 999 },
boss: { ...state.boss },
}));
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
useGameStore.getState().tick(2);
@@ -190,40 +206,40 @@ describe("Disc Priest combat simulation", () => {
});
it("telegraphs, executes, and recovers from a Bull charge", () => {
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
while (useGameStore.getState().bossMotion.mode !== "telegraph") useGameStore.getState().tick(0.1);
const telegraph = useGameStore.getState().bossMotion;
expect(telegraph.mode).toBe("telegraph");
expect(telegraph.chargeTargetId).toBe("nia");
const targetId = telegraph.chargeTargetId;
const midpoint: [number, number] = [
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
];
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, nia: midpoint },
party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member),
partyPositions: { ...state.partyPositions, [targetId]: midpoint },
party: state.party.map((member) => member.id === targetId ? { ...member, knockedUntil: state.time + 10 } : member),
}));
while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes("nia"); step += 1) {
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes(targetId); step += 1) {
useGameStore.getState().tick(0.05);
}
const hitState = useGameStore.getState();
expect(hitState.bossMotion.chargeHitIds).toContain("nia");
expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
expect(hitState.bossMotion.chargeHitIds).toContain(targetId);
expect(hitState.party.find((member) => member.id === targetId)!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
useGameStore.getState().tick(0.1);
}
const recovered = useGameStore.getState();
expect(recovered.bossMotion.mode).toBe("holding");
expect(recovered.bossMotion.nextChargeAt).toBeGreaterThan(recovered.time);
expect(recovered.bossMotion.nextMechanicAt).toBeGreaterThan(recovered.time);
});
it("moves every mobile AI party member out of the charge lane before impact", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
boss: { ...state.boss, nextMeleeAt: 999 },
}));
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
useGameStore.getState().tick(0.1);
@@ -260,7 +276,7 @@ describe("Disc Priest combat simulation", () => {
it("marks a stack target after three charges and splits 200 pounce damage", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
boss: { ...state.boss, nextMeleeAt: 999 },
playerPosition: [0, 0],
partyPositions: {
aelia: [0, 0],
@@ -269,18 +285,13 @@ describe("Disc Priest combat simulation", () => {
orin: [0, 0],
vale: [0, 0],
},
bossMotion: {
...state.bossMotion,
mode: "returning",
position: [0, -1],
chargesSincePounce: 3,
},
bossMotion: { ...state.bossMotion, activeMechanicId: null, mode: "holding", position: [0, -1], mechanicCount: 1, nextMechanicAt: state.time },
}));
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
expect(useGameStore.getState().bossMotion.pounceTargetId).toBe("aelia");
expect(useGameStore.getState().bossMotion.pounceTargetId).toBeTruthy();
expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
@@ -292,7 +303,7 @@ describe("Disc Priest combat simulation", () => {
useGameStore.getState().tick(0.05);
}
const impacted = useGameStore.getState();
expect(impacted.bossMotion.mode).toBe("returning");
expect(impacted.bossMotion.mode).toBe("holding");
for (const member of impacted.party) {
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
}
@@ -345,7 +356,7 @@ describe("Broodfang encounter", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: {
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
nextMechanicAt: 999,
},
}));
@@ -356,8 +367,8 @@ describe("Broodfang encounter", () => {
useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage);
expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(1);
@@ -418,6 +429,129 @@ describe("PVE dual-boss encounter", () => {
});
});
describe("Roguelike ability buffs", () => {
it("reduces Mend cost and cast time while echoing to lowest-health allies", () => {
startBuffedEncounter({ "mend-echo": 2, "mend-efficiency": 3, "mend-cast-speed": 3 });
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "aelia" ? 90 : member.id === "brann" ? 60 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : 30,
})),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
expect(useGameStore.getState().mana).toBe(97);
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
useGameStore.getState().tick(0.22);
const party = useGameStore.getState().party;
expect(party.find((member) => member.id === "brann")?.hp).toBe(98);
expect(party.find((member) => member.id === "nia")?.hp).toBe(29);
expect(party.find((member) => member.id === "orin")?.hp).toBe(39);
expect(party.find((member) => member.id === "vale")?.hp).toBe(30);
});
it("spreads longer, stronger Renew effects without recursive targeting", () => {
startBuffedEncounter({ "renew-spread": 2, "renew-duration": 2, "renew-potency": 2 });
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "brann" ? 50 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
})),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("renew")).toBe(true);
let party = useGameStore.getState().party;
expect(party.filter((member) => member.renewExpiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]);
useGameStore.getState().tick(1.01);
party = useGameStore.getState().party;
expect(party.find((member) => member.id === "brann")?.hp).toBeCloseTo(59.8);
expect(party.find((member) => member.id === "nia")?.hp).toBeCloseTo(19.8);
expect(party.find((member) => member.id === "orin")?.hp).toBeCloseTo(29.8);
});
it("strengthens and echoes Shield to deterministic secondary targets", () => {
startBuffedEncounter({ "shield-echo": 2, "shield-potency": 2 });
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "brann" ? 70 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
})),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("shield")).toBe(true);
const party = useGameStore.getState().party;
expect(party.find((member) => member.id === "brann")?.absorb).toBe(54);
expect(party.find((member) => member.id === "nia")?.absorb).toBe(27);
expect(party.find((member) => member.id === "orin")?.absorb).toBe(27);
});
it("chains Purify and applies triggered Renew and half-strength Shield", () => {
startBuffedEncounter({ "purify-renew": 1, "purify-shield": 1, "purify-chain": 1 });
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "brann"
? { ...member, hp: 70, debuffs: [testDebuff("tank-mark")] }
: member.id === "nia"
? { ...member, hp: 10, debuffs: [testDebuff("ranger-mark")] }
: member.id === "orin"
? { ...member, hp: 20, debuffs: [testDebuff("mage-mark")] }
: member),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("purify")).toBe(true);
const party = useGameStore.getState().party;
for (const id of ["brann", "nia"] as const) {
const member = party.find((candidate) => candidate.id === id)!;
expect(member.debuffs).toEqual([]);
expect(member.renewExpiresAt).toBe(8);
expect(member.absorb).toBe(18);
}
expect(party.find((member) => member.id === "orin")?.debuffs).toHaveLength(1);
});
it("applies Radiance cooldown, party Renew, and party absorption", () => {
startBuffedEncounter({ "radiance-cooldown": 3, "radiance-renew": 1, "radiance-shield": 2 });
useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 30) })) }));
expect(useGameStore.getState().castAbility("radiance")).toBe(true);
const state = useGameStore.getState();
expect(state.cooldowns.radiance).toBeCloseTo(14 * 0.8 ** 3);
expect(state.party.every((member) => member.renewExpiresAt === 8)).toBe(true);
expect(state.party.every((member) => member.absorb === 18)).toBe(true);
});
it("extends, quickens, and pulses healing from Barrier", () => {
startBuffedEncounter({ "barrier-cooldown": 3, "barrier-duration": 3, "barrier-regen": 3 });
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 50 } : member),
}));
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
expect(useGameStore.getState().cooldowns.barrier).toBeCloseTo(60 * 0.8 ** 3);
expect(useGameStore.getState().barrier.expiresAt).toBe(14);
useGameStore.getState().tick(1.01);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBe(59);
expect(useGameStore.getState().barrier.nextHealAt).toBe(2);
});
it("reduces incoming damage while absorption is present", () => {
startBuffedEncounter({ "shield-guard": 3 });
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? {
...member,
hp: 50,
absorb: 100,
debuffs: [{ id: "pulse", name: "Pulse", expiresAt: 2, nextTickAt: 0.5, tickDamage: 10 }],
} : member),
}));
useGameStore.getState().tick(1);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.absorb).toBeCloseTo(92.4);
});
});
describe("Roguelike rounds", () => {
beforeEach(() => {
useGameStore.getState().configureHealer(
@@ -445,23 +579,43 @@ describe("Roguelike rounds", () => {
expect(useGameStore.getState().phase).toBe("intermission");
expect(useGameStore.getState().round).toBe(1);
expect(useGameStore.getState().time).toBe(intermissionTime);
expect(useGameStore.getState().chooseRunBuff("vital-bloom")).toBe(true);
const chosenBuffId = useGameStore.getState().draftBuffIds[0]!;
expect(chosenBuffId).toBeDefined();
expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true);
const roundTwo = useGameStore.getState();
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
expect(roundTwo.phase).toBe("combat");
expect(roundTwo.round).toBe(2);
expect(roundTwo.runBuffs).toEqual(["vital-bloom"]);
expect(roundTwo.runBuffRanks[chosenBuffId]).toBe(1);
expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
expect(roundTwo.party[0].maxHp).toBe(112);
expect(roundTwo.party[0].maxHp).toBe(100);
});
it("rejects buff claims outside intermission", () => {
expect(useGameStore.getState().chooseRunBuff("deep-wells")).toBe(false);
expect(useGameStore.getState().chooseRunBuff("mend-efficiency")).toBe(false);
expect(useGameStore.getState().round).toBe(1);
});
it("offers explicit continuation after every buff reaches maximum rank", () => {
const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
useGameStore.setState({
phase: "intermission",
runBuffRanks: maxedRanks,
runModifiers: compileRunModifiers(maxedRanks),
draftBuffIds: [],
selectedRunBuffId: null,
});
expect(useGameStore.getState().continueRoguelikeRound()).toBe(true);
expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().round).toBe(2);
expect(useGameStore.getState().runBuffRanks).toEqual(maxedRanks);
expect(useGameStore.getState().draftBuffIds).toEqual([]);
expect(useGameStore.getState().continueRoguelikeRound()).toBe(false);
});
});
describe("shared arena boundary", () => {
@@ -473,8 +627,8 @@ describe("shared arena boundary", () => {
it("constrains player, party, and boss positions to the same room", () => {
useGameStore.getState().setPlayerPosition([100, 100]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 },
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100] },
partyPositions: {
...state.partyPositions,
brann: [50, 50],
@@ -533,6 +687,7 @@ describe("Tempestscale encounter", () => {
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
bossMotion: {
...state.bossMotion,
activeMechanicId: "storm-breath",
mode: "breath_sweeping",
position: [0, -2.8],
phaseStartedAt: state.time,
+169 -61
View File
@@ -15,13 +15,16 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import {
RUN_BUFF_ORDER,
RUN_BUFFS,
applyRunBuffsToParty,
bossHealthMultiplier,
runHealingMultiplier,
runMaxMana,
compileRunModifiers,
increaseRunBuffRank,
runAbilityCastTime,
runAbilityCooldown,
runAbilityManaCost,
selectRunBuffDraft,
selectRandomBossPair,
type CompiledRunModifiers,
} from "./roguelike";
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
@@ -41,6 +44,7 @@ import type {
MemberId,
PartyMember,
RunBuffId,
RunBuffRanks,
RunMode,
ScenePulse,
WorldPosition,
@@ -68,9 +72,11 @@ export interface GameState {
phase: GamePhase;
runMode: RunMode;
round: number;
runBuffs: RunBuffId[];
runBuffRanks: RunBuffRanks;
draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId;
selectedRunBuffId: RunBuffId | null;
passiveRunBuffId: RunBuffId | null;
runModifiers: CompiledRunModifiers;
healingMultiplier: number;
difficultySlug: DifficultySlug;
difficultyDamageMultiplier: number;
@@ -112,6 +118,7 @@ export interface GameState {
setPauseSelection: (selection: "resume" | "exit") => void;
setSelectedRunBuff: (buffId: RunBuffId) => void;
chooseRunBuff: (buffId: RunBuffId) => boolean;
continueRoguelikeRound: () => boolean;
}
const emptyCooldowns = (): Record<AbilityId, number> => ({
@@ -143,7 +150,6 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
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 constrainBossMotion(motion);
}
@@ -154,8 +160,6 @@ function createEncounterBoss(bossId: BossId, index: number, count: number, healt
boss.hp = boss.maxHp;
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) };
}
@@ -190,6 +194,45 @@ export function barrierProtects(position: WorldPosition, barrier: BarrierState,
return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
}
function lowestHealthIndexes(
party: readonly PartyMember[],
excludedIndex: number,
count: number,
predicate: (member: PartyMember) => boolean = () => true,
): number[] {
return party
.map((member, index) => ({ member, index }))
.filter(({ member, index }) => index !== excludedIndex && member.hp > 0 && predicate(member))
.sort((left, right) => (left.member.hp / left.member.maxHp) - (right.member.hp / right.member.maxHp) || left.index - right.index)
.slice(0, count)
.map(({ index }) => index);
}
function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers) {
if (party[index].hp <= 0) return;
party[index] = {
...party[index],
renewExpiresAt: time + 8 + modifiers.renewDurationBonus,
renewNextTickAt: time + 1,
};
}
function addHealerAbsorb(
party: PartyMember[],
index: number,
baseAmount: number,
healingPower: number,
modifiers: CompiledRunModifiers,
) {
if (party[index].hp <= 0 || baseAmount <= 0) return 0;
const amount = baseAmount * healingPower * modifiers.shieldAbsorbMultiplier;
party[index] = {
...party[index],
absorb: Math.min(party[index].maxHp, party[index].absorb + amount),
};
return amount;
}
function damageMemberAt(
member: PartyMember,
amount: number,
@@ -201,6 +244,7 @@ function damageMemberAt(
incomingDamageMultiplier = 1,
gearModifiers?: EncounterGearModifiers,
kind: "direct" | "hazard" = "direct",
shieldDamageTakenMultiplier = 1,
) {
amount *= incomingDamageMultiplier;
if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
@@ -211,7 +255,8 @@ function damageMemberAt(
barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
);
return damageMember(member, amount * (1 - reduction));
const shieldMultiplier = member.absorb > 0 ? shieldDamageTakenMultiplier : 1;
return damageMember(member, amount * (1 - reduction) * shieldMultiplier);
}
function addLog(
@@ -232,7 +277,7 @@ function initialState(
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
runMode: RunMode = "encounter",
round = 1,
runBuffs: RunBuffId[] = [],
runBuffRanks: RunBuffRanks = {},
gearProgress: GearProgress = createDefaultGearProgress(),
requestedDifficultySlug: DifficultySlug = "initiate",
) {
@@ -248,11 +293,10 @@ function initialState(
const primary = encounterBosses[0];
const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId);
const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null;
const effectiveRunBuffs = passiveInfusionId && !runBuffs.includes(passiveInfusionId)
? [passiveInfusionId, ...runBuffs]
: runBuffs;
const party = applyGearHealth(applyRunBuffsToParty(freshParty(healerClassId, playerName), effectiveRunBuffs), gearModifiers);
const maxMana = runMaxMana(effectiveRunBuffs);
const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId);
const draftBuffIds = runMode === "roguelike" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : [];
const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
const maxMana = 100;
return {
bossId: primary.boss.id,
paused: false,
@@ -262,10 +306,12 @@ function initialState(
phase: "briefing" as GamePhase,
runMode,
round,
runBuffs: [...runBuffs],
draftBuffIds: [...RUN_BUFF_ORDER],
selectedRunBuffId: RUN_BUFF_ORDER[0],
healingMultiplier: runHealingMultiplier(effectiveRunBuffs) * gearModifiers.aelia.healingPower,
runBuffRanks: { ...runBuffRanks },
draftBuffIds,
selectedRunBuffId: draftBuffIds[0] ?? null,
passiveRunBuffId: passiveInfusionId,
runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier,
gearProgress,
@@ -290,7 +336,7 @@ function initialState(
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,
barrier: { center: [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState,
};
}
@@ -298,14 +344,14 @@ export const useGameStore = create<GameState>((set, get) => ({
...initialState(),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => {
set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, [], gearProgress, difficultySlug));
set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug));
},
startEncounter: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffs, gearProgress, difficultySlug } = get();
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug } = get();
const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({
...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffs, gearProgress, difficultySlug),
...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug),
phase: "combat",
activeTab: "combat",
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
@@ -314,7 +360,7 @@ export const useGameStore = create<GameState>((set, get) => ({
restart: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get();
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, [], gearProgress, difficultySlug));
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, {}, gearProgress, difficultySlug));
},
selectMember: (selectedMemberId) => set({ selectedMemberId }),
@@ -341,18 +387,38 @@ export const useGameStore = create<GameState>((set, get) => ({
chooseRunBuff: (buffId) => {
const state = get();
if (state.phase !== "intermission" || !state.draftBuffIds.includes(buffId)) return false;
const runBuffs = [...state.runBuffs, buffId];
const runBuffRanks = increaseRunBuffRank(state.runBuffRanks, buffId);
const round = state.round + 1;
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
const bossIds = selectRandomBossPair(previousBossIds);
const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name;
set({
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffs, state.gearProgress, state.difficultySlug),
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffRanks, state.gearProgress, state.difficultySlug),
phase: "combat",
activeTab: "combat",
combatLog: [{
id: Date.now(),
time: 0,
message: `${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
message: `${abilityName}: ${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
tone: "good",
}],
});
return true;
},
continueRoguelikeRound: () => {
const state = get();
if (state.phase !== "intermission" || state.draftBuffIds.length > 0) return false;
const round = state.round + 1;
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
const bossIds = selectRandomBossPair(previousBossIds);
set({
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, state.runBuffRanks, state.gearProgress, state.difficultySlug),
phase: "combat",
activeTab: "combat",
combatLog: [{
id: Date.now(),
time: 0,
message: `All blessings mastered. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
tone: "good",
}],
});
@@ -381,12 +447,13 @@ export const useGameStore = create<GameState>((set, get) => ({
if (state.activeCast) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
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) {
if (state.mana < manaCost) {
set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
return false;
}
@@ -402,9 +469,9 @@ export const useGameStore = create<GameState>((set, get) => ({
abilityId: "mend",
targetId: selected.id,
startedAt: state.time,
completesAt: state.time + (ability.castTime ?? 0.5),
completesAt: state.time + runAbilityCastTime("mend", ability.castTime ?? 0.5, state.runModifiers),
},
mana: Math.max(0, state.mana - ability.mana),
mana: Math.max(0, state.mana - manaCost),
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
});
@@ -419,48 +486,73 @@ export const useGameStore = create<GameState>((set, get) => ({
switch (abilityId) {
case "renew":
party[selectedIndex] = {
...party[selectedIndex],
renewExpiresAt: state.time + 8,
renewNextTickAt: state.time + 1,
};
applyRenewAt(party, selectedIndex, state.time, state.runModifiers);
for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
applyRenewAt(party, index, state.time, state.runModifiers);
}
message = `${ability.name} placed on ${selected.name}.`;
break;
case "shield":
party[selectedIndex] = {
...party[selectedIndex],
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36 * state.gearModifiers.aelia.healingPower),
};
message = `${selected.name} gains ${Math.round(36 * state.gearModifiers.aelia.healingPower)} absorption.`;
case "shield": {
const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower, state.runModifiers);
for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
}
message = `${selected.name} gains ${Math.round(amount)} absorption.`;
break;
case "purify":
{
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
const primaryDispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
}
case "purify": {
const cleanseIndexes = [
selectedIndex,
...lowestHealthIndexes(party, selectedIndex, state.runModifiers.purifyExtraTargets, (member) => member.debuffs.length > 0),
];
const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
for (const index of cleanseIndexes) {
const target = party[index];
const dispelledNames = target.debuffs.map((debuff) => debuff.name);
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
bossMotion = primaryDispel.motion;
additionalBosses = state.additionalBosses.map((entry) => {
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
additionalBosses = additionalBosses.map((entry) => {
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.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[index] = { ...party[index], debuffs: [] };
if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers);
if (state.runModifiers.purifyAppliesShield) {
addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
}
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
}
message = primaryNames.includes("Widow Venom")
? "Widow Venom purged. A venom pool forms where the target stood."
: `${primaryNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
break;
}
case "radiance":
party = party.map((member) => healMember(member, 22 * state.healingMultiplier));
if (state.runModifiers.radianceAppliesRenew) {
for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers);
}
if (state.runModifiers.radianceAbsorb > 0) {
for (let index = 0; index < party.length; index += 1) {
addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower, state.runModifiers);
}
}
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.`;
barrier = {
center: [...state.partyPositions.aelia],
expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
nextHealAt: state.time + 1,
};
message = `${ability.name} protects a 3m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`;
break;
}
const cooldowns = {
...state.cooldowns,
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown * state.gearModifiers.aelia.cooldown : 0,
[abilityId]: ability.cooldown > 0
? state.time + runAbilityCooldown(abilityId, ability.cooldown, state.runModifiers) * state.gearModifiers.aelia.cooldown
: 0,
};
const pulse: ScenePulse = {
id: state.scenePulse.id + 1,
@@ -475,7 +567,7 @@ export const useGameStore = create<GameState>((set, get) => ({
barrier,
bossMotion,
additionalBosses,
mana: Math.max(0, state.mana - ability.mana),
mana: Math.max(0, state.mana - manaCost),
combatLog: addLog(state.combatLog, state.time, message, "good"),
scenePulse: pulse,
});
@@ -504,7 +596,7 @@ export const useGameStore = create<GameState>((set, get) => ({
let activeCast = state.activeCast ? { ...state.activeCast } : null;
let partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents;
const barrier = state.barrier;
let barrier = { ...state.barrier };
if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
@@ -512,6 +604,9 @@ export const useGameStore = create<GameState>((set, get) => ({
if (target?.hp > 0) {
const healing = 38 * state.healingMultiplier;
party[targetIndex] = healMember(target, healing);
for (const index of lowestHealthIndexes(party, targetIndex, state.runModifiers.mendExtraTargets)) {
party[index] = healMember(party[index], healing * 0.5);
}
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
@@ -525,7 +620,7 @@ export const useGameStore = create<GameState>((set, get) => ({
let tickAt = next.renewNextTickAt;
const lastTickAt = Math.min(time, next.renewExpiresAt);
while (tickAt <= lastTickAt + 0.001) {
next = healMember(next, 7 * state.healingMultiplier);
next = healMember(next, 7 * state.healingMultiplier * state.runModifiers.renewHealingMultiplier);
tickAt += 1;
}
next = {
@@ -539,7 +634,7 @@ export const useGameStore = create<GameState>((set, get) => ({
.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, state.difficultyDamageMultiplier, state.gearModifiers);
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier);
updated.nextTickAt += 1;
}
return updated;
@@ -548,6 +643,17 @@ export const useGameStore = create<GameState>((set, get) => ({
return { ...next, debuffs: activeDebuffs };
});
if (state.runModifiers.barrierHealingPerSecond > 0) {
while (barrier.nextHealAt <= time && barrier.nextHealAt < barrier.expiresAt) {
const pulseAt = barrier.nextHealAt;
const healing = state.runModifiers.barrierHealingPerSecond * state.healingMultiplier;
party = party.map((member) => barrierProtects(state.partyPositions[member.id], barrier, pulseAt)
? healMember(member, healing)
: member);
barrier.nextHealAt += 1;
}
}
const livingMotions = [
...(boss.hp > 0 ? [bossMotion] : []),
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
@@ -574,8 +680,7 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions,
time,
delta: time - oldTime,
allowPooledMechanics: encounterBosses.length === 1,
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier),
});
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
party = mechanicResult.party.map((member) => {
@@ -638,6 +743,7 @@ export const useGameStore = create<GameState>((set, get) => ({
activeCast,
combatLog,
scenePulse: pulse,
barrier,
});
},
}));
@@ -658,6 +764,7 @@ export type GameSnapshot = Omit<GameState,
| "setPauseSelection"
| "setSelectedRunBuff"
| "chooseRunBuff"
| "continueRoguelikeRound"
>;
export function getGameSnapshot(): GameSnapshot {
@@ -677,6 +784,7 @@ export function getGameSnapshot(): GameSnapshot {
setPauseSelection: _setPauseSelection,
setSelectedRunBuff: _setSelectedRunBuff,
chooseRunBuff: _chooseRunBuff,
continueRoguelikeRound: _continueRoguelikeRound,
...snapshot
} = useGameStore.getState();
return snapshot;
+55 -33
View File
@@ -28,16 +28,66 @@ export type BossId =
| "moonfang-wolf"
| "frostmaw-yeti"
| "rimeclaw-yeti";
export type BossMechanicId =
| "basic-melee"
| "bull-charge"
| "crushing-pounce"
| "cinder-nova"
| "ember-brand"
| "binding-web"
| "venom-purge"
| "storm-breath"
| "stormfall"
| "elemental-beam"
| "guardian-cross"
| "destruction-rush"
| "ruin-quake"
| "destruction-pulse"
| "ricochet-rush"
| "meteor-slam"
| "burrow-rush"
| "hourglass-eruption"
| "sidewinder-rush"
| "crushing-tide"
| "vine-scissors"
| "haunting-rifts"
| "tri-burst"
| "ultimate-skyfall"
| "meteor-spread"
| "hollow-collapse"
| "aetheric-soak"
| "prism-beam"
| "memory-sequence"
| "soul-siphon";
export type BossAnimationCue = "idle" | "move" | "attack" | "special";
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
export type RunMode = "encounter" | "roguelike";
export type RunBuffId = "vital-bloom" | "deep-wells" | "restoring-grace";
export type RunBuffId =
| "mend-echo"
| "mend-efficiency"
| "mend-cast-speed"
| "renew-spread"
| "renew-duration"
| "renew-potency"
| "shield-echo"
| "shield-potency"
| "shield-guard"
| "purify-renew"
| "purify-shield"
| "purify-chain"
| "radiance-cooldown"
| "radiance-renew"
| "radiance-shield"
| "barrier-cooldown"
| "barrier-duration"
| "barrier-regen";
export type RunBuffRanks = Partial<Record<RunBuffId, number>>;
export type BottomTab = "combat" | "map" | "pack";
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
export type BossMotionMode =
| "holding"
| "telegraph"
| "charging"
| "returning"
| "stacking"
| "pouncing"
| "tethering"
@@ -45,35 +95,12 @@ export type BossMotionMode =
| "breath_telegraph"
| "breath_sweeping"
| "skyfall"
| "mantis_sidestep"
| "mantis_line_telegraph"
| "mantis_cross_telegraph"
| "mantis_recover"
| "ram_charge_telegraph"
| "ram_charging"
| "ram_quake"
| "ram_shatter"
| "ram_recover"
| "cinderback_curl"
| "cinderback_ricochet"
| "cinderback_slam"
| "cinderback_recover"
| "sandglass_burrow_telegraph"
| "sandglass_burrowing"
| "sandglass_eruption"
| "sandglass_hourglass"
| "sandglass_recover"
| "crab_scuttle_telegraph"
| "crab_scuttling"
| "crab_tidal_burst"
| "crab_recover"
| "ghost_soul_cross"
| "ghost_soul_cross_followup"
| "ghost_haunting"
| "ghost_recover"
| "golem_shockwave"
| "golem_crownfall"
| "golem_recover";
| "golem_crownfall";
export type WorldPosition = [number, number];
export type CircleHazardKind =
@@ -194,13 +221,11 @@ export interface BossState {
maxHp: number;
hp: number;
nextMeleeAt: number;
nextNovaAt: number;
nextBrandAt: number;
brandCount: number;
}
export interface BossMotionState {
bossId: BossId;
activeMechanicId: BossMechanicId | null;
formationOffsetX: number;
mode: BossMotionMode;
position: WorldPosition;
@@ -209,12 +234,9 @@ export interface BossMotionState {
chargeTargetId: MemberId;
chargeHitIds: MemberId[];
phaseEndsAt: number;
nextChargeAt: number;
chargeCount: number;
chargesSincePounce: number;
pounceTargetId: MemberId;
pounceCenter: WorldPosition;
pounceCount: number;
nextMechanicAt: number;
mechanicCount: number;
phaseStartedAt: number;
@@ -227,7 +249,6 @@ export interface BossMotionState {
breathEndAngle: number;
hazards: CircleHazard[];
slashLanes: SlashLane[];
nextPoolMechanicAt: number;
poolMechanicCount: number;
poolTelegraphs: PoolTelegraph[];
}
@@ -257,6 +278,7 @@ export interface ActiveCast {
export interface BarrierState {
center: WorldPosition;
expiresAt: number;
nextHealAt: number;
}
export interface ScenePulse {
+10 -3
View File
@@ -6,7 +6,8 @@ import type { AbilityId } from "./types";
function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState();
const currentIndex = store.draftBuffIds.indexOf(store.selectedRunBuffId);
if (store.draftBuffIds.length === 0) return;
const currentIndex = store.selectedRunBuffId ? store.draftBuffIds.indexOf(store.selectedRunBuffId) : -1;
const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length;
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
}
@@ -44,7 +45,10 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1);
if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1);
if (key === "enter") store.chooseRunBuff(store.selectedRunBuffId);
if (key === "enter") {
if (store.selectedRunBuffId) store.chooseRunBuff(store.selectedRunBuffId);
else store.continueRoguelikeRound();
}
if (key === "escape") exitRef.current?.();
return;
}
@@ -97,7 +101,10 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (store.phase === "intermission") {
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1);
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1);
if (!repeat && token === "Button0") store.chooseRunBuff(store.selectedRunBuffId);
if (!repeat && token === "Button0") {
if (store.selectedRunBuffId) store.chooseRunBuff(store.selectedRunBuffId);
else store.continueRoguelikeRound();
}
if (!repeat && token === "Button1") exitRef.current?.();
return;
}
+6
View File
@@ -122,6 +122,8 @@ export function BottomDisplayApp() {
selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }),
selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }),
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }),
selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }),
upgradeSelectedGear: () => {
postFrontend({ name: "upgradeSelectedGear" });
return false;
@@ -155,6 +157,10 @@ export function BottomDisplayApp() {
postCommand({ name: "chooseRunBuff", buffId });
return false;
},
continueRoguelikeRound: () => {
postCommand({ name: "continueRoguelikeRound" });
return false;
},
});
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") {
+36 -6
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { diffBottomGameSnapshot, type BottomGameSnapshot } from "./dualScreenSync";
import { diffBottomGameSnapshot, executeFrontendCommand, executeGameCommand, type BottomGameSnapshot } from "./dualScreenSync";
import { useGameStore } from "../game/store";
import { useFrontendStore } from "../frontend/store";
function snapshot(): BottomGameSnapshot {
return {
@@ -8,16 +10,24 @@ function snapshot(): BottomGameSnapshot {
healerClassId: "priest",
phase: "combat",
round: 1,
runModifiers: {
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
shieldExtraTargets: 0, shieldAbsorbMultiplier: 1, shieldDamageTakenMultiplier: 1,
purifyAppliesRenew: false, purifyAppliesShield: false, purifyExtraTargets: 0,
radianceCooldownMultiplier: 1, radianceAppliesRenew: false, radianceAbsorb: 0,
barrierCooldownMultiplier: 1, barrierDurationBonus: 0, barrierHealingPerSecond: 0,
},
time: 10,
party: [],
boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 },
boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1 },
additionalBosses: [],
partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] },
bossMotion: {
bossId: "bulldrome", formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0,
nextChargeAt: Infinity, chargeCount: 0, chargesSincePounce: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], pounceCount: 0,
bossId: "bulldrome", activeMechanicId: null, formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0,
chargeCount: 0, pounceTargetId: "aelia", pounceCenter: [0, 0],
nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0,
breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], nextPoolMechanicAt: Infinity, poolMechanicCount: 0, poolTelegraphs: [],
breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], poolMechanicCount: 0, poolTelegraphs: [],
},
partyCombat: {
combatants: {
@@ -39,7 +49,7 @@ function snapshot(): BottomGameSnapshot {
inventory: [],
playerPosition: [0, 0],
activeCast: null,
barrier: { center: [0, 0], expiresAt: 0 },
barrier: { center: [0, 0], expiresAt: 0, nextHealAt: 0 },
};
}
@@ -54,4 +64,24 @@ describe("dual-screen game snapshots", () => {
const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 };
expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 });
});
it("routes maxed-run continuation and passive filter commands", () => {
const originalContinue = useGameStore.getState().continueRoguelikeRound;
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
const calls: string[] = [];
useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } });
useFrontendStore.setState({
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
});
executeGameCommand({ name: "continueRoguelikeRound" });
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]);
useGameStore.setState({ continueRoguelikeRound: originalContinue });
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
});
});
+10 -2
View File
@@ -22,7 +22,8 @@ export type GameCommand =
| { name: "setPaused"; paused: boolean }
| { name: "setPauseSelection"; selection: "resume" | "exit" }
| { name: "setSelectedRunBuff"; buffId: RunBuffId }
| { name: "chooseRunBuff"; buffId: RunBuffId };
| { name: "chooseRunBuff"; buffId: RunBuffId }
| { name: "continueRoguelikeRound" };
export type FrontendCommand =
| { name: "signIn"; username: string; password: string }
@@ -44,6 +45,8 @@ export type FrontendCommand =
| { name: "selectGearSlot"; slotId: GearSlotId }
| { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" }
| { name: "selectInfusion"; infusionId: string }
| { name: "selectPassiveAbility"; abilityId: AbilityId }
| { name: "selectPassiveInfusion"; passiveId: RunBuffId }
| { name: "upgradeSelectedGear" }
| { name: "equipSelectedInfusion" }
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
@@ -82,6 +85,7 @@ export function executeGameCommand(command: GameCommand) {
case "setPauseSelection": game.setPauseSelection(command.selection); break;
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
}
}
@@ -107,6 +111,8 @@ export function executeFrontendCommand(command: FrontendCommand) {
case "selectGearSlot": frontend.selectGearSlot(command.slotId); break;
case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break;
case "selectInfusion": frontend.selectInfusion(command.infusionId); break;
case "selectPassiveAbility": frontend.selectPassiveAbility(command.abilityId); break;
case "selectPassiveInfusion": frontend.selectPassiveInfusion(command.passiveId); break;
case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break;
case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break;
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
@@ -130,6 +136,7 @@ export type BottomGameSnapshot = Pick<GameState,
| "healerClassId"
| "phase"
| "round"
| "runModifiers"
| "time"
| "party"
| "boss"
@@ -151,7 +158,7 @@ export type BottomGameSnapshot = Pick<GameState,
>;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
"bossId", "paused", "healerClassId", "phase", "round", "time", "party", "boss", "additionalBosses",
"bossId", "paused", "healerClassId", "phase", "round", "runModifiers", "time", "party", "boss", "additionalBosses",
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
];
@@ -181,6 +188,7 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
healerClassId: state.healerClassId,
phase: state.phase,
round: state.round,
runModifiers: state.runModifiers,
time: state.time,
party: state.party,
boss: state.boss,
+90 -18
View File
@@ -1117,6 +1117,8 @@ button:focus-visible {
.buff-draft > header h2 { margin: 2px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; }
.buff-draft > header p { margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); }
.buff-choice-grid { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.5%; }
.buff-choice-grid.choice-count-1 { grid-template-columns: minmax(0, 360px); justify-content: center; }
.buff-choice-grid.choice-count-2 { width: min(640px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); justify-self: center; }
.buff-choice-grid button {
min-width: 0;
display: grid;
@@ -1138,6 +1140,7 @@ button:focus-visible {
.buff-choice-grid button strong { overflow: hidden; font-family: "Cinzel", serif; font-size: clamp(9px, 2cqw, 12px); text-overflow: ellipsis; white-space: nowrap; }
.buff-choice-grid button > b { grid-column: 1 / -1; color: #edf5f1; font-size: clamp(8px, 1.7cqw, 10px); }
.buff-choice-grid button > p { grid-column: 1 / -1; margin: 0; color: #71867e; font-size: clamp(7px, 1.45cqw, 9px); line-height: 1.25; }
.buff-choice-grid .buff-mastery-continue { grid-column: 1 / -1; width: min(420px, 100%); justify-self: center; align-self: center; }
.buff-draft > footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #6e827a; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; }
.buff-draft > footer b { color: #dce8e3; }
.buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); }
@@ -1908,7 +1911,18 @@ button:focus-visible {
.boss-picker-heading > div { display: flex; gap: 4px; }
.boss-picker-heading button { min-width: 72px; padding: 2px 6px; border: 1px solid var(--line); color: #a9bbb4; background: rgba(6,18,16,0.82); font-size: 7px; text-transform: uppercase; }
.boss-picker-heading button:disabled { opacity: 0.32; }
.boss-choice-grid { display: grid; grid-template-columns: repeat(var(--boss-grid-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
.boss-group-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; }
.boss-group-choice { min-height: 30px; display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 5px; padding: 4px 6px; border: 1px solid var(--line); color: #9db2aa; background: rgba(6,18,16,0.82); text-align: left; }
.boss-group-choice > b { display: grid; width: 19px; height: 19px; place-items: center; border: 1px solid #527066; border-radius: 50%; color: var(--gold); font-family: "Cinzel", serif; font-size: 9px; }
.boss-group-choice > span { display: grid; min-width: 0; }
.boss-group-choice strong { font-size: 7px; }
.boss-group-choice small { overflow: hidden; color: #6f867d; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
.boss-group-choice.is-selected { border-color: var(--gold); background: rgba(87,69,25,0.22); color: #e8d68d; }
.boss-group-choice.is-selected > b { border-color: var(--gold); background: rgba(232,200,114,0.14); }
.boss-group-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding-top: 3px; color: #d6e3de; }
.boss-group-heading > span { font-family: "Cinzel", serif; font-size: 10px; }
.boss-group-heading > small { color: #71867e; font-size: 7px; text-transform: uppercase; }
.boss-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; }
.boss-choice > span { display: grid; min-width: 0; }
@@ -2058,7 +2072,15 @@ button:focus-visible {
.mode-hero p { font-size: 7px; }
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; }
.boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; }
.boss-group-grid { gap: 3px; }
.boss-group-choice { min-height: 23px; grid-template-columns: 16px minmax(0, 1fr); gap: 3px; padding: 2px 3px; }
.boss-group-choice > b { width: 15px; height: 15px; font-size: 6px; }
.boss-group-choice strong { font-size: 5px; }
.boss-group-choice small { font-size: 4px; }
.boss-group-heading { padding-top: 1px; }
.boss-group-heading > span { font-size: 7px; }
.boss-group-heading > small { font-size: 4px; }
.boss-choice-grid { gap: 3px; }
.boss-picker-heading { min-height: 11px; }
.boss-picker-heading > span { font-size: 4px; }
.boss-picker-heading button { min-width: 42px; padding: 1px 3px; font-size: 4px; }
@@ -2101,7 +2123,7 @@ button:focus-visible {
.gear-mode-tabs { display: flex; gap: 4px; }
.gear-mode-tabs button { padding: 6px 8px; color: #7f958c; font-size: 7px; font-weight: 700; text-transform: uppercase; }
.gear-mode-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.2); }
.gear-workshop-layout { height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; }
.gear-workshop-layout { box-sizing: border-box; height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; }
.gear-owner-list,
.gear-slot-list { display: grid; align-content: start; gap: 5px; }
.gear-owner-list button,
@@ -2132,23 +2154,27 @@ button:focus-visible {
.gear-infusion-options,
.gear-passive-options { display: grid; gap: 4px; margin-top: 9px; }
.gear-infusion-options button,
.gear-passive-options button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; }
.gear-passive-choice-list button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; }
.gear-infusion-options button > i,
.gear-passive-options button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; }
.gear-passive-choice-list button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; }
.gear-infusion-options button > span,
.gear-passive-options button > span { min-width: 0; display: grid; }
.gear-passive-choice-list button > span { min-width: 0; display: grid; }
.gear-infusion-options button strong,
.gear-passive-options button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.gear-passive-choice-list button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.gear-infusion-options button small,
.gear-passive-options button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
.gear-passive-choice-list button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
.gear-infusion-options button > b,
.gear-passive-options button > b { color: var(--gold); font-size: 9px; }
.gear-passive-choice-list button > b { color: var(--gold); font-size: 9px; }
.gear-infusion-options button.is-selected { border-color: var(--gold); background: rgba(72,58,21,.16); }
.gear-infusion-options button.is-equipped,
.gear-passive-options button.is-equipped { box-shadow: inset 3px 0 #67c89e; }
.gear-passive-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.gear-passive-options > span { grid-column: 1 / -1; color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.gear-passive-options button { min-width: 0; grid-template-columns: 18px 1fr 10px; }
.gear-passive-choice-list button.is-equipped { box-shadow: inset 3px 0 #67c89e; }
.gear-passive-options > span { color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.gear-passive-ability-filter { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; }
.gear-passive-ability-filter button { min-width: 0; min-height: 22px; padding: 2px 4px; color: #829890; font-size: 6px; }
.gear-passive-ability-filter button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.16); }
.gear-passive-choice-list { display: grid; gap: 3px; }
.gear-passive-choice-list button { min-width: 0; grid-template-columns: 18px 1fr 10px; }
.gear-passive-choice-list button.is-selected { border-color: #70b99d; background: rgba(46,103,81,.14); }
.gear-context { padding: 0 5.5% 18px; }
.gear-context .context-header { margin: 0 -5.8%; }
@@ -2165,6 +2191,44 @@ button:focus-visible {
.gear-upgrade-action:disabled { border-color: #42554e; color: #71827c; background: #13201c; }
.gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; }
.gear-upgrade-action small { font-size: clamp(6px, 1.35cqw, 8px); }
.gear-passive-context-action { position: absolute; right: 5.5%; bottom: 57px; left: 5.5%; min-height: 56px; display: grid; align-content: center; padding: 9px 13px; border: 1px solid #67c89e; color: #dce8e3; background: rgba(20,55,43,.72); }
.gear-passive-context-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; }
.gear-passive-context-action small { color: #8aa198; font-size: clamp(6px, 1.35cqw, 8px); }
@media (min-width: 761px) and (max-width: 1000px) and (max-height: 650px) {
.gear-surface { padding: 0 12px; }
.gear-surface .front-screen-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
.gear-mode-tabs { gap: 2px; }
.gear-mode-tabs button { padding: 3px 4px; font-size: 4px; }
.gear-workshop-layout { height: calc(100% - 81px); grid-template-columns: 27% 31% 1fr; gap: 4px; padding-top: 5px; }
.gear-owner-list,
.gear-slot-list { gap: 2px; }
.gear-owner-list button,
.gear-slot-list button { min-height: 27px; gap: 3px; padding: 2px 4px; }
.gear-slot-list button { grid-template-columns: 16px 1fr 17px; }
.gear-owner-list strong,
.gear-slot-list strong { font-size: 5px; }
.gear-owner-list small,
.gear-slot-list small { font-size: 4px; }
.gear-slot-list button > i { width: 14px; height: 14px; font-size: 6px; }
.gear-preview { padding: 6px; }
.gear-preview h2 { margin: 3px 0; font-size: 8px; }
.gear-preview p { font-size: 5px; }
.gear-preview > span { font-size: 4px; }
.gear-infusion-options,
.gear-passive-options { gap: 2px; margin-top: 3px; }
.gear-infusion-options button,
.gear-passive-choice-list button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
.gear-infusion-options button > i,
.gear-passive-choice-list button > i { font-size: 6px; }
.gear-infusion-options button strong,
.gear-passive-choice-list button strong { font-size: 4px; }
.gear-infusion-options button small,
.gear-passive-choice-list button small,
.gear-passive-options > span { font-size: 3px; }
.gear-passive-ability-filter { gap: 2px; }
.gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; }
}
.reward-summary { display: grid; gap: 4px; margin: 8px 0; }
.reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; }
@@ -2191,7 +2255,13 @@ button:focus-visible {
.difficulty-picker > span { font-size: 4px; }
.mode-dungeons .mode-hero { display: none; }
.mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; }
.mode-dungeons .boss-choice-grid { gap: 2px; }
.mode-dungeons .boss-group-grid, .mode-dungeons .boss-choice-grid { gap: 2px; }
.mode-dungeons .boss-group-choice { min-height: 22px; grid-template-columns: 15px minmax(0, 1fr); gap: 2px; padding: 2px; }
.mode-dungeons .boss-group-choice > b { width: 14px; height: 14px; font-size: 6px; }
.mode-dungeons .boss-group-choice strong { font-size: 5px; }
.mode-dungeons .boss-group-choice small { display: none; }
.mode-dungeons .boss-group-heading > span { font-size: 6px; }
.mode-dungeons .boss-group-heading > small { font-size: 4px; }
.mode-dungeons .boss-choice { min-width: 0; min-height: 27px; grid-template-columns: 15px 1fr 7px; gap: 2px; padding: 2px 3px; }
.mode-dungeons .boss-choice > i { width: 14px; height: 14px; font-size: 6px; }
.mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
@@ -2224,12 +2294,14 @@ button:focus-visible {
.gear-infusion-options,
.gear-passive-options { gap: 2px; margin-top: 3px; }
.gear-infusion-options button,
.gear-passive-options button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
.gear-passive-choice-list button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
.gear-infusion-options button > i,
.gear-passive-options button > i { font-size: 6px; }
.gear-passive-choice-list button > i { font-size: 6px; }
.gear-infusion-options button strong,
.gear-passive-options button strong { font-size: 4px; }
.gear-passive-choice-list button strong { font-size: 4px; }
.gear-infusion-options button small,
.gear-passive-options button small,
.gear-passive-choice-list button small,
.gear-passive-options > span { font-size: 3px; }
.gear-passive-ability-filter { gap: 2px; }
.gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; }
}