Release v0.1.5 2026-07-12

This commit is contained in:
Warren H
2026-07-12 22:10:12 -04:00
parent bef71d391a
commit 35553c18dd
41 changed files with 2005 additions and 2654 deletions
+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. - 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 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. - 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. - 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. - State what was verified and any deliberately unverified scope in the handoff.
## Definition of done ## 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. 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. 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. 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. 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. Every code change is not complete until:
7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
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", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "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 { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store"; 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 { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types"; import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store"; 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 selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast); const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility); const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const remaining = abilityRemaining(abilityId, time, cooldowns); 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 globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0; const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 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 disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`; const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return ( return (
<button <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-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span> <span className="ability-pad">{ability.gamepad}</span>
{remaining > 0 && ( {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> <b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span> </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"; import { useGameStore } from "../game/store";
export function BuffDraftPanel({ className = "" }: { className?: string }) { export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round); 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 choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId); const selected = useGameStore((state) => state.selectedRunBuffId);
const setSelected = useGameStore((state) => state.setSelectedRunBuff); const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff); const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1; const nextRound = round + 1;
const abilities = HEALER_CLASSES[healerClassId].abilities;
return ( return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}> <div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<header> <header>
@@ -16,10 +21,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
<h2>Choose one blessing</h2> <h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p> <p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header> </header>
<div className="buff-choice-grid"> <div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.map((buffId) => { {choices.length > 0 ? choices.map((buffId) => {
const buff = RUN_BUFFS[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 ( return (
<button <button
key={buffId} key={buffId}
@@ -31,14 +38,21 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
aria-pressed={selected === buffId} aria-pressed={selected === buffId}
> >
<i>{buff.icon}</i> <i>{buff.icon}</i>
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span> <span><small>{rank ? `Rank ${rank} ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{buff.summary}</b> <b>{formatRunBuffEffect(buffId, nextRank)}</b>
<p>{buff.detail}</p> <p>{buff.detail}</p>
</button> </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> </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> </div>
); );
} }
+115 -56
View File
@@ -5,9 +5,11 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types"; import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers"; import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog"; import { ABILITY_ORDER } from "../game/data";
import { selectRandomBossPair } from "../game/roguelike"; import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
import type { BossId } from "../game/types"; import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
import type { AbilityId, BossId } from "../game/types";
import { import {
GEAR_OWNER_LABELS, GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER, GEAR_OWNER_ORDER,
@@ -483,10 +485,14 @@ function GearScreen() {
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId); const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode); const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId); 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 selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot); const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode); const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion); 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 upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion); const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion); const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
@@ -503,6 +509,10 @@ function GearScreen() {
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts)); const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress)); const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman"; 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 previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({ ...GEAR_OWNER_ORDER.map((ownerId, index) => ({
@@ -518,7 +528,7 @@ function GearScreen() {
id: `slot-${slotId}`, id: `slot-${slotId}`,
run: () => selectSlot(slotId), run: () => selectSlot(slotId),
neighbors: { 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]}`, down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`, left: `owner-${selectedOwnerId}`,
right: previewEntryId, right: previewEntryId,
@@ -531,25 +541,42 @@ function GearScreen() {
run: () => selectInfusion(infusion.id), run: () => selectInfusion(infusion.id),
neighbors: { neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`, 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}`, 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}`, id: `passive-${passive.id}`,
run: () => installPassive(passive.id), run: () => {
selectPassiveInfusion(passive.id);
installPassive(passive.id);
},
enabled: passiveUnlocked, enabled: passiveUnlocked,
neighbors: { neighbors: {
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`, up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`, down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`, left: `slot-${selectedSlotId}`,
}, },
})) : []), })) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `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: "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: { down: `owner-${GEAR_OWNER_ORDER[0]}` } }, { id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]); ], [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 controller = useMenuController(actions, { onBack: () => navigate("home") });
const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-");
if (!hunter || !slot) return null; if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level); const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1)); const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
@@ -585,7 +612,25 @@ function GearScreen() {
<div className="gear-infusion-options"> <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>)} {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> </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>} </article>}
</div> </div>
<ControllerLegend back /> <ControllerLegend back />
@@ -593,15 +638,15 @@ function GearScreen() {
} }
bottom={ bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory"> <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"> <div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span> <span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { {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; 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>; 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>} }) : <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> </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> <div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface> </FrontSurface>
} }
@@ -664,8 +709,6 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty); const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate); const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState(""); 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 mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest; const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId]; 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 selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve"; const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons"; const isDungeon = modeId === "dungeons";
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize); const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize); const visibleBossIds = selectedBossGroup.bossIds;
const bossGridRows = Math.ceil(visibleBossIds.length / 3); const bossGridColumns = Math.min(2, visibleBossIds.length);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows); const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
const changeBossPage = (nextPage: number) => { selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
}; };
const launch = () => { const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate"); 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."); setMessage("Online matchmaking connects here when game server is configured.");
}; };
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? visibleBossIds.map((bossId, index) => { ...(isDungeon ? BOSS_GROUPS.map((group, index) => {
const column = Math.floor(index / bossGridRows); const groupColumns = 5;
const row = index % bossGridRows; const row = Math.floor(index / groupColumns);
const neighborInColumn = (targetColumn: number) => { const column = index % groupColumns;
const columnStart = targetColumn * bossGridRows; const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1; return {
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`; 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 { return {
id: `boss-${bossId}`, id: `boss-${bossId}`,
run: () => selectBoss(bossId), run: () => selectBoss(bossId),
neighbors: { neighbors: {
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back", up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length) down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
? `boss-${visibleBossIds[index + 1]}` left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
: `difficulty-${DIFFICULTIES[0].slug}`, right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : 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) => ({ ...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`, id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(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: "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" } }, { id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]); ], [bossGridColumns, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") }); const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking"; const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon const contextRules = isDungeon
? [ ? [
[selectedBoss.name, selectedBoss.summary], [selectedBoss.name, selectedBoss.summary],
[selectedBoss.mechanics[0], selectedBoss.briefing], [bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."], [bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
] ]
: isPve : 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="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && ( {isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter"> <div className="boss-picker" aria-label="Choose boss encounter">
<div className="boss-picker-heading"> <div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span> <div className="boss-group-grid" aria-label="Choose boss group">
<div> {BOSS_GROUPS.map((group) => (
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton> <FocusButton
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton> key={group.id}
</div> 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) => { {visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId]; const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return ( return (
<FocusButton <FocusButton
key={bossId} key={bossId}
@@ -775,7 +834,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId} aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(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> </FocusButton>
); );
})} })}
+19 -89
View File
@@ -6,6 +6,7 @@ import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena } from "../game/arena"; import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog"; import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import { bossAnimationCue } from "../game/bosses/mechanicPool";
import { import {
isActorAnimationOneShot, isActorAnimationOneShot,
shouldStartActorAnimation, shouldStartActorAnimation,
@@ -598,7 +599,9 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
function BullBoss({ bossIndex }: { bossIndex: number }) { function BullBoss({ bossIndex }: { bossIndex: number }) {
const phase = useGameStore((state) => state.phase); 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 bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0; const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null); const group = useRef<THREE.Group>(null);
@@ -618,15 +621,13 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const clipName = phase === "victory" || defeated const clipName = phase === "victory" || defeated
? "Death" ? "Death"
: motionMode === "telegraph" : animationCue === "attack"
? "Idle_Headlow" ? "Idle_Headlow"
: motionMode === "pouncing" : animationCue === "special"
? "Gallop_Jump" ? "Gallop_Jump"
: motionMode === "charging" || motionMode === "returning" : animationCue === "move"
? "Gallop" ? "Gallop"
: motionMode === "stacking" : "Idle";
? "Idle_Headlow"
: "Idle";
useEffect(() => { useEffect(() => {
const next = actions[clipName]; const next = actions[clipName];
@@ -661,9 +662,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") { if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
facingX = motion.chargeEnd[0] - motion.chargeStart[0]; facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1]; 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) { if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ); const targetAngle = Math.atan2(facingX, facingZ);
@@ -745,7 +743,7 @@ const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
}, },
"crystal-bat-matriarch": { "crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL, url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 1.04, scale: 0.828,
idle: "Idle", idle: "Idle",
move: "Swoop", move: "Swoop",
attack: "SonicPulse", attack: "SonicPulse",
@@ -820,84 +818,17 @@ const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
}, },
}; };
const PROTOTYPE_MOVE_MODES = [ function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
"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"]) {
const config = ALTERNATE_BOSS_CONFIG[kind]; const config = ALTERNATE_BOSS_CONFIG[kind];
if (config.prototype) { return config[bossAnimationCue(motion)];
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;
} }
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) { function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind]; const config = ALTERNATE_BOSS_CONFIG[kind];
const archetype = BOSS_ARCHETYPE_BY_ID[kind]; const archetype = BOSS_ARCHETYPE_BY_ID[kind];
const phase = useGameStore((state) => state.phase); 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 bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0; const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null); const group = useRef<THREE.Group>(null);
@@ -915,7 +846,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
}); });
}, [kind, model]); }, [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(() => { useEffect(() => {
const next = actions[clipName]; const next = actions[clipName];
@@ -944,7 +875,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return; if (!current) return;
const motion = current.motion; const motion = current.motion;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall"; 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; const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]); 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)); 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")) { if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle; targetAngle = motion.breathAngle;
} else if (archetype === "duelist" && ( } else if (
motion.mode === "mantis_sidestep" motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph" || motion.mode === "mantis_cross_telegraph"
)) { ) {
const target = state.partyPositions[motion.chargeTargetId]; const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]); 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]); targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
} }
const difference = Math.atan2( const difference = Math.atan2(
+96 -21
View File
@@ -1,18 +1,17 @@
import { useFrame } from "@react-three/fiber"; 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 * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics"; import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { useGameStore } from "../../game/store"; 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 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 STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = []; const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = []; const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: 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_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a"; const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80"; 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} />)}</>; 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 }) { export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex)); 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 color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5; const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const; 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 ( return (
<group position={[tile.center[0], 0.075, tile.center[1]]}> <group ref={group} position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh> <mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} /> <boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} /> <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 flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center; const source = bossPosition ?? telegraph.center;
const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0;
return ( 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 && ( {showingSequence && (
<group position={[source[0], 2.5, source[1]]}> <Html position={[source[0], 3.1, source[1] + 0.3]} center zIndexRange={[30, 20]} style={{ pointerEvents: "none" }}>
<mesh rotation={[-Math.PI / 2, 0, 0]}> <div style={{ display: "grid", justifyItems: "center", gap: 7 }}>
<circleGeometry args={[0.88, 32]} /> <div
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} /> style={{
</mesh> width: 64,
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}> height: 64,
<ringGeometry args={[0.8, 0.9, 32]} /> display: "grid",
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} /> placeItems: "center",
</mesh> borderRadius: "50%",
<MemorySymbolMark symbol={flashSymbol} size={1.05} /> border: `4px solid ${MEMORY_SYMBOLS[flashSymbol].color}`,
</group> 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 { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository"; import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { groupDrop } from "../game/progression/loot"; import { groupDrop } from "../game/progression/loot";
import { RUN_BUFF_ORDER } from "../game/roguelike";
function memoryStorage(): StorageAdapter { function memoryStorage(): StorageAdapter {
const data = new Map<string, string>(); const data = new Map<string, string>();
@@ -169,16 +170,29 @@ describe("SaveRepository", () => {
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused"); const created = repository.create(1, "Infused");
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary"; 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.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 })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
const migrated = repository.list(null)[0].local!; const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(5); expect(migrated.schemaVersion).toBe(5);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); 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.infusionAbilityId).toBeNull();
expect(migrated.gearProgress.brann.passiveInfusionId).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 { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository"; import { AccountRepository, type AccountResult } from "./accountRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types"; 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 { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
import { import {
equipActiveInfusion, equipActiveInfusion,
equipPassiveInfusion, equipPassiveInfusion,
infusionsForOwner, infusionsForOwner,
} from "../game/progression/infusions"; } from "../game/progression/infusions";
import type { RunBuffId } from "../game/types";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
const repository = new SaveRepository(); const repository = new SaveRepository();
@@ -59,6 +59,8 @@ export interface FrontendState {
selectedGearSlotId: GearSlotId; selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion"; gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string; selectedInfusionId: string;
selectedPassiveAbilityId: AbilityId;
selectedPassiveInfusionId: RunBuffId;
recentRewards: BossRewardAward[]; recentRewards: BossRewardAward[];
settings: GameSettings; settings: GameSettings;
notice: string; notice: string;
@@ -81,6 +83,8 @@ export interface FrontendState {
selectGearSlot: (slotId: GearSlotId) => void; selectGearSlot: (slotId: GearSlotId) => void;
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void; selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
selectInfusion: (infusionId: string) => void; selectInfusion: (infusionId: string) => void;
selectPassiveAbility: (abilityId: AbilityId) => void;
selectPassiveInfusion: (passiveId: RunBuffId) => void;
upgradeSelectedGear: () => boolean; upgradeSelectedGear: () => boolean;
equipSelectedInfusion: () => boolean; equipSelectedInfusion: () => boolean;
equipPassiveInfusion: (passiveId: RunBuffId) => boolean; equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
@@ -110,6 +114,8 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedGearSlotId: "weapon", selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade", gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id, selectedInfusionId: infusionsForOwner("priest")[0].id,
selectedPassiveAbilityId: "mend",
selectedPassiveInfusionId: "mend-echo",
recentRewards: [], recentRewards: [],
settings: loadSettings(), settings: loadSettings(),
notice: "", notice: "",
@@ -187,6 +193,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }), selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }), selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, 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: () => { upgradeSelectedGear: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get(); const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
if (!activeSlotId) return false; if (!activeSlotId) return false;
@@ -326,6 +341,8 @@ export type FrontendSnapshot = Omit<FrontendState,
| "selectGearSlot" | "selectGearSlot"
| "selectGearWorkshopMode" | "selectGearWorkshopMode"
| "selectInfusion" | "selectInfusion"
| "selectPassiveAbility"
| "selectPassiveInfusion"
| "upgradeSelectedGear" | "upgradeSelectedGear"
| "equipSelectedInfusion" | "equipSelectedInfusion"
| "equipPassiveInfusion" | "equipPassiveInfusion"
@@ -359,6 +376,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
selectGearSlot: _selectGearSlot, selectGearSlot: _selectGearSlot,
selectGearWorkshopMode: _selectGearWorkshopMode, selectGearWorkshopMode: _selectGearWorkshopMode,
selectInfusion: _selectInfusion, selectInfusion: _selectInfusion,
selectPassiveAbility: _selectPassiveAbility,
selectPassiveInfusion: _selectPassiveInfusion,
upgradeSelectedGear: _upgradeSelectedGear, upgradeSelectedGear: _upgradeSelectedGear,
equipSelectedInfusion: _equipSelectedInfusion, equipSelectedInfusion: _equipSelectedInfusion,
equipPassiveInfusion: _equipPassiveInfusion, equipPassiveInfusion: _equipPassiveInfusion,
+18
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics"; import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
describe("boss catalog", () => { describe("boss catalog", () => {
it("derives the available roster from every catalog definition", () => { it("derives the available roster from every catalog definition", () => {
@@ -42,4 +43,21 @@ describe("boss catalog", () => {
expect(state.hp).toBe(state.maxHp); expect(state.hp).toBe(state.maxHp);
expect(motion.bossId).toBe(bossId); 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 = export type BossArchetype =
| "bull" | "bull"
@@ -46,7 +47,7 @@ export interface BossDefinition {
failure: string; failure: string;
mapTitle: string; mapTitle: string;
mapCopy: string; mapCopy: string;
mechanics: readonly [string, string, ...string[]]; mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
maxHp: number; maxHp: number;
archetype: BossArchetype; archetype: BossArchetype;
groupId: BossGroupId; groupId: BossGroupId;
@@ -58,16 +59,20 @@ interface BossSeed {
icon: string; icon: string;
accent: string; accent: string;
summary: string; summary: string;
mechanics: readonly [string, string, ...string[]]; mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
maxHp: number; maxHp: number;
archetype: BossArchetype; archetype: BossArchetype;
} }
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition { 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 { return {
id, id,
...seed, ...seed,
mechanicIds,
groupId: BOSS_GROUP_BY_BOSS_ID[id], groupId: BOSS_GROUP_BY_BOSS_ID[id],
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`, trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
briefing: `Read ${first}, then preserve open ground for ${second}.`, 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> = { const BOSS_SEEDS: Record<BossId, BossSeed> = {
bulldrome: { bulldrome: {
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e", 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": { "sandglass-scorpion": {
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f", 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": { "cragclaw-crab": {
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4", 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": { "mournveil-ghost": {
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff", 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": { "crownshard-golem": {
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45", 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": { "crystal-bat-matriarch": {
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff", 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": { "stormwool-alpaca": {
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff", 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": { "cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d", 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": { "ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d", 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": { "riftclaw-demon": {
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff", 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": { "tempestscale-dragon": {
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8", 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: { emberfox: {
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45", 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": { "mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b", 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": { "stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563", 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": { "glub-sovereign": {
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8", 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": { "scrapking-goblin": {
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b", 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": { "warcaller-orc": {
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f", 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": { "tuskmaw-orc": {
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d", 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": { "broodfang-spider": {
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff", 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": { "silkfang-spider": {
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8", 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": { "thorncrown-stag": {
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b", 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": { "sky-totem": {
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1", 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": { "razorcrest-raptor": {
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a", 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": { "bristlequake-boar": {
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45", 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": { "moonfang-wolf": {
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5", 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": { "frostmaw-yeti": {
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef", 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": { "rimeclaw-yeti": {
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8", 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) => { it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
const motion = createBossMotionState(bossId); const motion = createBossMotionState(bossId);
motion.position = [7, 5]; motion.position = [7, 5];
motion.nextChargeAt = Number.POSITIVE_INFINITY;
motion.nextMechanicAt = Number.POSITIVE_INFINITY; motion.nextMechanicAt = Number.POSITIVE_INFINITY;
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
const before = Math.hypot( const before = Math.hypot(
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX), motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
motion.position[1] - ARENA_CENTER[1], motion.position[1] - ARENA_CENTER[1],
+29 -423
View File
@@ -1,447 +1,53 @@
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog"; import { BOSS_DEFINITIONS } from "./bossCatalog";
import { clampToArena } from "./arena"; import {
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper"; advanceMechanicLoadout,
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet"; BOSS_MECHANIC_REGISTRY,
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab"; BULL_CHARGE,
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem"; BULL_POUNCE,
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis"; handleMechanicDispel,
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost"; upcomingLoadoutMechanic,
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem"; } from "./bosses/mechanicPool";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion"; import { createBaseMotion } from "./bosses/shared";
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared"; import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool"; import type { BossId, BossMotionState, BossState, MemberId, WorldPosition } from "./types";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
export const BULL_CHARGE = { export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
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 function createBossState(bossId: BossId = "bulldrome"): BossState { export function createBossState(bossId: BossId = "bulldrome"): BossState {
const definition = BOSS_DEFINITIONS[bossId]; const definition = BOSS_DEFINITIONS[bossId];
const archetype = BOSS_ARCHETYPE_BY_ID[bossId]; return {
let state: BossState; id: bossId,
if (archetype === "web-caster") state = createVexaState(bossId); name: definition.name,
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId); maxHp: definition.maxHp,
else if (archetype === "duelist") state = createEmberMantisState(bossId); hp: definition.maxHp,
else if (archetype === "ram") state = createObsidianRamState(bossId); nextMeleeAt: 2,
else if (archetype === "ricochet") state = createCinderbackState(bossId); };
else if (archetype === "burrower") state = createSandglassState();
else if (archetype === "crab") state = createCragclawState();
else if (archetype === "ghost") state = createMournveilState();
else if (archetype === "golem") state = createCrownshardState();
else {
state = {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
brandCount: 0,
};
}
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
} }
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState { export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
const archetype = BOSS_ARCHETYPE_BY_ID[bossId]; return {
let motion: BossMotionState; ...createBaseMotion(bossId),
if (archetype === "web-caster") motion = createVexaMotion(bossId); position: [0, -6.8],
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId); chargeStart: [0, -6.8],
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId); nextMechanicAt: 5,
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,
};
});
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 { export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
const archetype = mechanicArchetype(context.boss.id); return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds);
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);
} }
export function handleBossDispel( export function handleBossDispel(
bossId: BossId, _bossId: BossId,
motion: BossMotionState, motion: BossMotionState,
memberId: MemberId, memberId: MemberId,
position: WorldPosition, position: WorldPosition,
time: number, time: number,
debuffNames: readonly string[], debuffNames: readonly string[],
) { ) {
if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) { return handleMechanicDispel(motion, memberId, position, time, debuffNames);
return {
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
}
return { motion, message: "Harmful magic removed." };
} }
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) { export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
const pooled = upcomingPooledMechanic(motion, time); return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, 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 };
} }
-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 { describe, expect, it } from "vitest";
import { freshParty } from "../data"; import { freshParty } from "../data";
import { createBaseMotion } from "./shared"; import { createBaseMotion } from "./shared";
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool"; import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext, BossMechanicResult } from "./types"; import type { BossMechanicContext } from "./types";
import type { BossState, PoolTelegraph, WorldPosition } from "../types"; import type { BossState, PoolTelegraph, WorldPosition } from "../types";
const POSITIONS: BossMechanicContext["partyPositions"] = { const POSITIONS: BossMechanicContext["partyPositions"] = {
@@ -20,9 +20,6 @@ function state(): BossState {
maxHp: 500, maxHp: 500,
hp: 500, hp: 500,
nextMeleeAt: Number.POSITIVE_INFINITY, 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 { function memoryTelegraph(): PoolTelegraph {
return { return {
id: "test-memory", id: "test-memory",
@@ -79,10 +72,10 @@ function advanceMemory(
source.party = party; source.party = party;
source.motion = { source.motion = {
...source.motion, ...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY, activeMechanicId: "memory-sequence",
poolTelegraphs: [telegraph], poolTelegraphs: [telegraph],
}; };
return advancePooledBossMechanics(source, result(source)); return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]);
} }
function soulSiphonTelegraph(): PoolTelegraph { function soulSiphonTelegraph(): PoolTelegraph {
@@ -121,21 +114,31 @@ function advanceSoulSiphon(
source.party = party; source.party = party;
source.motion = { source.motion = {
...source.motion, ...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY, activeMechanicId: "soul-siphon",
poolTelegraphs: [telegraph], poolTelegraphs: [telegraph],
}; };
return advancePooledBossMechanics(source, result(source)); return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]);
} }
describe("shared boss mechanic pool", () => { describe("shared boss mechanic pool", () => {
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => { it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => {
const source = context(POOLED_MECHANIC_TIMING.firstAt); const source = context(0);
const started = advancePooledBossMechanics(source, result(source)); 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.motion.poolTelegraphs).not.toHaveLength(0);
expect(started.events[0].message).toContain(":"); 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", () => { it("splits soak damage across allies inside its indicator", () => {
@@ -156,14 +159,14 @@ describe("shared boss mechanic pool", () => {
resolved: false, resolved: false,
hitIds: [], 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); const positions = structuredClone(POSITIONS);
positions.aelia = [0.2, 0]; positions.aelia = [0.2, 0];
positions.brann = [0, 0]; positions.brann = [0, 0];
positions.nia = [-0.2, 0]; positions.nia = [-0.2, 0];
positions.vale = [0, -4]; positions.vale = [0, -4];
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt }; 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 === "aelia")!.hp).toBe(source.party[0].hp - 30);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].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]; positions.brann = [3, 0];
const atImpact = { const atImpact = {
...source, ...source,
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] }, motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] },
partyPositions: positions, partyPositions: positions,
time: activatesAt, 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 === "aelia")!.hp).toBe(source.party[0].hp);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25); 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 { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry"; 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"; import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const BOSS_MECHANIC_POOL = [ 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", id: "meteor-spread",
name: "Meteor Spread", name: "Meteor Spread",
@@ -36,18 +61,62 @@ export const BOSS_MECHANIC_POOL = [
}, },
] as const; ] 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 = { export const POOLED_MECHANIC_TIMING = {
firstAt: 15,
repeatDelay: 26,
activeDuration: 0.42, activeDuration: 0.42,
warningDuration: 1.4, warningDuration: 1.4,
} as const; } 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 = { export const MEMORY_SEQUENCE = {
sequenceLength: 4, sequenceLength: 4,
flashDuration: 0.68, flashDuration: 0.9,
inputDuration: 7, inputDuration: 7,
tileSize: 2.15, tileSize: 2.15,
raidwideDamage: 15, raidwideDamage: 15,
@@ -84,13 +153,6 @@ const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
]; ];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"]; 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) { function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0) return party.some((member) => member.id === targetId && member.hp > 0)
? targetId ? targetId
@@ -220,9 +282,10 @@ function beginPoolMechanic(
party: PartyMember[], party: PartyMember[],
positions: BossMechanicContext["partyPositions"], positions: BossMechanicContext["partyPositions"],
time: number, time: number,
requestedId: BossMechanicId,
) { ) {
const count = motion.poolMechanicCount + 1; 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; const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[]; let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined; let targetId: MemberId | undefined;
@@ -281,7 +344,6 @@ function beginPoolMechanic(
motion: { motion: {
...motion, ...motion,
poolMechanicCount: count, poolMechanicCount: count,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: telegraphs, poolTelegraphs: telegraphs,
}, },
event: { event: {
@@ -498,61 +560,6 @@ function resolveTelegraph(
return next; 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 { export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time); const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
if (!telegraphs.length) return null; if (!telegraphs.length) return null;
@@ -582,3 +589,702 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U
urgent: true, 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; 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({ export function createCircleHazard({
id, id,
kind, kind,
@@ -92,6 +79,7 @@ export function createCircleHazard({
export function createBaseMotion(bossId: BossId): BossMotionState { export function createBaseMotion(bossId: BossId): BossMotionState {
return { return {
bossId, bossId,
activeMechanicId: null,
formationOffsetX: 0, formationOffsetX: 0,
mode: "holding", mode: "holding",
position: [0, -8.2], position: [0, -8.2],
@@ -100,12 +88,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
chargeTargetId: "nia", chargeTargetId: "nia",
chargeHitIds: [], chargeHitIds: [],
phaseEndsAt: 0, phaseEndsAt: 0,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: 0, chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia", pounceTargetId: "aelia",
pounceCenter: [0, 4.5], pounceCenter: [0, 4.5],
pounceCount: 0,
nextMechanicAt: Number.POSITIVE_INFINITY, nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: 0, mechanicCount: 0,
phaseStartedAt: 0, phaseStartedAt: 0,
@@ -118,7 +103,6 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathEndAngle: 0, breathEndAngle: 0,
hazards: [], hazards: [],
slashLanes: [], slashLanes: [],
nextPoolMechanicAt: 15,
poolMechanicCount: 0, poolMechanicCount: 0,
poolTelegraphs: [], 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>; partyPositions: Record<MemberId, WorldPosition>;
time: number; time: number;
delta: 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; 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 { 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 { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; 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"][] = [ const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
"mantis_line_telegraph", "mantis_line_telegraph",
"mantis_cross_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"][] = [ const FORMATION_MODES: readonly BossMotionState["mode"][] = [
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover", "holding", "telegraph", "tethering", "venom_cast", "skyfall", "cinderback_slam", "sandglass_hourglass", "golem_shockwave", "golem_crownfall",
"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",
]; ];
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> = { const FORMATION_SLOTS: Record<AiMemberId, WorldPosition> = {
// Bosses face Brann during normal uptime, making positive Z their front. // Bosses face Brann during normal uptime, making positive Z their front.
+9 -3
View File
@@ -7,15 +7,20 @@ import {
equipActiveInfusion, equipActiveInfusion,
equipPassiveInfusion, equipPassiveInfusion,
infusionCosts, infusionCosts,
PASSIVE_INFUSIONS,
passiveInfusionUnlocked, passiveInfusionUnlocked,
} from "./infusions"; } from "./infusions";
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot"; import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
import { RUN_BUFF_ORDER } from "../roguelike";
function stack(item: ReturnType<typeof groupDrop>, quantity: number): MaterialStack { 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 }; return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
} }
describe("IWT2-style gear infusions", () => { 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", () => { it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => {
const progress = createDefaultGearProgress(); const progress = createDefaultGearProgress();
progress.brann.slots.weapon.level = 5; progress.brann.slots.weapon.level = 5;
@@ -54,9 +59,10 @@ describe("IWT2-style gear infusions", () => {
const progress = createDefaultGearProgress(); const progress = createDefaultGearProgress();
progress.vale.slots.feet.level = 10; progress.vale.slots.feet.level = 10;
expect(passiveInfusionUnlocked(progress)).toBe(true); 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); useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused);
expect(useGameStore.getState().maxMana).toBe(120); expect(useGameStore.getState().runModifiers.mendManaMultiplier).toBe(0.75);
expect(useGameStore.getState().runBuffs).toEqual([]); 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 { describe, expect, it } from "vitest";
import { freshParty } from "./data";
import { import {
applyRunBuffsToParty, RUN_BUFF_ORDER,
RUN_BUFFS,
bossHealthMultiplier, bossHealthMultiplier,
runHealingMultiplier, compileRunModifiers,
runMaxMana, effectiveRunBuffRank,
formatRunBuffEffect,
increaseRunBuffRank,
selectRandomBossPair, selectRandomBossPair,
selectRunBuffDraft,
} from "./roguelike"; } from "./roguelike";
import type { RunBuffRanks } from "./types";
describe("roguelike progression", () => { describe("roguelike progression", () => {
it("adds 10% base boss HP per completed round", () => { it("adds 10% base boss HP per completed round", () => {
@@ -15,14 +19,51 @@ describe("roguelike progression", () => {
expect(bossHealthMultiplier(5)).toBe(1.4); expect(bossHealthMultiplier(5)).toBe(1.4);
}); });
it("stacks each persistent buff independently", () => { it("defines 18 unique infusion-eligible ability buffs without legacy ids", () => {
const buffs = ["vital-bloom", "vital-bloom", "deep-wells", "restoring-grace"] as const; expect(RUN_BUFF_ORDER).toHaveLength(18);
const party = applyRunBuffsToParty(freshParty("priest", "Aelia"), buffs); 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); it("compiles capped ranks and overlays one passive infusion rank", () => {
expect(party[1].maxHp).toBe(186); const ranks: RunBuffRanks = {
expect(runMaxMana(buffs)).toBe(120); "mend-echo": 9,
expect(runHealingMultiplier(buffs)).toBe(1.15); "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", () => { 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 { 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 { export interface RunBuffDefinition {
id: RunBuffId; id: RunBuffId;
abilityId: AbilityId;
effectKind: RunBuffEffectKind;
name: string; name: string;
icon: string; icon: string;
summary: string; summary: string;
detail: string; detail: string;
accent: 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> = { export const RUN_BUFFS: Record<RunBuffId, RunBuffDefinition> = {
"vital-bloom": { "mend-echo": buff("mend-echo", "mend", "extra-target", "Echoing", "+", "+1 secondary ally", "Mend heals another injured ally for 50% power per rank.", "#f2d690"),
id: "vital-bloom", "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"),
name: "Vital Bloom", "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"),
icon: "♧", "renew-spread": buff("renew-spread", "renew", "extra-target", "Spreading", "✣", "+1 injured ally", "Direct Renew casts affect another injured ally per rank.", "#71df9c"),
summary: "+12% party max HP", "renew-duration": buff("renew-duration", "renew", "duration", "Enduring", "◷", "+2s duration", "Every Renew effect lasts 2 seconds longer per rank.", "#83d9aa"),
detail: "Stacks each time chosen. New round starts at full health.", "renew-potency": buff("renew-potency", "renew", "healing", "Potent", "↑", "+20% tick healing", "Every Renew tick heals 20% more per rank.", "#a8e875"),
accent: "#74d18c", "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"),
"deep-wells": { "shield-guard": buff("shield-guard", "shield", "damage-reduction", "Guardian Aegis", "▣", "8% shielded damage", "Targets with absorption take 8% less incoming damage per rank.", "#8baeff"),
id: "deep-wells", "purify-renew": buff("purify-renew", "purify", "trigger-renew", "Cleansing Renewal", "✧", "Purify applies Renew", "Every ally cleansed by Purify also gains Renew.", "#b58cff", 1),
name: "Deep Wells", "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),
icon: "◇", "purify-chain": buff("purify-chain", "purify", "chain-cleanse", "Mass Purification", "✦", "+1 cleansed ally", "Purify also cleanses the most injured other debuffed ally.", "#d4a7ff", 1),
summary: "+20 maximum mana", "radiance-cooldown": buff("radiance-cooldown", "radiance", "cooldown", "Quickened Radiance", "☀", "20% cooldown", "Radiance cooldown is multiplied by 0.8 per rank.", "#ffd66b"),
detail: "Stacks each time chosen. New round starts with full mana.", "radiance-renew": buff("radiance-renew", "radiance", "trigger-renew", "Radiant Renewal", "❈", "Radiance applies Renew", "Radiance applies Renew to every living party member.", "#d4e978", 1),
accent: "#69baff", "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"),
"restoring-grace": { "barrier-duration": buff("barrier-duration", "barrier", "duration", "Lingering Barrier", "⌛", "+2s duration", "Barrier remains active 2 seconds longer per rank.", "#cdbd69"),
id: "restoring-grace", "barrier-regen": buff("barrier-regen", "barrier", "barrier-healing", "Restorative Ground", "✚", "+3 healing per second", "Living allies inside Barrier heal every second per rank.", "#84d69a"),
name: "Restoring Grace",
icon: "✦",
summary: "+15% healing done",
detail: "Strengthens Mend, Renew, and Radiance. Stacks additively.",
accent: "#f1d479",
},
}; };
export function countRunBuff(buffs: readonly RunBuffId[], buffId: RunBuffId) { export function runBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): number {
return buffs.reduce((count, current) => count + Number(current === buffId), 0); return Math.max(0, Math.min(RUN_BUFFS[buffId].maxRank, Math.floor(ranks[buffId] ?? 0)));
} }
export function applyRunBuffsToParty(party: PartyMember[], buffs: readonly RunBuffId[]) { export function effectiveRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId, passiveInfusionId: RunBuffId | null = null): number {
const vitalityMultiplier = 1 + countRunBuff(buffs, "vital-bloom") * 0.12; return Math.min(RUN_BUFFS[buffId].maxRank, runBuffRank(ranks, buffId) + Number(passiveInfusionId === buffId));
return party.map((member) => {
const maxHp = Math.round(member.maxHp * vitalityMultiplier);
return { ...member, maxHp, hp: maxHp };
});
} }
export function runMaxMana(buffs: readonly RunBuffId[]) { export function increaseRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): RunBuffRanks {
return 100 + countRunBuff(buffs, "deep-wells") * 20; const current = runBuffRank(ranks, buffId);
if (current >= RUN_BUFFS[buffId].maxRank) return { ...ranks };
return { ...ranks, [buffId]: current + 1 };
} }
export function runHealingMultiplier(buffs: readonly RunBuffId[]) { export function selectRunBuffDraft(
return 1 + countRunBuff(buffs, "restoring-grace") * 0.15; 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) { export function bossHealthMultiplier(round: number) {
+191 -36
View File
@@ -3,9 +3,25 @@ import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry"; import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store"; import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers"; 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 { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog"; 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", () => { describe("Disc Priest combat simulation", () => {
beforeEach(() => { beforeEach(() => {
@@ -106,15 +122,16 @@ describe("Disc Priest combat simulation", () => {
}); });
it("Purify removes Ember Brand from selected ally", () => { it("Purify removes Ember Brand from selected ally", () => {
useGameStore.getState().tick(2); useGameStore.setState((state) => ({
useGameStore.getState().tick(2); bossMotion: { ...state.bossMotion, mechanicCount: 3, nextMechanicAt: state.time },
useGameStore.getState().tick(1.1); }));
const branded = useGameStore.getState().party.find((member) => member.id === "nia")!; 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); expect(branded.debuffs).toHaveLength(1);
useGameStore.getState().selectMember("nia"); useGameStore.getState().selectMember(branded.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true); 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", () => { 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", () => { it("reduces damage by 30% for party members inside Barrier", () => {
useGameStore.getState().setPlayerPosition([0, -3.7]); useGameStore.getState().setPlayerPosition([0, -3.7]);
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, nextNovaAt: 999, nextBrandAt: 999 }, boss: { ...state.boss },
bossMotion: { ...state.bossMotion, nextChargeAt: 999 },
})); }));
expect(useGameStore.getState().castAbility("barrier")).toBe(true); expect(useGameStore.getState().castAbility("barrier")).toBe(true);
useGameStore.getState().tick(2); useGameStore.getState().tick(2);
@@ -190,40 +206,40 @@ describe("Disc Priest combat simulation", () => {
}); });
it("telegraphs, executes, and recovers from a Bull charge", () => { 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; const telegraph = useGameStore.getState().bossMotion;
expect(telegraph.mode).toBe("telegraph"); expect(telegraph.mode).toBe("telegraph");
expect(telegraph.chargeTargetId).toBe("nia"); const targetId = telegraph.chargeTargetId;
const midpoint: [number, number] = [ const midpoint: [number, number] = [
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2, (telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2, (telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
]; ];
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, nia: midpoint }, partyPositions: { ...state.partyPositions, [targetId]: midpoint },
party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member), 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); 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); useGameStore.getState().tick(0.05);
} }
const hitState = useGameStore.getState(); const hitState = useGameStore.getState();
expect(hitState.bossMotion.chargeHitIds).toContain("nia"); expect(hitState.bossMotion.chargeHitIds).toContain(targetId);
expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1); 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) { for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
useGameStore.getState().tick(0.1); useGameStore.getState().tick(0.1);
} }
const recovered = useGameStore.getState(); const recovered = useGameStore.getState();
expect(recovered.bossMotion.mode).toBe("holding"); 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", () => { it("moves every mobile AI party member out of the charge lane before impact", () => {
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 }, boss: { ...state.boss, nextMeleeAt: 999 },
})); }));
while (useGameStore.getState().bossMotion.mode !== "telegraph") { while (useGameStore.getState().bossMotion.mode !== "telegraph") {
useGameStore.getState().tick(0.1); 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", () => { it("marks a stack target after three charges and splits 200 pounce damage", () => {
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 }, boss: { ...state.boss, nextMeleeAt: 999 },
playerPosition: [0, 0], playerPosition: [0, 0],
partyPositions: { partyPositions: {
aelia: [0, 0], aelia: [0, 0],
@@ -269,18 +285,13 @@ describe("Disc Priest combat simulation", () => {
orin: [0, 0], orin: [0, 0],
vale: [0, 0], vale: [0, 0],
}, },
bossMotion: { bossMotion: { ...state.bossMotion, activeMechanicId: null, mode: "holding", position: [0, -1], mechanicCount: 1, nextMechanicAt: state.time },
...state.bossMotion,
mode: "returning",
position: [0, -1],
chargesSincePounce: 3,
},
})); }));
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp])); const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
useGameStore.getState().tick(0.05); useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossMotion.mode).toBe("stacking"); 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); expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) { 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); useGameStore.getState().tick(0.05);
} }
const impacted = useGameStore.getState(); const impacted = useGameStore.getState();
expect(impacted.bossMotion.mode).toBe("returning"); expect(impacted.bossMotion.mode).toBe("holding");
for (const member of impacted.party) { for (const member of impacted.party) {
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3); expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
} }
@@ -345,7 +356,7 @@ describe("Broodfang encounter", () => {
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 }, boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { bossMotion: {
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time), ...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
nextMechanicAt: 999, nextMechanicAt: 999,
}, },
})); }));
@@ -356,8 +367,8 @@ describe("Broodfang encounter", () => {
useGameStore.getState().tick(1); useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp; const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage); expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage); expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage);
useGameStore.getState().setPlayerPosition([6, 6]); useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(1); 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", () => { describe("Roguelike rounds", () => {
beforeEach(() => { beforeEach(() => {
useGameStore.getState().configureHealer( useGameStore.getState().configureHealer(
@@ -445,23 +579,43 @@ describe("Roguelike rounds", () => {
expect(useGameStore.getState().phase).toBe("intermission"); expect(useGameStore.getState().phase).toBe("intermission");
expect(useGameStore.getState().round).toBe(1); expect(useGameStore.getState().round).toBe(1);
expect(useGameStore.getState().time).toBe(intermissionTime); 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 roundTwo = useGameStore.getState();
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id]; const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
expect(roundTwo.phase).toBe("combat"); expect(roundTwo.phase).toBe("combat");
expect(roundTwo.round).toBe(2); 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(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.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.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", () => { 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); 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", () => { describe("shared arena boundary", () => {
@@ -473,8 +627,8 @@ describe("shared arena boundary", () => {
it("constrains player, party, and boss positions to the same room", () => { it("constrains player, party, and boss positions to the same room", () => {
useGameStore.getState().setPlayerPosition([100, 100]); useGameStore.getState().setPlayerPosition([100, 100]);
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 }, boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 }, bossMotion: { ...state.bossMotion, position: [100, -100] },
partyPositions: { partyPositions: {
...state.partyPositions, ...state.partyPositions,
brann: [50, 50], brann: [50, 50],
@@ -533,6 +687,7 @@ describe("Tempestscale encounter", () => {
partyPositions: { ...state.partyPositions, aelia: [0, 1] }, partyPositions: { ...state.partyPositions, aelia: [0, 1] },
bossMotion: { bossMotion: {
...state.bossMotion, ...state.bossMotion,
activeMechanicId: "storm-breath",
mode: "breath_sweeping", mode: "breath_sweeping",
position: [0, -2.8], position: [0, -2.8],
phaseStartedAt: state.time, phaseStartedAt: state.time,
+169 -61
View File
@@ -15,13 +15,16 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors"; import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { import {
RUN_BUFF_ORDER,
RUN_BUFFS, RUN_BUFFS,
applyRunBuffsToParty,
bossHealthMultiplier, bossHealthMultiplier,
runHealingMultiplier, compileRunModifiers,
runMaxMana, increaseRunBuffRank,
runAbilityCastTime,
runAbilityCooldown,
runAbilityManaCost,
selectRunBuffDraft,
selectRandomBossPair, selectRandomBossPair,
type CompiledRunModifiers,
} from "./roguelike"; } from "./roguelike";
import { createDefaultGearProgress, type GearProgress } from "./progression/gear"; import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects"; import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
@@ -41,6 +44,7 @@ import type {
MemberId, MemberId,
PartyMember, PartyMember,
RunBuffId, RunBuffId,
RunBuffRanks,
RunMode, RunMode,
ScenePulse, ScenePulse,
WorldPosition, WorldPosition,
@@ -68,9 +72,11 @@ export interface GameState {
phase: GamePhase; phase: GamePhase;
runMode: RunMode; runMode: RunMode;
round: number; round: number;
runBuffs: RunBuffId[]; runBuffRanks: RunBuffRanks;
draftBuffIds: RunBuffId[]; draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId; selectedRunBuffId: RunBuffId | null;
passiveRunBuffId: RunBuffId | null;
runModifiers: CompiledRunModifiers;
healingMultiplier: number; healingMultiplier: number;
difficultySlug: DifficultySlug; difficultySlug: DifficultySlug;
difficultyDamageMultiplier: number; difficultyDamageMultiplier: number;
@@ -112,6 +118,7 @@ export interface GameState {
setPauseSelection: (selection: "resume" | "exit") => void; setPauseSelection: (selection: "resume" | "exit") => void;
setSelectedRunBuff: (buffId: RunBuffId) => void; setSelectedRunBuff: (buffId: RunBuffId) => void;
chooseRunBuff: (buffId: RunBuffId) => boolean; chooseRunBuff: (buffId: RunBuffId) => boolean;
continueRoguelikeRound: () => boolean;
} }
const emptyCooldowns = (): Record<AbilityId, number> => ({ const emptyCooldowns = (): Record<AbilityId, number> => ({
@@ -143,7 +150,6 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
motion.chargeEnd[0] += offset; motion.chargeEnd[0] += offset;
motion.pounceCenter[0] += offset; motion.pounceCenter[0] += offset;
const stagger = index * 2.4; const stagger = index * 2.4;
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger; if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
return constrainBossMotion(motion); return constrainBossMotion(motion);
} }
@@ -154,8 +160,6 @@ function createEncounterBoss(bossId: BossId, index: number, count: number, healt
boss.hp = boss.maxHp; boss.hp = boss.maxHp;
const stagger = index * 0.8; const stagger = index * 0.8;
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger; 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) }; 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; 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( function damageMemberAt(
member: PartyMember, member: PartyMember,
amount: number, amount: number,
@@ -201,6 +244,7 @@ function damageMemberAt(
incomingDamageMultiplier = 1, incomingDamageMultiplier = 1,
gearModifiers?: EncounterGearModifiers, gearModifiers?: EncounterGearModifiers,
kind: "direct" | "hazard" = "direct", kind: "direct" | "hazard" = "direct",
shieldDamageTakenMultiplier = 1,
) { ) {
amount *= incomingDamageMultiplier; amount *= incomingDamageMultiplier;
if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1; if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
@@ -211,7 +255,8 @@ function damageMemberAt(
barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0, barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 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( function addLog(
@@ -232,7 +277,7 @@ function initialState(
requestedBossIds: BossId | readonly BossId[] = "bulldrome", requestedBossIds: BossId | readonly BossId[] = "bulldrome",
runMode: RunMode = "encounter", runMode: RunMode = "encounter",
round = 1, round = 1,
runBuffs: RunBuffId[] = [], runBuffRanks: RunBuffRanks = {},
gearProgress: GearProgress = createDefaultGearProgress(), gearProgress: GearProgress = createDefaultGearProgress(),
requestedDifficultySlug: DifficultySlug = "initiate", requestedDifficultySlug: DifficultySlug = "initiate",
) { ) {
@@ -248,11 +293,10 @@ function initialState(
const primary = encounterBosses[0]; const primary = encounterBosses[0];
const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId); const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId);
const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null; const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null;
const effectiveRunBuffs = passiveInfusionId && !runBuffs.includes(passiveInfusionId) const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId);
? [passiveInfusionId, ...runBuffs] const draftBuffIds = runMode === "roguelike" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : [];
: runBuffs; const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
const party = applyGearHealth(applyRunBuffsToParty(freshParty(healerClassId, playerName), effectiveRunBuffs), gearModifiers); const maxMana = 100;
const maxMana = runMaxMana(effectiveRunBuffs);
return { return {
bossId: primary.boss.id, bossId: primary.boss.id,
paused: false, paused: false,
@@ -262,10 +306,12 @@ function initialState(
phase: "briefing" as GamePhase, phase: "briefing" as GamePhase,
runMode, runMode,
round, round,
runBuffs: [...runBuffs], runBuffRanks: { ...runBuffRanks },
draftBuffIds: [...RUN_BUFF_ORDER], draftBuffIds,
selectedRunBuffId: RUN_BUFF_ORDER[0], selectedRunBuffId: draftBuffIds[0] ?? null,
healingMultiplier: runHealingMultiplier(effectiveRunBuffs) * gearModifiers.aelia.healingPower, passiveRunBuffId: passiveInfusionId,
runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug, difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier, difficultyDamageMultiplier: difficulty.damageMultiplier,
gearProgress, gearProgress,
@@ -290,7 +336,7 @@ function initialState(
scenePulse: { id: 0, kind: "mend" as const }, scenePulse: { id: 0, kind: "mend" as const },
playerPosition: [0, 4.5] as [number, number], playerPosition: [0, 4.5] as [number, number],
activeCast: null as ActiveCast | null, 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(), ...initialState(),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => { 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: () => { 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)]; const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({ set({
...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffs, gearProgress, difficultySlug), ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug),
phase: "combat", phase: "combat",
activeTab: "combat", activeTab: "combat",
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }], 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: () => { restart: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get(); 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 }), selectMember: (selectedMemberId) => set({ selectedMemberId }),
@@ -341,18 +387,38 @@ export const useGameStore = create<GameState>((set, get) => ({
chooseRunBuff: (buffId) => { chooseRunBuff: (buffId) => {
const state = get(); const state = get();
if (state.phase !== "intermission" || !state.draftBuffIds.includes(buffId)) return false; 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 round = state.round + 1;
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
const bossIds = selectRandomBossPair(previousBossIds); const bossIds = selectRandomBossPair(previousBossIds);
const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name;
set({ 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", phase: "combat",
activeTab: "combat", activeTab: "combat",
combatLog: [{ combatLog: [{
id: Date.now(), id: Date.now(),
time: 0, 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", tone: "good",
}], }],
}); });
@@ -381,12 +447,13 @@ export const useGameStore = create<GameState>((set, get) => ({
if (state.activeCast) return false; if (state.activeCast) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId]; 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 selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
const selected = state.party[selectedIndex]; const selected = state.party[selectedIndex];
if (state.cooldowns[abilityId] > state.time + 0.01) return false; if (state.cooldowns[abilityId] > state.time + 0.01) return false;
if (state.globalCooldownUntil > state.time + 0.001) 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") }); set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
return false; return false;
} }
@@ -402,9 +469,9 @@ export const useGameStore = create<GameState>((set, get) => ({
abilityId: "mend", abilityId: "mend",
targetId: selected.id, targetId: selected.id,
startedAt: state.time, 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, globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`), 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) { switch (abilityId) {
case "renew": case "renew":
party[selectedIndex] = { applyRenewAt(party, selectedIndex, state.time, state.runModifiers);
...party[selectedIndex], for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
renewExpiresAt: state.time + 8, applyRenewAt(party, index, state.time, state.runModifiers);
renewNextTickAt: state.time + 1, }
};
message = `${ability.name} placed on ${selected.name}.`; message = `${ability.name} placed on ${selected.name}.`;
break; break;
case "shield": case "shield": {
party[selectedIndex] = { const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower, state.runModifiers);
...party[selectedIndex], for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36 * state.gearModifiers.aelia.healingPower), addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
}; }
message = `${selected.name} gains ${Math.round(36 * state.gearModifiers.aelia.healingPower)} absorption.`; message = `${selected.name} gains ${Math.round(amount)} absorption.`;
break; break;
case "purify": }
{ case "purify": {
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name); const cleanseIndexes = [
const primaryDispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames); 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; bossMotion = primaryDispel.motion;
additionalBosses = state.additionalBosses.map((entry) => { additionalBosses = additionalBosses.map((entry) => {
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames); const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
return { ...entry, motion: dispel.motion }; return { ...entry, motion: dispel.motion };
}); });
message = dispelledNames.includes("Widow Venom") party[index] = { ...party[index], debuffs: [] };
? "Widow Venom purged. A venom pool forms where the target stood." if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers);
: `${dispelledNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`; 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; break;
}
case "radiance": case "radiance":
party = party.map((member) => healMember(member, 22 * state.healingMultiplier)); 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.`; message = `${ability.name} heals the full party.`;
break; break;
case "barrier": case "barrier":
barrier = { center: [...state.partyPositions.aelia], expiresAt: state.time + 8 }; barrier = {
message = `${ability.name} protects a 3m circle for 8 seconds.`; 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; break;
} }
const cooldowns = { const cooldowns = {
...state.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 = { const pulse: ScenePulse = {
id: state.scenePulse.id + 1, id: state.scenePulse.id + 1,
@@ -475,7 +567,7 @@ export const useGameStore = create<GameState>((set, get) => ({
barrier, barrier,
bossMotion, bossMotion,
additionalBosses, additionalBosses,
mana: Math.max(0, state.mana - ability.mana), mana: Math.max(0, state.mana - manaCost),
combatLog: addLog(state.combatLog, state.time, message, "good"), combatLog: addLog(state.combatLog, state.time, message, "good"),
scenePulse: pulse, scenePulse: pulse,
}); });
@@ -504,7 +596,7 @@ export const useGameStore = create<GameState>((set, get) => ({
let activeCast = state.activeCast ? { ...state.activeCast } : null; let activeCast = state.activeCast ? { ...state.activeCast } : null;
let partyCombat = state.partyCombat; let partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents; let partyDamageEvents = state.partyDamageEvents;
const barrier = state.barrier; let barrier = { ...state.barrier };
if (activeCast && activeCast.completesAt <= time) { if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId); const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
@@ -512,6 +604,9 @@ export const useGameStore = create<GameState>((set, get) => ({
if (target?.hp > 0) { if (target?.hp > 0) {
const healing = 38 * state.healingMultiplier; const healing = 38 * state.healingMultiplier;
party[targetIndex] = healMember(target, healing); 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; const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good"); combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id }; 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; let tickAt = next.renewNextTickAt;
const lastTickAt = Math.min(time, next.renewExpiresAt); const lastTickAt = Math.min(time, next.renewExpiresAt);
while (tickAt <= lastTickAt + 0.001) { while (tickAt <= lastTickAt + 0.001) {
next = healMember(next, 7 * state.healingMultiplier); next = healMember(next, 7 * state.healingMultiplier * state.runModifiers.renewHealingMultiplier);
tickAt += 1; tickAt += 1;
} }
next = { next = {
@@ -539,7 +634,7 @@ export const useGameStore = create<GameState>((set, get) => ({
.map((debuff) => { .map((debuff) => {
let updated = { ...debuff }; let updated = { ...debuff };
while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) { 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; updated.nextTickAt += 1;
} }
return updated; return updated;
@@ -548,6 +643,17 @@ export const useGameStore = create<GameState>((set, get) => ({
return { ...next, debuffs: activeDebuffs }; 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 = [ const livingMotions = [
...(boss.hp > 0 ? [bossMotion] : []), ...(boss.hp > 0 ? [bossMotion] : []),
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion), ...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
@@ -574,8 +680,7 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions, partyPositions,
time, time,
delta: time - oldTime, 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, state.runModifiers.shieldDamageTakenMultiplier),
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
}); });
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) }; encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
party = mechanicResult.party.map((member) => { party = mechanicResult.party.map((member) => {
@@ -638,6 +743,7 @@ export const useGameStore = create<GameState>((set, get) => ({
activeCast, activeCast,
combatLog, combatLog,
scenePulse: pulse, scenePulse: pulse,
barrier,
}); });
}, },
})); }));
@@ -658,6 +764,7 @@ export type GameSnapshot = Omit<GameState,
| "setPauseSelection" | "setPauseSelection"
| "setSelectedRunBuff" | "setSelectedRunBuff"
| "chooseRunBuff" | "chooseRunBuff"
| "continueRoguelikeRound"
>; >;
export function getGameSnapshot(): GameSnapshot { export function getGameSnapshot(): GameSnapshot {
@@ -677,6 +784,7 @@ export function getGameSnapshot(): GameSnapshot {
setPauseSelection: _setPauseSelection, setPauseSelection: _setPauseSelection,
setSelectedRunBuff: _setSelectedRunBuff, setSelectedRunBuff: _setSelectedRunBuff,
chooseRunBuff: _chooseRunBuff, chooseRunBuff: _chooseRunBuff,
continueRoguelikeRound: _continueRoguelikeRound,
...snapshot ...snapshot
} = useGameStore.getState(); } = useGameStore.getState();
return snapshot; return snapshot;
+55 -33
View File
@@ -28,16 +28,66 @@ export type BossId =
| "moonfang-wolf" | "moonfang-wolf"
| "frostmaw-yeti" | "frostmaw-yeti"
| "rimeclaw-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 GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
export type RunMode = "encounter" | "roguelike"; 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 BottomTab = "combat" | "map" | "pack";
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash"; export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
export type BossMotionMode = export type BossMotionMode =
| "holding" | "holding"
| "telegraph" | "telegraph"
| "charging" | "charging"
| "returning"
| "stacking" | "stacking"
| "pouncing" | "pouncing"
| "tethering" | "tethering"
@@ -45,35 +95,12 @@ export type BossMotionMode =
| "breath_telegraph" | "breath_telegraph"
| "breath_sweeping" | "breath_sweeping"
| "skyfall" | "skyfall"
| "mantis_sidestep"
| "mantis_line_telegraph" | "mantis_line_telegraph"
| "mantis_cross_telegraph" | "mantis_cross_telegraph"
| "mantis_recover"
| "ram_charge_telegraph"
| "ram_charging"
| "ram_quake"
| "ram_shatter"
| "ram_recover"
| "cinderback_curl"
| "cinderback_ricochet"
| "cinderback_slam" | "cinderback_slam"
| "cinderback_recover"
| "sandglass_burrow_telegraph"
| "sandglass_burrowing"
| "sandglass_eruption"
| "sandglass_hourglass" | "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_shockwave"
| "golem_crownfall" | "golem_crownfall";
| "golem_recover";
export type WorldPosition = [number, number]; export type WorldPosition = [number, number];
export type CircleHazardKind = export type CircleHazardKind =
@@ -194,13 +221,11 @@ export interface BossState {
maxHp: number; maxHp: number;
hp: number; hp: number;
nextMeleeAt: number; nextMeleeAt: number;
nextNovaAt: number;
nextBrandAt: number;
brandCount: number;
} }
export interface BossMotionState { export interface BossMotionState {
bossId: BossId; bossId: BossId;
activeMechanicId: BossMechanicId | null;
formationOffsetX: number; formationOffsetX: number;
mode: BossMotionMode; mode: BossMotionMode;
position: WorldPosition; position: WorldPosition;
@@ -209,12 +234,9 @@ export interface BossMotionState {
chargeTargetId: MemberId; chargeTargetId: MemberId;
chargeHitIds: MemberId[]; chargeHitIds: MemberId[];
phaseEndsAt: number; phaseEndsAt: number;
nextChargeAt: number;
chargeCount: number; chargeCount: number;
chargesSincePounce: number;
pounceTargetId: MemberId; pounceTargetId: MemberId;
pounceCenter: WorldPosition; pounceCenter: WorldPosition;
pounceCount: number;
nextMechanicAt: number; nextMechanicAt: number;
mechanicCount: number; mechanicCount: number;
phaseStartedAt: number; phaseStartedAt: number;
@@ -227,7 +249,6 @@ export interface BossMotionState {
breathEndAngle: number; breathEndAngle: number;
hazards: CircleHazard[]; hazards: CircleHazard[];
slashLanes: SlashLane[]; slashLanes: SlashLane[];
nextPoolMechanicAt: number;
poolMechanicCount: number; poolMechanicCount: number;
poolTelegraphs: PoolTelegraph[]; poolTelegraphs: PoolTelegraph[];
} }
@@ -257,6 +278,7 @@ export interface ActiveCast {
export interface BarrierState { export interface BarrierState {
center: WorldPosition; center: WorldPosition;
expiresAt: number; expiresAt: number;
nextHealAt: number;
} }
export interface ScenePulse { export interface ScenePulse {
+10 -3
View File
@@ -6,7 +6,8 @@ import type { AbilityId } from "./types";
function cycleRunBuff(direction: 1 | -1) { function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState(); 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; const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length;
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); 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 (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1);
if (key === "arrowright" || key === "arrowdown") 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?.(); if (key === "escape") exitRef.current?.();
return; return;
} }
@@ -97,7 +101,10 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (store.phase === "intermission") { if (store.phase === "intermission") {
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1);
if (["Button13", "Button15", "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?.(); if (!repeat && token === "Button1") exitRef.current?.();
return; return;
} }
+6
View File
@@ -122,6 +122,8 @@ export function BottomDisplayApp() {
selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }), selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }),
selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }), selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }),
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }), selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }),
selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }),
upgradeSelectedGear: () => { upgradeSelectedGear: () => {
postFrontend({ name: "upgradeSelectedGear" }); postFrontend({ name: "upgradeSelectedGear" });
return false; return false;
@@ -155,6 +157,10 @@ export function BottomDisplayApp() {
postCommand({ name: "chooseRunBuff", buffId }); postCommand({ name: "chooseRunBuff", buffId });
return false; return false;
}, },
continueRoguelikeRound: () => {
postCommand({ name: "continueRoguelikeRound" });
return false;
},
}); });
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") { if (event.data.type === "authoritative-ready") {
+36 -6
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; 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 { function snapshot(): BottomGameSnapshot {
return { return {
@@ -8,16 +10,24 @@ function snapshot(): BottomGameSnapshot {
healerClassId: "priest", healerClassId: "priest",
phase: "combat", phase: "combat",
round: 1, 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, time: 10,
party: [], 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: [], additionalBosses: [],
partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] }, partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] },
bossMotion: { bossMotion: {
bossId: "bulldrome", formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0, bossId: "bulldrome", activeMechanicId: null, 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, chargeCount: 0, pounceTargetId: "aelia", pounceCenter: [0, 0],
nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 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: { partyCombat: {
combatants: { combatants: {
@@ -39,7 +49,7 @@ function snapshot(): BottomGameSnapshot {
inventory: [], inventory: [],
playerPosition: [0, 0], playerPosition: [0, 0],
activeCast: null, 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 }; const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 };
expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ 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: "setPaused"; paused: boolean }
| { name: "setPauseSelection"; selection: "resume" | "exit" } | { name: "setPauseSelection"; selection: "resume" | "exit" }
| { name: "setSelectedRunBuff"; buffId: RunBuffId } | { name: "setSelectedRunBuff"; buffId: RunBuffId }
| { name: "chooseRunBuff"; buffId: RunBuffId }; | { name: "chooseRunBuff"; buffId: RunBuffId }
| { name: "continueRoguelikeRound" };
export type FrontendCommand = export type FrontendCommand =
| { name: "signIn"; username: string; password: string } | { name: "signIn"; username: string; password: string }
@@ -44,6 +45,8 @@ export type FrontendCommand =
| { name: "selectGearSlot"; slotId: GearSlotId } | { name: "selectGearSlot"; slotId: GearSlotId }
| { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" } | { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" }
| { name: "selectInfusion"; infusionId: string } | { name: "selectInfusion"; infusionId: string }
| { name: "selectPassiveAbility"; abilityId: AbilityId }
| { name: "selectPassiveInfusion"; passiveId: RunBuffId }
| { name: "upgradeSelectedGear" } | { name: "upgradeSelectedGear" }
| { name: "equipSelectedInfusion" } | { name: "equipSelectedInfusion" }
| { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "equipPassiveInfusion"; passiveId: RunBuffId }
@@ -82,6 +85,7 @@ export function executeGameCommand(command: GameCommand) {
case "setPauseSelection": game.setPauseSelection(command.selection); break; case "setPauseSelection": game.setPauseSelection(command.selection); break;
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break; case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
case "chooseRunBuff": game.chooseRunBuff(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 "selectGearSlot": frontend.selectGearSlot(command.slotId); break;
case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break; case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break;
case "selectInfusion": frontend.selectInfusion(command.infusionId); 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 "upgradeSelectedGear": frontend.upgradeSelectedGear(); break;
case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break; case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break;
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
@@ -130,6 +136,7 @@ export type BottomGameSnapshot = Pick<GameState,
| "healerClassId" | "healerClassId"
| "phase" | "phase"
| "round" | "round"
| "runModifiers"
| "time" | "time"
| "party" | "party"
| "boss" | "boss"
@@ -151,7 +158,7 @@ export type BottomGameSnapshot = Pick<GameState,
>; >;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ 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", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
]; ];
@@ -181,6 +188,7 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
healerClassId: state.healerClassId, healerClassId: state.healerClassId,
phase: state.phase, phase: state.phase,
round: state.round, round: state.round,
runModifiers: state.runModifiers,
time: state.time, time: state.time,
party: state.party, party: state.party,
boss: state.boss, 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 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-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 { 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 { .buff-choice-grid button {
min-width: 0; min-width: 0;
display: grid; 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 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 > 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 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 { 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 b { color: #dce8e3; }
.buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); } .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 > 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 { 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-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 { 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 > 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; } .boss-choice > span { display: grid; min-width: 0; }
@@ -2058,7 +2072,15 @@ button:focus-visible {
.mode-hero p { font-size: 7px; } .mode-hero p { font-size: 7px; }
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; } .mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; } .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 { min-height: 11px; }
.boss-picker-heading > span { font-size: 4px; } .boss-picker-heading > span { font-size: 4px; }
.boss-picker-heading button { min-width: 42px; padding: 1px 3px; 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 { 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 { 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-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-owner-list,
.gear-slot-list { display: grid; align-content: start; gap: 5px; } .gear-slot-list { display: grid; align-content: start; gap: 5px; }
.gear-owner-list button, .gear-owner-list button,
@@ -2132,23 +2154,27 @@ button:focus-visible {
.gear-infusion-options, .gear-infusion-options,
.gear-passive-options { display: grid; gap: 4px; margin-top: 9px; } .gear-passive-options { display: grid; gap: 4px; margin-top: 9px; }
.gear-infusion-options button, .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-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-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-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-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-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-selected { border-color: var(--gold); background: rgba(72,58,21,.16); }
.gear-infusion-options button.is-equipped, .gear-infusion-options button.is-equipped,
.gear-passive-options button.is-equipped { box-shadow: inset 3px 0 #67c89e; } .gear-passive-choice-list button.is-equipped { box-shadow: inset 3px 0 #67c89e; }
.gear-passive-options { grid-template-columns: repeat(3, minmax(0, 1fr)); } .gear-passive-options > span { color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.gear-passive-options > span { grid-column: 1 / -1; 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-options button { min-width: 0; grid-template-columns: 18px 1fr 10px; } .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 { padding: 0 5.5% 18px; }
.gear-context .context-header { margin: 0 -5.8%; } .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:disabled { border-color: #42554e; color: #71827c; background: #13201c; }
.gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; } .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-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 { 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; } .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; } .difficulty-picker > span { font-size: 4px; }
.mode-dungeons .mode-hero { display: none; } .mode-dungeons .mode-hero { display: none; }
.mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; } .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 { 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 > i { width: 14px; height: 14px; font-size: 6px; }
.mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; } .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-infusion-options,
.gear-passive-options { gap: 2px; margin-top: 3px; } .gear-passive-options { gap: 2px; margin-top: 3px; }
.gear-infusion-options button, .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-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-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-infusion-options button small,
.gear-passive-options button small, .gear-passive-choice-list button small,
.gear-passive-options > span { font-size: 3px; } .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; }
} }