diff --git a/AGENTS.md b/AGENTS.md index 611c4a9..a342886 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,17 +96,24 @@ Stable locked 60 FPS is a release requirement, not a best-effort goal. - Verify the changed code and its direct dependencies. Do not run tests, visual QA, or controller checks for unrelated screens or systems by default. - For a screen change, check that screen's applicable display layouts, browser fallback, and controller path only. - For a reusable mechanic or domain change, run its focused tests plus direct consumers affected by the change. +- Run viewport, display-layout, and browser-fallback testing only when a change adds or modifies a screen, menu, modal, HUD, overlay, or other UI/layout behavior. +- Combat rules, encounter mechanics, world-space telegraphs, animations, VFX, tuning, and other gameplay-only changes do not require viewport testing unless they also change UI or screen layout. - Expand to broader regression or full-suite verification only for shared foundations, cross-cutting changes, risky refactors, release validation, or when explicitly requested. - State what was verified and any deliberately unverified scope in the handoff. ## Definition of done -A screen or mechanic is not complete until: +A screen or UI change is not complete until: 1. Its main-display UI is designed and verified at the approximately `960 x 540` Android CSS/layout viewport, with rendering validated against the `1920 x 1080` physical panel. 2. Its applicable secondary UI is designed and verified at the approximately `620 x 540` Android CSS/layout viewport, with rendering validated against the `1240 x 1080` physical panel. 3. It has a complete single-display browser fallback. 4. Every action is reachable and understandable using only a controller, with no click-to-focus step. -5. Shared game logic is modular, typed, and testable outside the renderer/UI. -6. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation. -7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception. + +Every code change is not complete until: + +1. Shared game logic is modular, typed, and testable outside the renderer/UI. +2. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation. +3. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception. + +Gameplay-only mechanic and domain changes do not inherit the screen/UI viewport requirements above. diff --git a/package.json b/package.json index 08aaeda..b1a3e86 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index 3ef14a7..355d599 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -2,6 +2,7 @@ import { ABILITY_ORDER } from "../game/data"; import { HEALER_CLASSES } from "../game/healers"; import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store"; +import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike"; import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat"; import type { BottomTab, PartyMember } from "../game/types"; import { useFrontendStore } from "../frontend/store"; @@ -85,12 +86,16 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!); const activeCast = useGameStore((state) => state.activeCast); const castAbility = useGameStore((state) => state.castAbility); + const runModifiers = useGameStore((state) => state.runModifiers); const remaining = abilityRemaining(abilityId, time, cooldowns); + const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers); + const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0; + const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers); const globalRemaining = Math.max(0, globalCooldownUntil - time); const noDispel = abilityId === "purify" && selected.debuffs.length === 0; const invalidTarget = ability.targeting === "ally" && selected.hp <= 0; - const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget; - const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`; + const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget; + const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`; return ( ); - })} + }) : ( + + )} - + ); } diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index 2b735cb..a9a2061 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -5,9 +5,11 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store"; import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers"; -import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog"; -import { selectRandomBossPair } from "../game/roguelike"; -import type { BossId } from "../game/types"; +import { ABILITY_ORDER } from "../game/data"; +import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog"; +import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool"; +import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike"; +import type { AbilityId, BossId } from "../game/types"; import { GEAR_OWNER_LABELS, GEAR_OWNER_ORDER, @@ -483,10 +485,14 @@ function GearScreen() { const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId); const workshopMode = useFrontendStore((state) => state.gearWorkshopMode); const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId); + const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId); + const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId); const selectOwner = useFrontendStore((state) => state.selectGearOwner); const selectSlot = useFrontendStore((state) => state.selectGearSlot); const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode); const selectInfusion = useFrontendStore((state) => state.selectInfusion); + const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility); + const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion); const upgrade = useFrontendStore((state) => state.upgradeSelectedGear); const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion); const installPassive = useFrontendStore((state) => state.equipPassiveInfusion); @@ -503,6 +509,10 @@ function GearScreen() { const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts)); const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress)); const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman"; + const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest"; + const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === selectedPassiveAbilityId); + const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId]; + const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities; const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`; const actions = useMemo(() => [ ...GEAR_OWNER_ORDER.map((ownerId, index) => ({ @@ -518,7 +528,7 @@ function GearScreen() { id: `slot-${slotId}`, run: () => selectSlot(slotId), neighbors: { - up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`, + up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`, down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`, left: `owner-${selectedOwnerId}`, right: previewEntryId, @@ -531,25 +541,42 @@ function GearScreen() { run: () => selectInfusion(infusion.id), neighbors: { up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`, - down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`, + down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`, left: `slot-${selectedSlotId}`, + right: index === infusionChoices.length - 1 ? "install-infusion" : undefined, }, })), - ...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({ + ...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({ + id: `passive-ability-${abilityId}`, + run: () => selectPassiveAbility(abilityId), + neighbors: { + up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`, + down: index < 3 + ? `passive-ability-${ABILITY_ORDER[index + 3]}` + : `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`, + left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`, + right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined, + }, + })) : []), + ...(healerOwner ? passiveChoices.map((passive, index) => ({ id: `passive-${passive.id}`, - run: () => installPassive(passive.id), + run: () => { + selectPassiveInfusion(passive.id); + installPassive(passive.id); + }, enabled: passiveUnlocked, neighbors: { - up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`, - down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`, + up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`, + down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`, left: `slot-${selectedSlotId}`, }, })) : []), { id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } }, - { id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } }, - { id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } }, - ], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]); + { id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } }, + { id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } }, + ], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]); const controller = useMenuController(actions, { onBack: () => navigate("home") }); + const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-"); if (!hunter || !slot) return null; const currentBonus = gearBonusText(recipe.statId, slot.level); const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1)); @@ -585,7 +612,25 @@ function GearScreen() {
{infusionChoices.map((infusion) => selectInfusion(infusion.id)}>{infusion.icon}{infusion.name}{infusion.description}{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""})}
- {healerOwner &&
Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}{PASSIVE_INFUSIONS.map((passive) => installPassive(passive.id)}>{passive.icon}{passive.name}{passive.summary}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""})}
} + {healerOwner &&
+ Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL} +
+ {ABILITY_ORDER.map((abilityId) => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName})} +
+
+ {passiveChoices.map((passive) => selectPassiveInfusion(passive.id)} + onPointerEnter={() => selectPassiveInfusion(passive.id)} + onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }} + >{passive.icon}{healerAbilities[passive.abilityId].shortName}: {passive.name}{formatRunBuffEffect(passive.id, 1)}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""})} +
+
} } @@ -593,15 +638,15 @@ function GearScreen() { } bottom={ -
{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS
+
{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS
- {workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`} - {(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { + {workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`} + {passiveContext ?
{passiveUnlocked ? "✓" : "×"}{formatRunBuffEffect(selectedPassive.id, 1)}{selectedPassive.detail}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}
: (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0; return
= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}>{owned >= cost.quantity ? "✓" : "×"}{cost.itemName}{owned} owned · {cost.quantity} needed{owned}/{cost.quantity}
; }) :
Maximum rank reachedNo more materials required.+{MAX_GEAR_LEVEL}
}
- {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!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"}} + {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : passiveContext ?
{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}Applies at rank 1 next encounter.
: {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!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"}}
{notice || "Gear changes save locally and apply when next encounter starts."}
} @@ -664,8 +709,6 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const selectDifficulty = useFrontendStore((state) => state.selectDifficulty); const navigate = useFrontendStore((state) => state.navigate); const [message, setMessage] = useState(""); - const bossPageSize = 12; - const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize))); const mode = MODE_COPY[modeId]; const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest; const progress = hunter?.healers[hunter.activeClassId]; @@ -673,14 +716,11 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug]; const isPve = modeId === "roguelike-pve"; const isDungeon = modeId === "dungeons"; - const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize); - const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize); - const bossGridRows = Math.ceil(visibleBossIds.length / 3); - const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows); - const changeBossPage = (nextPage: number) => { - const page = Math.max(0, Math.min(bossPageCount - 1, nextPage)); - setBossPage(page); - selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]); + const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId]; + const visibleBossIds = selectedBossGroup.bossIds; + const bossGridColumns = Math.min(2, visibleBossIds.length); + const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => { + selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]); }; const launch = () => { if (isPve) return onLaunch(selectRandomBossPair(), "initiate"); @@ -688,31 +728,41 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi setMessage("Online matchmaking connects here when game server is configured."); }; const actions = useMemo(() => [ - ...(isDungeon ? visibleBossIds.map((bossId, index) => { - const column = Math.floor(index / bossGridRows); - const row = index % bossGridRows; - const neighborInColumn = (targetColumn: number) => { - const columnStart = targetColumn * bossGridRows; - if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined; - const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1; - return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`; + ...(isDungeon ? BOSS_GROUPS.map((group, index) => { + const groupColumns = 5; + const row = Math.floor(index / groupColumns); + const column = index % groupColumns; + const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn]; + + return { + id: `boss-group-${group.id}`, + run: () => selectBossGroup(group.id), + neighbors: { + up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back", + down: groupAt(row + 1, column) + ? `boss-group-${groupAt(row + 1, column)?.id}` + : group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined, + left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined, + right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined, + }, }; + }) : []), + ...(isDungeon ? visibleBossIds.map((bossId, index) => { + const row = Math.floor(index / bossGridColumns); + const column = index % bossGridColumns; + const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn]; return { id: `boss-${bossId}`, run: () => selectBoss(bossId), neighbors: { - up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back", - down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length) - ? `boss-${visibleBossIds[index + 1]}` - : `difficulty-${DIFFICULTIES[0].slug}`, - left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined), - right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined), + up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`, + down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`, + left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined, + right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined, }, }; }) : []), - ...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []), - ...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []), ...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({ id: `difficulty-${difficulty.slug}`, run: () => selectDifficulty(difficulty.slug), @@ -724,15 +774,15 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi }, })) : []), { id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } }, - { id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } }, - ], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]); + { id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } }, + ], [bossGridColumns, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]); const controller = useMenuController(actions, { onBack: () => navigate("home") }); const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking"; const contextRules = isDungeon ? [ [selectedBoss.name, selectedBoss.summary], - [selectedBoss.mechanics[0], selectedBoss.briefing], - [selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."], + [bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing], + [bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."], ] : isPve ? [ @@ -753,17 +803,26 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi {!isDungeon &&
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
} {isDungeon && (
-
- Choose encounter · Page {bossPage + 1}/{bossPageCount} -
- changeBossPage(bossPage - 1)}>◀ Previous - changeBossPage(bossPage + 1)}>Next ▶ -
+
Choose a mechanic group
+
+ {BOSS_GROUPS.map((group) => ( + selectBossGroup(group.id)} + > + {group.letter}Group {group.letter}{group.name} + + ))}
-
+
Group {selectedBossGroup.letter} · {selectedBossGroup.name}{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians
+
{visibleBossIds.map((bossId) => { const boss = BOSS_DEFINITIONS[bossId]; - const group = BOSS_GROUP_BY_ID[boss.groupId]; return ( selectBoss(bossId)} > - {boss.icon}{boss.name}Group {group.letter} · {group.name} · {boss.mechanics[0]}{selectedBossId === bossId ? "✓" : ""} + {boss.icon}{boss.name}{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}{selectedBossId === bossId ? "✓" : ""} ); })} diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 6d4fe18..49ea4cf 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -6,6 +6,7 @@ import { getControllerMovement } from "../input/controller"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { ARENA_CENTER, clampToArena } from "../game/arena"; import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog"; +import { bossAnimationCue } from "../game/bosses/mechanicPool"; import { isActorAnimationOneShot, shouldStartActorAnimation, @@ -598,7 +599,9 @@ function BossFallback({ bossIndex }: { bossIndex: number }) { function BullBoss({ bossIndex }: { bossIndex: number }) { const phase = useGameStore((state) => state.phase); - const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding"); + const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); + const motionMode = motion?.mode ?? "holding"; + const animationCue = motion ? bossAnimationCue(motion) : "idle"; const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(null); @@ -618,15 +621,13 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { const clipName = phase === "victory" || defeated ? "Death" - : motionMode === "telegraph" + : animationCue === "attack" ? "Idle_Headlow" - : motionMode === "pouncing" + : animationCue === "special" ? "Gallop_Jump" - : motionMode === "charging" || motionMode === "returning" + : animationCue === "move" ? "Gallop" - : motionMode === "stacking" - ? "Idle_Headlow" - : "Idle"; + : "Idle"; useEffect(() => { const next = actions[clipName]; @@ -661,9 +662,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") { facingX = motion.chargeEnd[0] - motion.chargeStart[0]; facingZ = motion.chargeEnd[1] - motion.chargeStart[1]; - } else if (motion.mode === "returning") { - facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0]; - facingZ = ARENA_CENTER[1] - motion.position[1]; } if (Math.hypot(facingX, facingZ) > 0.01) { const targetAngle = Math.atan2(facingX, facingZ); @@ -745,7 +743,7 @@ const ALTERNATE_BOSS_CONFIG: Record = { }, "crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, - scale: 1.04, + scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", @@ -820,84 +818,17 @@ const ALTERNATE_BOSS_CONFIG: Record = { }, }; -const PROTOTYPE_MOVE_MODES = [ - "skyfall", - "mantis_sidestep", - "ram_charging", - "cinderback_ricochet", - "charging", - "returning", - "sandglass_burrowing", - "crab_scuttling", -] as const; - -const PROTOTYPE_ATTACK_MODES = [ - "tethering", - "venom_cast", - "breath_telegraph", - "breath_sweeping", - "mantis_line_telegraph", - "mantis_cross_telegraph", - "ram_charge_telegraph", - "ram_quake", - "ram_shatter", - "cinderback_curl", - "cinderback_slam", - "ghost_soul_cross", - "ghost_soul_cross_followup", - "ghost_haunting", - "golem_shockwave", - "golem_crownfall", - "telegraph", - "stacking", - "pouncing", - "sandglass_burrow_telegraph", - "sandglass_eruption", - "sandglass_hourglass", - "crab_scuttle_telegraph", - "crab_tidal_burst", -] as const; - -function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType["bossMotion"]["mode"]) { +function alternateBossClip(kind: AlternateBossKind, motion: ReturnType["bossMotion"]) { const config = ALTERNATE_BOSS_CONFIG[kind]; - if (config.prototype) { - if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move; - if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack; - return config.idle; - } - if (kind === "sandglass-scorpion") { - if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move; - if (motionMode === "sandglass_eruption") return config.attack; - if (motionMode === "sandglass_hourglass") return config.special; - if (motionMode === "sandglass_recover") return "Stagger"; - } - if (kind === "cragclaw-crab") { - if (motionMode === "crab_scuttling") return config.move; - if (motionMode === "crab_scuttle_telegraph") return config.attack; - if (motionMode === "crab_tidal_burst") return config.special; - } - if (kind === "mournveil-ghost") { - if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack; - if (motionMode === "ghost_haunting") return config.special; - } - if (kind === "crownshard-golem") { - if (motionMode === "golem_shockwave") return config.attack; - if (motionMode === "golem_crownfall") return config.special; - } - if (kind === "crystal-bat-matriarch") { - if (motionMode === "golem_shockwave") return config.attack; - if (motionMode === "golem_crownfall") return config.special; - if (motionMode === "golem_recover") return "Stagger"; - } - if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack; - return config.idle; + return config[bossAnimationCue(motion)]; } function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) { const config = ALTERNATE_BOSS_CONFIG[kind]; const archetype = BOSS_ARCHETYPE_BY_ID[kind]; const phase = useGameStore((state) => state.phase); - const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding"); + const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); + const motionMode = motion?.mode ?? "holding"; const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(null); @@ -915,7 +846,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex }); }, [kind, model]); - const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode); + const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion); useEffect(() => { const next = actions[clipName]; @@ -944,7 +875,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex if (!current) return; const motion = current.motion; const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall"; - const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing"; + const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging"; const floatingHeight = config.floating ? 0.2 : 0.03; targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); @@ -955,14 +886,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex ); if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) { targetAngle = motion.breathAngle; - } else if (archetype === "duelist" && ( - motion.mode === "mantis_sidestep" - || motion.mode === "mantis_line_telegraph" + } else if ( + motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph" - )) { + ) { const target = state.partyPositions[motion.chargeTargetId]; targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]); - } else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) { + } else if (motion.mode === "telegraph" || motion.mode === "charging") { targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]); } const difference = Math.atan2( diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx index e2f9ca0..caa2e26 100644 --- a/src/components/boss/BossMechanicIndicators.tsx +++ b/src/components/boss/BossMechanicIndicators.tsx @@ -1,18 +1,17 @@ import { useFrame } from "@react-three/fiber"; -import { useRef, type ComponentType } from "react"; +import { Html } from "@react-three/drei"; +import { useLayoutEffect, useRef, type ComponentType } from "react"; import * as THREE from "three"; -import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics"; -import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool"; -import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper"; +import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool"; import { useGameStore } from "../../game/store"; -import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types"; +import type { BossMotionMode, MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types"; const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const; const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2); const EMPTY_HAZARDS: never[] = []; const EMPTY_SLASH_LANES: never[] = []; const EMPTY_POOL_TELEGRAPHS: never[] = []; -const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]); +const ACTIVE_LANE_MODES = new Set(["charging"]); const DANGER_WARNING_COLOR = "#ff3b30"; const DANGER_ACTIVE_COLOR = "#d4142a"; const DANGER_HIGHLIGHT_COLOR = "#ff8a80"; @@ -290,7 +289,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number } return <>{hazards.map((hazard) => )}; } -/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */ +/** Tall gold ward plus a lightweight spectral pursuer for the shared Soul Siphon mechanic. */ export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) { const phase = useGameStore((state) => state.phase); const motion = useGameStore((state) => motionAt(state, bossIndex)); @@ -392,12 +391,49 @@ function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySym ); } -function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) { +const MEMORY_SYMBOL_GLYPHS: Record = { + triangle: "△", + cross: "+", + circle: "○", + square: "◇", +}; + +function MemoryTileIndicator({ tile, inputActive, completed }: { tile: MemoryTile; inputActive: boolean; completed: boolean }) { const color = MEMORY_SYMBOLS[tile.symbol].color; const halfSize = MEMORY_SEQUENCE.tileSize * 0.5; const gridOffsets = [-0.48, 0, 0.48] as const; + const group = useRef(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 ( - + @@ -440,21 +476,60 @@ function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph: ); const flashSymbol = telegraph.sequence[flashIndex]; const source = bossPosition ?? telegraph.center; + const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0; return ( <> - {telegraph.tiles.map((tile) => )} + {telegraph.tiles.map((tile) => { + const sequenceIndex = telegraph.sequence!.indexOf(tile.symbol); + return ( + = 0 && sequenceIndex < completedCount} + /> + ); + })} {showingSequence && ( - - - - - - - - - - - + +
+
+ {MEMORY_SYMBOL_GLYPHS[flashSymbol]} +
+
+ {telegraph.sequence.map((_, index) => ( + + ))} +
+
+ )} ); diff --git a/src/frontend/saveRepository.test.ts b/src/frontend/saveRepository.test.ts index 7d45310..2bc5730 100644 --- a/src/frontend/saveRepository.test.ts +++ b/src/frontend/saveRepository.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { SaveRepository, type StorageAdapter } from "./saveRepository"; import { groupDrop } from "../game/progression/loot"; +import { RUN_BUFF_ORDER } from "../game/roguelike"; function memoryStorage(): StorageAdapter { const data = new Map(); @@ -169,16 +170,29 @@ describe("SaveRepository", () => { const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Infused"); created.gearProgress.priest.infusionAbilityId = "priest-sanctuary"; - created.gearProgress.priest.passiveInfusionId = "restoring-grace"; + created.gearProgress.priest.passiveInfusionId = "restoring-grace" as never; + created.gearProgress.druid.passiveInfusionId = "mend-echo"; created.gearProgress.brann.infusionAbilityId = "removed-infusion"; - created.gearProgress.brann.passiveInfusionId = "deep-wells"; + created.gearProgress.brann.passiveInfusionId = "deep-wells" as never; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); const migrated = repository.list(null)[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); - expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace"); + expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); + expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo"); expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull(); expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull(); }); + + it("persists every new roguelike buff as a healer passive infusion", () => { + for (const passiveId of RUN_BUFF_ORDER) { + const storage = memoryStorage(); + const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); + const created = repository.create(1, "Infused"); + created.gearProgress.priest.passiveInfusionId = passiveId; + storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); + expect(repository.list(null)[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId); + } + }); }); diff --git a/src/frontend/store.ts b/src/frontend/store.ts index 11a237c..c796a70 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -3,14 +3,14 @@ import { DEFAULT_SETTINGS, normalizeHunterName } from "./data"; import { SaveRepository } from "./saveRepository"; import { AccountRepository, type AccountResult } from "./accountRepository"; import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types"; -import type { BossId, HealerClassId, InventoryItem } from "../game/types"; +import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types"; +import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike"; import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear"; import { equipActiveInfusion, equipPassiveInfusion, infusionsForOwner, } from "../game/progression/infusions"; -import type { RunBuffId } from "../game/types"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; const repository = new SaveRepository(); @@ -59,6 +59,8 @@ export interface FrontendState { selectedGearSlotId: GearSlotId; gearWorkshopMode: "upgrade" | "infusion"; selectedInfusionId: string; + selectedPassiveAbilityId: AbilityId; + selectedPassiveInfusionId: RunBuffId; recentRewards: BossRewardAward[]; settings: GameSettings; notice: string; @@ -81,6 +83,8 @@ export interface FrontendState { selectGearSlot: (slotId: GearSlotId) => void; selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void; selectInfusion: (infusionId: string) => void; + selectPassiveAbility: (abilityId: AbilityId) => void; + selectPassiveInfusion: (passiveId: RunBuffId) => void; upgradeSelectedGear: () => boolean; equipSelectedInfusion: () => boolean; equipPassiveInfusion: (passiveId: RunBuffId) => boolean; @@ -110,6 +114,8 @@ export const useFrontendStore = create((set, get) => ({ selectedGearSlotId: "weapon", gearWorkshopMode: "upgrade", selectedInfusionId: infusionsForOwner("priest")[0].id, + selectedPassiveAbilityId: "mend", + selectedPassiveInfusionId: "mend-echo", recentRewards: [], settings: loadSettings(), notice: "", @@ -187,6 +193,15 @@ export const useFrontendStore = create((set, get) => ({ selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }), selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }), selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }), + selectPassiveAbility: (selectedPassiveAbilityId) => { + const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo"; + set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" }); + }, + selectPassiveInfusion: (selectedPassiveInfusionId) => set({ + selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId, + selectedPassiveInfusionId, + notice: "", + }), upgradeSelectedGear: () => { const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get(); if (!activeSlotId) return false; @@ -326,6 +341,8 @@ export type FrontendSnapshot = Omit { it("derives the available roster from every catalog definition", () => { @@ -42,4 +43,21 @@ describe("boss catalog", () => { expect(state.hp).toBe(state.maxHp); expect(motion.bossId).toBe(bossId); }); + + it("resolves every boss loadout through the canonical mechanic registry", () => { + const assigned = new Set(Object.values(BOSS_DEFINITIONS).flatMap((boss) => boss.mechanicIds)); + expect(assigned).toEqual(new Set(Object.keys(BOSS_MECHANIC_REGISTRY))); + expect(Object.keys(BOSS_MECHANIC_REGISTRY)).toEqual(BOSS_MECHANIC_POOL.map((mechanic) => mechanic.id)); + for (const boss of Object.values(BOSS_DEFINITIONS)) { + expect(boss.mechanicIds).toContain("basic-melee"); + for (const mechanicId of boss.mechanicIds) { + expect(BOSS_MECHANIC_REGISTRY[mechanicId].name).toBe(bossMechanicName(mechanicId)); + } + } + }); + + it("gives every boss a distinct mechanic kit", () => { + const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(",")); + expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length); + }); }); diff --git a/src/game/bossCatalog.ts b/src/game/bossCatalog.ts index ef29677..05cdb7d 100644 --- a/src/game/bossCatalog.ts +++ b/src/game/bossCatalog.ts @@ -1,4 +1,5 @@ -import type { BossId } from "./types"; +import { bossMechanicName } from "./bosses/mechanicPool"; +import type { BossId, BossMechanicId } from "./types"; export type BossArchetype = | "bull" @@ -46,7 +47,7 @@ export interface BossDefinition { failure: string; mapTitle: string; mapCopy: string; - mechanics: readonly [string, string, ...string[]]; + mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]]; maxHp: number; archetype: BossArchetype; groupId: BossGroupId; @@ -58,16 +59,20 @@ interface BossSeed { icon: string; accent: string; summary: string; - mechanics: readonly [string, string, ...string[]]; + mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]]; maxHp: number; archetype: BossArchetype; } function boss(id: BossId, index: number, seed: BossSeed): BossDefinition { - const [first, second] = seed.mechanics; + const [firstId, secondId] = seed.mechanicIds; + const first = bossMechanicName(firstId); + const second = bossMechanicName(secondId); + const mechanicIds = [...seed.mechanicIds, "basic-melee"] as BossDefinition["mechanicIds"]; return { id, ...seed, + mechanicIds, groupId: BOSS_GROUP_BY_BOSS_ID[id], trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`, briefing: `Read ${first}, then preserve open ground for ${second}.`, @@ -80,111 +85,111 @@ function boss(id: BossId, index: number, seed: BossSeed): BossDefinition { const BOSS_SEEDS: Record = { bulldrome: { name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e", - summary: "Charges marked lanes and crushes grouped targets.", mechanics: ["Bull Charge", "Crushing Pounce"], maxHp: 500, archetype: "bull", + summary: "Charges marked lanes and crushes grouped targets.", mechanicIds: ["bull-charge", "crushing-pounce", "cinder-nova", "ember-brand"], maxHp: 500, archetype: "bull", }, "sandglass-scorpion": { name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f", - summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanics: ["Burrow Rush", "Hourglass Eruption"], maxHp: 515, archetype: "burrower", + summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "memory-sequence"], maxHp: 515, archetype: "burrower", }, "cragclaw-crab": { name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4", - summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanics: ["Sidewinder Rush", "Crushing Tide"], maxHp: 505, archetype: "crab", + summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "aetheric-soak"], maxHp: 505, archetype: "crab", }, "mournveil-ghost": { name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff", - summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanics: ["Soul Scissors", "Haunting Rifts"], maxHp: 505, archetype: "ghost", + summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanicIds: ["vine-scissors", "haunting-rifts", "soul-siphon"], maxHp: 505, archetype: "ghost", }, "crownshard-golem": { name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45", - summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanics: ["Royal Shockwave", "Crownfall"], maxHp: 500, archetype: "golem", + summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanicIds: ["tri-burst", "ultimate-skyfall", "aetheric-soak"], maxHp: 500, archetype: "golem", }, "crystal-bat-matriarch": { name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff", - summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanics: ["Sonic Ring", "Mirror Shards"], maxHp: 480, archetype: "golem", + summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanicIds: ["tri-burst", "prism-beam", "memory-sequence"], maxHp: 480, archetype: "golem", }, "stormwool-alpaca": { name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff", - summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanics: ["Storm Charge", "Cloudburst Pounce"], maxHp: 480, archetype: "bull", + summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull", }, "cluckhorn-colossus": { name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d", - summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab", + summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab", }, "ashwing-demon": { name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d", - summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanics: ["Ash Scissors", "Cinder Rifts"], maxHp: 515, archetype: "ghost", + summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "cinder-nova"], maxHp: 515, archetype: "ghost", }, "riftclaw-demon": { name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff", - summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanics: ["Rift Blade", "Abyss Cross"], maxHp: 495, archetype: "duelist", + summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "soul-siphon"], maxHp: 495, archetype: "duelist", }, "tempestscale-dragon": { name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8", - summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanics: ["Tempest Breath", "Stormfall"], maxHp: 500, archetype: "sky-sweeper", + summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanicIds: ["storm-breath", "stormfall", "prism-beam"], maxHp: 500, archetype: "sky-sweeper", }, emberfox: { name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45", - summary: "Ricochets across the arena and leaves fire at every landing.", mechanics: ["Foxfire Rush", "Ember Pounce"], maxHp: 465, archetype: "ricochet", + summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet", }, "mirelord-frog": { name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b", - summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower", + summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower", }, "stonebreaker-giant": { name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563", - summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanics: ["Crag Shockwave", "Boulderfall"], maxHp: 500, archetype: "golem", + summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanicIds: ["tri-burst", "ruin-quake", "meteor-spread"], maxHp: 500, archetype: "golem", }, "glub-sovereign": { name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8", - summary: "Links two allies with living slime before seeding toxic pools.", mechanics: ["Ooze Tether", "Caustic Brood"], maxHp: 495, archetype: "web-caster", + summary: "Links two allies with living slime before seeding toxic pools.", mechanicIds: ["binding-web", "venom-purge", "hollow-collapse"], maxHp: 495, archetype: "web-caster", }, "scrapking-goblin": { name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b", - summary: "Repositions between attacks and fires improvised blade lanes.", mechanics: ["Scrap Blade", "Junkyard Cross"], maxHp: 485, archetype: "duelist", + summary: "Repositions between attacks and fires improvised blade lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "meteor-spread"], maxHp: 485, archetype: "duelist", }, "warcaller-orc": { name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f", - summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanics: ["Warpath Cleave", "Banner Cross"], maxHp: 500, archetype: "duelist", + summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "aetheric-soak"], maxHp: 500, archetype: "duelist", }, "tuskmaw-orc": { name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d", - summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanics: ["Tusk Rush", "Groundbreaker"], maxHp: 525, archetype: "crab", + summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "destruction-pulse"], maxHp: 525, archetype: "crab", }, "broodfang-spider": { name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff", - summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanics: ["Binding Web", "Venom Brood"], maxHp: 535, archetype: "web-caster", + summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanicIds: ["binding-web", "venom-purge", "meteor-spread"], maxHp: 535, archetype: "web-caster", }, "silkfang-spider": { name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8", - summary: "Snares paired allies before seeding the arena with toxic nests.", mechanics: ["Silk Snare", "Venom Nest"], maxHp: 505, archetype: "web-caster", + summary: "Snares paired allies before seeding the arena with toxic nests.", mechanicIds: ["binding-web", "venom-purge", "soul-siphon"], maxHp: 505, archetype: "web-caster", }, "thorncrown-stag": { name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b", - summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanics: ["Briar Charge", "Crown Pounce"], maxHp: 510, archetype: "bull", + summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanicIds: ["bull-charge", "crushing-pounce", "hollow-collapse"], maxHp: 510, archetype: "bull", }, "sky-totem": { name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1", - summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanics: ["Spirit Scissors", "Wind Rifts"], maxHp: 505, archetype: "ghost", + summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "prism-beam"], maxHp: 505, archetype: "ghost", }, "razorcrest-raptor": { name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a", - summary: "Rebounds through hunting lanes and tears open impact pools.", mechanics: ["Raptor Rush", "Talon Slam"], maxHp: 500, archetype: "ricochet", + summary: "Rebounds through hunting lanes and tears open impact pools.", mechanicIds: ["ricochet-rush", "meteor-slam", "prism-beam"], maxHp: 500, archetype: "ricochet", }, "bristlequake-boar": { name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45", - summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanics: ["Tusk Charge", "Bristle Quake"], maxHp: 545, archetype: "ram", + summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanicIds: ["destruction-rush", "ruin-quake", "destruction-pulse"], maxHp: 545, archetype: "ram", }, "moonfang-wolf": { name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5", - summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanics: ["Lunar Rush", "Moonfall"], maxHp: 495, archetype: "ricochet", + summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanicIds: ["ricochet-rush", "meteor-slam", "soul-siphon"], maxHp: 495, archetype: "ricochet", }, "frostmaw-yeti": { name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef", - summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanics: ["Avalanche Rush", "Frost Crush"], maxHp: 550, archetype: "crab", + summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "hollow-collapse"], maxHp: 550, archetype: "crab", }, "rimeclaw-yeti": { name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8", - summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanics: ["Rime Blade", "Glacier Cross"], maxHp: 500, archetype: "duelist", + summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist", }, }; diff --git a/src/game/bossHome.test.ts b/src/game/bossHome.test.ts index 8d21cef..7f94e8c 100644 --- a/src/game/bossHome.test.ts +++ b/src/game/bossHome.test.ts @@ -17,9 +17,7 @@ describe("boss home positioning", () => { it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => { const motion = createBossMotionState(bossId); motion.position = [7, 5]; - motion.nextChargeAt = Number.POSITIVE_INFINITY; motion.nextMechanicAt = Number.POSITIVE_INFINITY; - motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY; const before = Math.hypot( motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX), motion.position[1] - ARENA_CENTER[1], diff --git a/src/game/bossMechanics.ts b/src/game/bossMechanics.ts index 770c179..b0b7c89 100644 --- a/src/game/bossMechanics.ts +++ b/src/game/bossMechanics.ts @@ -1,447 +1,53 @@ -import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog"; -import { clampToArena } from "./arena"; -import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper"; -import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet"; -import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab"; -import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem"; -import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis"; -import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost"; -import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem"; -import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion"; -import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared"; -import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool"; -import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types"; -import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa"; -import { distance, moveToward, pointToSegmentDistance } from "./geometry"; -import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types"; +import { BOSS_DEFINITIONS } from "./bossCatalog"; +import { + advanceMechanicLoadout, + BOSS_MECHANIC_REGISTRY, + BULL_CHARGE, + BULL_POUNCE, + handleMechanicDispel, + upcomingLoadoutMechanic, +} from "./bosses/mechanicPool"; +import { createBaseMotion } from "./bosses/shared"; +import type { BossMechanicContext, BossMechanicResult } from "./bosses/types"; +import type { BossId, BossMotionState, BossState, MemberId, WorldPosition } from "./types"; -export const BULL_CHARGE = { - firstAt: 7, - repeatDelay: 4, - telegraphDuration: 1.8, - distance: 13.5, - speed: 10.5, - hitRadius: 1.35, - damage: 18, - knockdownDuration: 0.75, - aiClearance: 1.9, - aiEvadeSpeed: 3.4, -} as const; - -export const BULL_POUNCE = { - afterCharges: 3, - stackDuration: 5, - stackRadius: 2.2, - sharedDamage: 200, - leapDuration: 0.55, -} as const; - -export const BOSS_PERIODIC_MECHANICS = { - melee: { firstAt: 2, interval: 2.5, damage: 15 }, - nova: { firstAt: 9, interval: 12, damage: 13 }, - brand: { firstAt: 5, interval: 9, duration: 7, tickDamage: 6 }, -} as const; - -const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"]; -const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"]; +export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE }; export function createBossState(bossId: BossId = "bulldrome"): BossState { const definition = BOSS_DEFINITIONS[bossId]; - const archetype = BOSS_ARCHETYPE_BY_ID[bossId]; - let state: BossState; - if (archetype === "web-caster") state = createVexaState(bossId); - else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId); - else if (archetype === "duelist") state = createEmberMantisState(bossId); - else if (archetype === "ram") state = createObsidianRamState(bossId); - else if (archetype === "ricochet") state = createCinderbackState(bossId); - else if (archetype === "burrower") state = createSandglassState(); - else if (archetype === "crab") state = createCragclawState(); - else if (archetype === "ghost") state = createMournveilState(); - else if (archetype === "golem") state = createCrownshardState(); - else { - state = { - id: bossId, - name: definition.name, - maxHp: definition.maxHp, - hp: definition.maxHp, - nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt, - nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt, - nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt, - brandCount: 0, - }; - } - return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp }; + return { + id: bossId, + name: definition.name, + maxHp: definition.maxHp, + hp: definition.maxHp, + nextMeleeAt: 2, + }; } export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState { - const archetype = BOSS_ARCHETYPE_BY_ID[bossId]; - let motion: BossMotionState; - if (archetype === "web-caster") motion = createVexaMotion(bossId); - else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId); - else if (archetype === "duelist") motion = createEmberMantisMotion(bossId); - else if (archetype === "ram") motion = createObsidianRamMotion(bossId); - else if (archetype === "ricochet") motion = createCinderbackMotion(bossId); - else if (archetype === "burrower") motion = createSandglassMotion(); - else if (archetype === "crab") motion = createCragclawMotion(); - else if (archetype === "ghost") motion = createMournveilMotion(); - else if (archetype === "golem") motion = createCrownshardMotion(); - else motion = { - ...createBaseMotion("bulldrome"), - mode: "holding", - position: [0, -8.2], - chargeStart: [0, -8.2], - chargeEnd: [0, 5.5], - chargeTargetId: "nia", - chargeHitIds: [], - phaseEndsAt: 0, - nextChargeAt: BULL_CHARGE.firstAt, - chargeCount: 0, - chargesSincePounce: 0, - pounceTargetId: "aelia", - pounceCenter: [0, 4.5], - pounceCount: 0, + return { + ...createBaseMotion(bossId), + position: [0, -6.8], + chargeStart: [0, -6.8], + nextMechanicAt: 5, }; - return { ...motion, bossId }; -} - -function mechanicArchetype(bossId: BossId): BossArchetype { - return BOSS_ARCHETYPE_BY_ID[bossId]; -} - -function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition { - const dx = target[0] - start[0]; - const dz = target[1] - start[1]; - const length = Math.max(0.001, Math.hypot(dx, dz)); - return clampToArena([ - start[0] + (dx / length) * BULL_CHARGE.distance, - start[1] + (dz / length) * BULL_CHARGE.distance, - ]); -} - -function livingMember(party: PartyMember[], memberId: MemberId) { - return party.find((member) => member.id === memberId && member.hp > 0); -} - -function chooseTarget(party: PartyMember[], order: readonly MemberId[], startIndex: number): MemberId { - for (let offset = 0; offset < order.length; offset += 1) { - const candidate = order[(startIndex + offset) % order.length]; - if (livingMember(party, candidate)) return candidate; - } - return order[0]; -} - -function advanceMotionMechanics( - source: BossMotionState, - party: PartyMember[], - partyPositions: Record, - 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, - time: number, - damageMember: BossMechanicContext["damageMember"], - events: BossMechanicEvent[], -) { - let updatedParty = party; - - while (boss.nextMeleeAt <= time) { - if (motion.mode === "holding") { - const tankIndex = updatedParty.findIndex((member) => member.id === "brann"); - updatedParty[tankIndex] = damageMember( - updatedParty[tankIndex], - BOSS_PERIODIC_MECHANICS.melee.damage, - partyPositions.brann, - boss.nextMeleeAt, - ); - } - boss.nextMeleeAt += BOSS_PERIODIC_MECHANICS.melee.interval; - } - - while (boss.nextNovaAt <= time) { - updatedParty = updatedParty.map((member) => damageMember( - member, - BOSS_PERIODIC_MECHANICS.nova.damage, - partyPositions[member.id], - boss.nextNovaAt, - )); - events.push({ - at: boss.nextNovaAt, - message: "Cinder Nova strikes the party.", - tone: "danger", - pulseKind: "boss", - }); - boss.nextNovaAt += BOSS_PERIODIC_MECHANICS.nova.interval; - } - - while (boss.nextBrandAt <= time) { - const targetId = CHARGE_TARGET_ORDER[boss.brandCount % CHARGE_TARGET_ORDER.length]; - const targetIndex = updatedParty.findIndex((member) => member.id === targetId); - if (updatedParty[targetIndex].hp > 0) { - const appliedAt = boss.nextBrandAt; - updatedParty[targetIndex] = { - ...updatedParty[targetIndex], - debuffs: [ - ...updatedParty[targetIndex].debuffs, - { - id: `brand-${boss.brandCount}`, - name: "Ember Brand", - expiresAt: appliedAt + BOSS_PERIODIC_MECHANICS.brand.duration, - nextTickAt: appliedAt + 1, - tickDamage: BOSS_PERIODIC_MECHANICS.brand.tickDamage, - }, - ], - }; - events.push({ - at: appliedAt, - message: `Ember Brand afflicts ${updatedParty[targetIndex].name}.`, - tone: "danger", - pulseKind: "debuff", - targetId, - }); - } - boss.brandCount += 1; - boss.nextBrandAt += BOSS_PERIODIC_MECHANICS.brand.interval; - } - return updatedParty; -} - -function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicResult { - const boss = { ...context.boss }; - const events: BossMechanicEvent[] = []; - const motionResult = advanceMotionMechanics( - context.motion, - context.party, - context.partyPositions, - context.time, - context.delta, - context.damageMember, - events, - ); - const party = resolvePeriodicMechanics( - boss, - motionResult.motion, - motionResult.party, - context.partyPositions, - context.time, - context.damageMember, - events, - ); - return { boss, motion: motionResult.motion, party, events }; } export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult { - const archetype = mechanicArchetype(context.boss.id); - const result = archetype === "web-caster" ? advanceVexaMechanics(context) - : archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context) - : archetype === "duelist" ? advanceEmberMantisMechanics(context) - : archetype === "ram" ? advanceObsidianRamMechanics(context) - : archetype === "ricochet" ? advanceCinderbackMechanics(context) - : archetype === "burrower" ? advanceSandglassMechanics(context) - : archetype === "crab" ? advanceCragclawMechanics(context) - : archetype === "ghost" ? advanceMournveilMechanics(context) - : archetype === "golem" ? advanceCrownshardMechanics(context) - : advanceBulldromeMechanics(context); - return advancePooledBossMechanics(context, result); + return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds); } export function handleBossDispel( - bossId: BossId, + _bossId: BossId, motion: BossMotionState, memberId: MemberId, position: WorldPosition, time: number, debuffNames: readonly string[], ) { - if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) { - return { - motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time), - message: "Widow Venom purged. A venom pool forms where the target stood.", - }; - } - return { motion, message: "Harmful magic removed." }; + return handleMechanicDispel(motion, memberId, position, time, debuffNames); } export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) { - const pooled = upcomingPooledMechanic(motion, time); - if (pooled) return pooled; - const archetype = mechanicArchetype(boss.id); - if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time); - if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time); - if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time); - if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time); - if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time); - if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time); - if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time); - if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time); - if (archetype === "golem") return upcomingCrownshardMechanic(boss, motion, time); - if (motion.mode === "telegraph") { - return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true }; - } - if (motion.mode === "charging") { - return { name: "Charge active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true }; - } - if (motion.mode === "stacking") { - const names: Record = { aelia: "Aelia", brann: "Brann", nia: "Nia", orin: "Orin", vale: "Vale" }; - return { name: `Stack on ${names[motion.pounceTargetId]}`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_POUNCE.stackDuration, urgent: true }; - } - if (motion.mode === "pouncing") { - return { name: "Bulldrome Pounce", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 0.75, urgent: true }; - } - const candidates: Array<{ name: string; remaining: number; cycle: number }> = [ - { name: "Cinder Nova", remaining: Math.max(0, boss.nextNovaAt - time), cycle: BOSS_PERIODIC_MECHANICS.nova.interval }, - { name: "Ember Brand", remaining: Math.max(0, boss.nextBrandAt - time), cycle: BOSS_PERIODIC_MECHANICS.brand.interval }, - ]; - if (motion.mode === "holding" && Number.isFinite(motion.nextChargeAt)) { - candidates.push({ name: "Bull Charge", remaining: Math.max(0, motion.nextChargeAt - time), cycle: BULL_CHARGE.firstAt + 1 }); - } - let next = candidates[0]; - for (let index = 1; index < candidates.length; index += 1) { - if (candidates[index].remaining < next.remaining) next = candidates[index]; - } - return { ...next, urgent: next.remaining < 2.5 }; + return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, motion, time); } diff --git a/src/game/bosses/claudecraftBossTrio.test.ts b/src/game/bosses/claudecraftBossTrio.test.ts deleted file mode 100644 index 8025afd..0000000 --- a/src/game/bosses/claudecraftBossTrio.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/game/bosses/cragclawCrab.ts b/src/game/bosses/cragclawCrab.ts deleted file mode 100644 index d17d56d..0000000 --- a/src/game/bosses/cragclawCrab.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/crownshardGolem.ts b/src/game/bosses/crownshardGolem.ts deleted file mode 100644 index 4b8fad1..0000000 --- a/src/game/bosses/crownshardGolem.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/emberMantis.test.ts b/src/game/bosses/emberMantis.test.ts deleted file mode 100644 index 1b23f86..0000000 --- a/src/game/bosses/emberMantis.test.ts +++ /dev/null @@ -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, - motion: ReturnType, - 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); - }); -}); diff --git a/src/game/bosses/emberMantis.ts b/src/game/bosses/emberMantis.ts deleted file mode 100644 index 5504779..0000000 --- a/src/game/bosses/emberMantis.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/game/bosses/iwt2BossTrio.test.ts b/src/game/bosses/iwt2BossTrio.test.ts deleted file mode 100644 index ffc4511..0000000 --- a/src/game/bosses/iwt2BossTrio.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/game/bosses/mechanicPool.test.ts b/src/game/bosses/mechanicPool.test.ts index f6809d5..94deabb 100644 --- a/src/game/bosses/mechanicPool.test.ts +++ b/src/game/bosses/mechanicPool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { freshParty } from "../data"; import { createBaseMotion } from "./shared"; -import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool"; -import type { BossMechanicContext, BossMechanicResult } from "./types"; +import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool"; +import type { BossMechanicContext } from "./types"; import type { BossState, PoolTelegraph, WorldPosition } from "../types"; const POSITIONS: BossMechanicContext["partyPositions"] = { @@ -20,9 +20,6 @@ function state(): BossState { maxHp: 500, hp: 500, nextMeleeAt: Number.POSITIVE_INFINITY, - nextNovaAt: Number.POSITIVE_INFINITY, - nextBrandAt: Number.POSITIVE_INFINITY, - brandCount: 0, }; } @@ -38,10 +35,6 @@ function context(time: number, positions = POSITIONS): BossMechanicContext { }; } -function result(contextValue: BossMechanicContext): BossMechanicResult { - return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] }; -} - function memoryTelegraph(): PoolTelegraph { return { id: "test-memory", @@ -79,10 +72,10 @@ function advanceMemory( source.party = party; source.motion = { ...source.motion, - nextPoolMechanicAt: Number.POSITIVE_INFINITY, + activeMechanicId: "memory-sequence", poolTelegraphs: [telegraph], }; - return advancePooledBossMechanics(source, result(source)); + return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]); } function soulSiphonTelegraph(): PoolTelegraph { @@ -121,21 +114,31 @@ function advanceSoulSiphon( source.party = party; source.motion = { ...source.motion, - nextPoolMechanicAt: Number.POSITIVE_INFINITY, + activeMechanicId: "soul-siphon", poolTelegraphs: [telegraph], }; - return advancePooledBossMechanics(source, result(source)); + return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]); } describe("shared boss mechanic pool", () => { - it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => { - const source = context(POOLED_MECHANIC_TIMING.firstAt); - const started = advancePooledBossMechanics(source, result(source)); + it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => { + const source = context(0); + source.motion.nextMechanicAt = 0; + const started = advanceMechanicLoadout(source, [id, id]); - expect(started.motion.mode).toBe("holding"); + expect(started.motion.activeMechanicId).toBe(id); + expect(["idle", "move", "attack", "special"]).toContain(bossAnimationCue(started.motion)); + }); + + it("schedules the exact mechanic ID selected by a boss loadout", () => { + const source = context(0); + source.motion.nextMechanicAt = 0; + const started = advanceMechanicLoadout(source, ["meteor-spread", "bull-charge"]); + + expect(started.motion.activeMechanicId).toBe("meteor-spread"); expect(started.motion.poolTelegraphs).not.toHaveLength(0); expect(started.events[0].message).toContain(":"); - expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY); + expect(bossAnimationCue(started.motion)).toBe("attack"); }); it("splits soak damage across allies inside its indicator", () => { @@ -156,14 +159,14 @@ describe("shared boss mechanic pool", () => { resolved: false, hitIds: [], }; - const motion = { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [soak] }; + const motion = { ...source.motion, activeMechanicId: "aetheric-soak" as const, poolTelegraphs: [soak] }; const positions = structuredClone(POSITIONS); positions.aelia = [0.2, 0]; positions.brann = [0, 0]; positions.nia = [-0.2, 0]; positions.vale = [0, -4]; const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt }; - const resolved = advancePooledBossMechanics(atImpact, result(atImpact)); + const resolved = advanceMechanicLoadout(atImpact, ["aetheric-soak", "bull-charge"]); expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30); expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30); @@ -192,11 +195,11 @@ describe("shared boss mechanic pool", () => { positions.brann = [3, 0]; const atImpact = { ...source, - motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] }, + motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] }, partyPositions: positions, time: activatesAt, }; - const resolved = advancePooledBossMechanics(atImpact, result(atImpact)); + const resolved = advanceMechanicLoadout(atImpact, ["hollow-collapse", "bull-charge"]); expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp); expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25); diff --git a/src/game/bosses/mechanicPool.ts b/src/game/bosses/mechanicPool.ts index 492a34e..df1dba4 100644 --- a/src/game/bosses/mechanicPool.ts +++ b/src/game/bosses/mechanicPool.ts @@ -1,9 +1,34 @@ import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena"; import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry"; -import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types"; +import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types"; +import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared"; import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types"; export const BOSS_MECHANIC_POOL = [ + { id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." }, + { id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." }, + { id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." }, + { id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." }, + { id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." }, + { id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." }, + { id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." }, + { id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." }, + { id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." }, + { id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." }, + { id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." }, + { id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." }, + { id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." }, + { id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." }, + { id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." }, + { id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." }, + { id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." }, + { id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." }, + { id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." }, + { id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." }, + { id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." }, + { id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." }, + { id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." }, + { id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." }, { id: "meteor-spread", name: "Meteor Spread", @@ -36,18 +61,62 @@ export const BOSS_MECHANIC_POOL = [ }, ] as const; -export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"]; +const MECHANIC_COPY_BY_ID = Object.fromEntries( + BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]), +) as Record; + +export function bossMechanicName(id: BossMechanicId) { + return MECHANIC_COPY_BY_ID[id].name; +} export const POOLED_MECHANIC_TIMING = { - firstAt: 15, - repeatDelay: 26, activeDuration: 0.42, warningDuration: 1.4, } as const; +export const BULL_CHARGE = { + warning: 1.8, + speed: 10.5, + distance: 13.5, + hitRadius: 1.35, + damage: 18, + knockdown: 0.75, + cooldown: 4, + aiClearance: 1.9, + aiEvadeSpeed: 3.4, +} as const; + +export const BULL_POUNCE = { + stackDuration: 5, + stackRadius: 2.2, + sharedDamage: 200, + leapDuration: 0.55, + cooldown: 4, +} as const; + +export const SKY_SWEEPER_BREATH = { + telegraphDuration: 2, + sweepDuration: 3.2, + range: 10.5, + halfAngle: Math.PI / 7, + sweepArc: Math.PI * 0.95, + tickDamage: 9, + tickInterval: 0.45, + cooldown: 4, +} as const; + +export const VENOM_PURGE = { + duration: 10, + tickDamage: 5, + castDuration: 2.5, + poolRadius: 2, + poolDuration: 7, + poolDamage: 14, +} as const; + export const MEMORY_SEQUENCE = { sequenceLength: 4, - flashDuration: 0.68, + flashDuration: 0.9, inputDuration: 7, tileSize: 2.15, raidwideDamage: 15, @@ -84,13 +153,6 @@ const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [ ]; const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"]; - -function bossPoolOffset(bossId: string) { - let value = 0; - for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length; - return value; -} - function liveTarget(party: PartyMember[], targetId: MemberId) { return party.some((member) => member.id === targetId && member.hp > 0) ? targetId @@ -220,9 +282,10 @@ function beginPoolMechanic( party: PartyMember[], positions: BossMechanicContext["partyPositions"], time: number, + requestedId: BossMechanicId, ) { const count = motion.poolMechanicCount + 1; - const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length]; + const entry = MECHANIC_COPY_BY_ID[requestedId]; const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration; let telegraphs: PoolTelegraph[]; let targetId: MemberId | undefined; @@ -281,7 +344,6 @@ function beginPoolMechanic( motion: { ...motion, poolMechanicCount: count, - nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: telegraphs, }, event: { @@ -498,61 +560,6 @@ function resolveTelegraph( return next; } -/** Composes the shared mechanic pool after a boss's signature mechanic update. */ -export function advancePooledBossMechanics( - context: BossMechanicContext, - result: BossMechanicResult, -): BossMechanicResult { - if (context.allowPooledMechanics === false) return result; - const source = result.motion; - if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result; - - let motion: BossMotionState = { - ...source, - poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({ - ...telegraph, - center: [telegraph.center[0], telegraph.center[1]], - start: telegraph.start && [telegraph.start[0], telegraph.start[1]], - end: telegraph.end && [telegraph.end[0], telegraph.end[1]], - tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })), - soulSiphon: telegraph.soulSiphon && { - ...telegraph.soulSiphon, - ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]], - wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]], - }, - hitIds: [...telegraph.hitIds], - })), - }; - let party = result.party; - const events = [...result.events]; - - for (const telegraph of motion.poolTelegraphs) { - if (telegraph.kind === "memory") { - if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events); - continue; - } - if (telegraph.kind === "soul-siphon") { - if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events); - continue; - } - if (telegraph.resolved || context.time < telegraph.activatesAt) continue; - party = resolveTelegraph(telegraph, party, context.partyPositions, context, events); - telegraph.resolved = true; - } - motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time); - - if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) { - const started = beginPoolMechanic(motion, party, context.partyPositions, context.time); - motion = started.motion; - events.push(started.event); - } - if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) { - motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay; - } - - return { ...result, motion, party, events }; -} - export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null { const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time); if (!telegraphs.length) return null; @@ -582,3 +589,702 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U urgent: true, }; } + +interface MechanicRuntime { + readonly context: BossMechanicContext; + boss: BossMechanicResult["boss"]; + motion: BossMotionState; + party: PartyMember[]; + events: BossMechanicResult["events"]; +} + +export interface BossMechanicDefinition { + readonly id: BossMechanicId; + readonly name: string; + readonly instruction: string; + readonly cooldown: number; + readonly passive?: boolean; + readonly start: (runtime: MechanicRuntime) => void; + readonly advance: (runtime: MechanicRuntime) => void; + upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic; + readonly animationCue: (motion: BossMotionState) => BossAnimationCue; +} + +function finishMechanic(runtime: MechanicRuntime, cooldown: number) { + runtime.motion.activeMechanicId = null; + runtime.motion.mode = "holding"; + runtime.motion.phaseEndsAt = 0; + runtime.motion.nextMechanicAt = runtime.context.time + cooldown; + runtime.motion.chargeHitIds = []; + runtime.motion.mechanicHitIds = []; + runtime.motion.tetherIds = []; + runtime.motion.slashLanes = []; +} + +function timedAdvance(runtime: MechanicRuntime, cooldown: number) { + if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown); +} + +function defaultUpcoming(definition: Pick, 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 = { + "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." }; +} diff --git a/src/game/bosses/mournveilGhost.ts b/src/game/bosses/mournveilGhost.ts deleted file mode 100644 index 1a849ea..0000000 --- a/src/game/bosses/mournveilGhost.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/obsidianRamGolem.ts b/src/game/bosses/obsidianRamGolem.ts deleted file mode 100644 index d2ae8f8..0000000 --- a/src/game/bosses/obsidianRamGolem.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/ricochet.ts b/src/game/bosses/ricochet.ts deleted file mode 100644 index 4c560b9..0000000 --- a/src/game/bosses/ricochet.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/sandglassScorpion.ts b/src/game/bosses/sandglassScorpion.ts deleted file mode 100644 index 6547174..0000000 --- a/src/game/bosses/sandglassScorpion.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/shared.ts b/src/game/bosses/shared.ts index f7e024c..2a19e09 100644 --- a/src/game/bosses/shared.ts +++ b/src/game/bosses/shared.ts @@ -39,19 +39,6 @@ export function returnBossToArenaCenter(motion: BossMotionState, delta: number, return false; } -export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState { - return { - id: bossId, - name, - maxHp, - hp: maxHp, - nextMeleeAt, - nextNovaAt: Number.POSITIVE_INFINITY, - nextBrandAt: Number.POSITIVE_INFINITY, - brandCount: 0, - }; -} - export function createCircleHazard({ id, kind, @@ -92,6 +79,7 @@ export function createCircleHazard({ export function createBaseMotion(bossId: BossId): BossMotionState { return { bossId, + activeMechanicId: null, formationOffsetX: 0, mode: "holding", position: [0, -8.2], @@ -100,12 +88,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState { chargeTargetId: "nia", chargeHitIds: [], phaseEndsAt: 0, - nextChargeAt: Number.POSITIVE_INFINITY, chargeCount: 0, - chargesSincePounce: 0, pounceTargetId: "aelia", pounceCenter: [0, 4.5], - pounceCount: 0, nextMechanicAt: Number.POSITIVE_INFINITY, mechanicCount: 0, phaseStartedAt: 0, @@ -118,7 +103,6 @@ export function createBaseMotion(bossId: BossId): BossMotionState { breathEndAngle: 0, hazards: [], slashLanes: [], - nextPoolMechanicAt: 15, poolMechanicCount: 0, poolTelegraphs: [], }; diff --git a/src/game/bosses/skySweeper.ts b/src/game/bosses/skySweeper.ts deleted file mode 100644 index 3d5b546..0000000 --- a/src/game/bosses/skySweeper.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/bosses/types.ts b/src/game/bosses/types.ts index 48e3558..86fce65 100644 --- a/src/game/bosses/types.ts +++ b/src/game/bosses/types.ts @@ -22,8 +22,6 @@ export interface BossMechanicContext { partyPositions: Record; time: number; delta: number; - /** Shared pool mechanics are reserved for solo encounters to avoid unreadable overlap in multi-boss fights. */ - allowPooledMechanics?: boolean; damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember; } diff --git a/src/game/bosses/vexa.ts b/src/game/bosses/vexa.ts deleted file mode 100644 index 1ffbd05..0000000 --- a/src/game/bosses/vexa.ts +++ /dev/null @@ -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 }; -} diff --git a/src/game/partyBehaviors.ts b/src/game/partyBehaviors.ts index 491ea0f..b0c6741 100644 --- a/src/game/partyBehaviors.ts +++ b/src/game/partyBehaviors.ts @@ -1,6 +1,5 @@ -import { BULL_CHARGE } from "./bossMechanics"; import { clampToArena } from "./arena"; -import { SKY_SWEEPER_BREATH } from "./bosses/skySweeper"; +import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool"; import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry"; import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; @@ -37,24 +36,11 @@ const STACK_OFFSETS: Record = { const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [ "mantis_line_telegraph", "mantis_cross_telegraph", - "ram_charge_telegraph", - "ram_charging", - "ram_shatter", - "cinderback_curl", - "cinderback_ricochet", - "sandglass_burrow_telegraph", - "sandglass_burrowing", - "crab_scuttle_telegraph", - "crab_scuttling", - "ghost_soul_cross", - "ghost_soul_cross_followup", ]; const FORMATION_MODES: readonly BossMotionState["mode"][] = [ - "holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover", - "ram_quake", "ram_recover", "cinderback_slam", "cinderback_recover", "sandglass_eruption", "sandglass_hourglass", "sandglass_recover", - "crab_tidal_burst", "crab_recover", "ghost_haunting", "ghost_recover", "golem_shockwave", "golem_crownfall", "golem_recover", + "holding", "telegraph", "tethering", "venom_cast", "skyfall", "cinderback_slam", "sandglass_hourglass", "golem_shockwave", "golem_crownfall", ]; -const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging", "ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"]; +const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging"]; const FORMATION_SLOTS: Record = { // Bosses face Brann during normal uptime, making positive Z their front. diff --git a/src/game/progression/infusions.test.ts b/src/game/progression/infusions.test.ts index 51d44b8..a934309 100644 --- a/src/game/progression/infusions.test.ts +++ b/src/game/progression/infusions.test.ts @@ -7,15 +7,20 @@ import { equipActiveInfusion, equipPassiveInfusion, infusionCosts, + PASSIVE_INFUSIONS, passiveInfusionUnlocked, } from "./infusions"; import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot"; +import { RUN_BUFF_ORDER } from "../roguelike"; function stack(item: ReturnType, quantity: number): MaterialStack { return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity }; } describe("IWT2-style gear infusions", () => { + it("offers all 18 roguelike buffs as passive infusion choices", () => { + expect(PASSIVE_INFUSIONS.map((passive) => passive.id)).toEqual(RUN_BUFF_ORDER); + }); it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => { const progress = createDefaultGearProgress(); progress.brann.slots.weapon.level = 5; @@ -54,9 +59,10 @@ describe("IWT2-style gear infusions", () => { const progress = createDefaultGearProgress(); progress.vale.slots.feet.level = 10; expect(passiveInfusionUnlocked(progress)).toBe(true); - const infused = equipPassiveInfusion(progress, "priest", "deep-wells"); + const infused = equipPassiveInfusion(progress, "priest", "mend-efficiency"); useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused); - expect(useGameStore.getState().maxMana).toBe(120); - expect(useGameStore.getState().runBuffs).toEqual([]); + expect(useGameStore.getState().runModifiers.mendManaMultiplier).toBe(0.75); + expect(useGameStore.getState().runBuffRanks).toEqual({}); + expect(useGameStore.getState().passiveRunBuffId).toBe("mend-efficiency"); }); }); diff --git a/src/game/roguelike.test.ts b/src/game/roguelike.test.ts index eea78ff..5c7165f 100644 --- a/src/game/roguelike.test.ts +++ b/src/game/roguelike.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "vitest"; -import { freshParty } from "./data"; import { - applyRunBuffsToParty, + RUN_BUFF_ORDER, + RUN_BUFFS, bossHealthMultiplier, - runHealingMultiplier, - runMaxMana, + compileRunModifiers, + effectiveRunBuffRank, + formatRunBuffEffect, + increaseRunBuffRank, selectRandomBossPair, + selectRunBuffDraft, } from "./roguelike"; +import type { RunBuffRanks } from "./types"; describe("roguelike progression", () => { it("adds 10% base boss HP per completed round", () => { @@ -15,14 +19,51 @@ describe("roguelike progression", () => { expect(bossHealthMultiplier(5)).toBe(1.4); }); - it("stacks each persistent buff independently", () => { - const buffs = ["vital-bloom", "vital-bloom", "deep-wells", "restoring-grace"] as const; - const party = applyRunBuffsToParty(freshParty("priest", "Aelia"), buffs); + it("defines 18 unique infusion-eligible ability buffs without legacy ids", () => { + expect(RUN_BUFF_ORDER).toHaveLength(18); + expect(new Set(RUN_BUFF_ORDER)).toHaveLength(18); + expect(RUN_BUFF_ORDER.every((id) => RUN_BUFFS[id].infusionEligible)).toBe(true); + expect(RUN_BUFF_ORDER).not.toContain("vital-bloom"); + expect(RUN_BUFF_ORDER).not.toContain("deep-wells"); + expect(RUN_BUFF_ORDER).not.toContain("restoring-grace"); + }); - expect(party[0].maxHp).toBe(124); - expect(party[1].maxHp).toBe(186); - expect(runMaxMana(buffs)).toBe(120); - expect(runHealingMultiplier(buffs)).toBe(1.15); + it("compiles capped ranks and overlays one passive infusion rank", () => { + const ranks: RunBuffRanks = { + "mend-echo": 9, + "mend-efficiency": 2, + "renew-duration": 2, + "shield-guard": 3, + "purify-renew": 1, + "radiance-shield": 2, + "barrier-regen": 3, + }; + const modifiers = compileRunModifiers(ranks, "mend-efficiency"); + + expect(modifiers.mendExtraTargets).toBe(3); + expect(modifiers.mendManaMultiplier).toBeCloseTo(0.75 ** 3); + expect(modifiers.renewDurationBonus).toBe(4); + expect(modifiers.shieldDamageTakenMultiplier).toBeCloseTo(0.76); + expect(modifiers.purifyAppliesRenew).toBe(true); + expect(modifiers.radianceAbsorb).toBe(18); + expect(modifiers.barrierHealingPerSecond).toBe(9); + expect(effectiveRunBuffRank(ranks, "mend-efficiency", "mend-efficiency")).toBe(3); + expect(formatRunBuffEffect("mend-efficiency", 3)).toBe("58% less Mend mana cost"); + }); + + it("increments earned ranks without exceeding each buff cap", () => { + const first = increaseRunBuffRank({}, "purify-renew"); + const capped = increaseRunBuffRank(first, "purify-renew"); + expect(first["purify-renew"]).toBe(1); + expect(capped["purify-renew"]).toBe(1); + }); + + it("draws unique eligible choices and handles one or zero remaining buffs", () => { + expect(selectRunBuffDraft({}, null, () => 0)).toEqual(RUN_BUFF_ORDER.slice(0, 3)); + const maxed = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks; + maxed["mend-efficiency"] = 2; + expect(selectRunBuffDraft(maxed, null, () => 0)).toEqual(["mend-efficiency"]); + expect(selectRunBuffDraft(maxed, "mend-efficiency", () => 0)).toEqual([]); }); it("selects a distinct pair that excludes both bosses from the prior round", () => { diff --git a/src/game/roguelike.ts b/src/game/roguelike.ts index 5cb829e..9dfca52 100644 --- a/src/game/roguelike.ts +++ b/src/game/roguelike.ts @@ -1,62 +1,203 @@ import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; -import type { BossId, PartyMember, RunBuffId } from "./types"; +import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types"; + +export type RunBuffEffectKind = + | "extra-target" + | "mana-cost" + | "cast-time" + | "duration" + | "healing" + | "absorb" + | "damage-reduction" + | "trigger-renew" + | "trigger-shield" + | "chain-cleanse" + | "cooldown" + | "barrier-healing"; export interface RunBuffDefinition { id: RunBuffId; + abilityId: AbilityId; + effectKind: RunBuffEffectKind; name: string; icon: string; summary: string; detail: string; accent: string; + maxRank: 1 | 3; + infusionEligible: true; } -export const RUN_BUFF_ORDER: readonly RunBuffId[] = ["vital-bloom", "deep-wells", "restoring-grace"]; +export interface CompiledRunModifiers { + mendExtraTargets: number; + mendManaMultiplier: number; + mendCastTimeMultiplier: number; + renewExtraTargets: number; + renewDurationBonus: number; + renewHealingMultiplier: number; + shieldExtraTargets: number; + shieldAbsorbMultiplier: number; + shieldDamageTakenMultiplier: number; + purifyAppliesRenew: boolean; + purifyAppliesShield: boolean; + purifyExtraTargets: number; + radianceCooldownMultiplier: number; + radianceAppliesRenew: boolean; + radianceAbsorb: number; + barrierCooldownMultiplier: number; + barrierDurationBonus: number; + barrierHealingPerSecond: number; +} + +export const RUN_BUFF_ORDER: readonly RunBuffId[] = [ + "mend-echo", + "mend-efficiency", + "mend-cast-speed", + "renew-spread", + "renew-duration", + "renew-potency", + "shield-echo", + "shield-potency", + "shield-guard", + "purify-renew", + "purify-shield", + "purify-chain", + "radiance-cooldown", + "radiance-renew", + "radiance-shield", + "barrier-cooldown", + "barrier-duration", + "barrier-regen", +]; + +const buff = ( + id: RunBuffId, + abilityId: AbilityId, + effectKind: RunBuffEffectKind, + name: string, + icon: string, + summary: string, + detail: string, + accent: string, + maxRank: 1 | 3 = 3, +): RunBuffDefinition => ({ id, abilityId, effectKind, name, icon, summary, detail, accent, maxRank, infusionEligible: true }); export const RUN_BUFFS: Record = { - "vital-bloom": { - id: "vital-bloom", - name: "Vital Bloom", - icon: "♧", - summary: "+12% party max HP", - detail: "Stacks each time chosen. New round starts at full health.", - accent: "#74d18c", - }, - "deep-wells": { - id: "deep-wells", - name: "Deep Wells", - icon: "◇", - summary: "+20 maximum mana", - detail: "Stacks each time chosen. New round starts with full mana.", - accent: "#69baff", - }, - "restoring-grace": { - id: "restoring-grace", - name: "Restoring Grace", - icon: "✦", - summary: "+15% healing done", - detail: "Strengthens Mend, Renew, and Radiance. Stacks additively.", - accent: "#f1d479", - }, + "mend-echo": buff("mend-echo", "mend", "extra-target", "Echoing", "+", "+1 secondary ally", "Mend heals another injured ally for 50% power per rank.", "#f2d690"), + "mend-efficiency": buff("mend-efficiency", "mend", "mana-cost", "Efficient", "▽", "−25% mana cost", "Mend mana cost is multiplied by 0.75 per rank, rounded up.", "#72c8ef"), + "mend-cast-speed": buff("mend-cast-speed", "mend", "cast-time", "Swift", "»", "−25% cast time", "Mend cast time is multiplied by 0.75 per rank.", "#d8b4ff"), + "renew-spread": buff("renew-spread", "renew", "extra-target", "Spreading", "✣", "+1 injured ally", "Direct Renew casts affect another injured ally per rank.", "#71df9c"), + "renew-duration": buff("renew-duration", "renew", "duration", "Enduring", "◷", "+2s duration", "Every Renew effect lasts 2 seconds longer per rank.", "#83d9aa"), + "renew-potency": buff("renew-potency", "renew", "healing", "Potent", "↑", "+20% tick healing", "Every Renew tick heals 20% more per rank.", "#a8e875"), + "shield-echo": buff("shield-echo", "shield", "extra-target", "Echoing Aegis", "◇", "+1 secondary ally", "Shield another injured ally for 50% power per rank.", "#75c9ff"), + "shield-potency": buff("shield-potency", "shield", "absorb", "Reinforced Aegis", "⬡", "+25% absorption", "All healer-created absorption is 25% stronger per rank.", "#61b9ee"), + "shield-guard": buff("shield-guard", "shield", "damage-reduction", "Guardian Aegis", "▣", "−8% shielded damage", "Targets with absorption take 8% less incoming damage per rank.", "#8baeff"), + "purify-renew": buff("purify-renew", "purify", "trigger-renew", "Cleansing Renewal", "✧", "Purify applies Renew", "Every ally cleansed by Purify also gains Renew.", "#b58cff", 1), + "purify-shield": buff("purify-shield", "purify", "trigger-shield", "Purifying Ward", "◈", "Purify grants 50% Shield", "Every ally cleansed by Purify gains half-strength absorption.", "#9f9aff", 1), + "purify-chain": buff("purify-chain", "purify", "chain-cleanse", "Mass Purification", "✦", "+1 cleansed ally", "Purify also cleanses the most injured other debuffed ally.", "#d4a7ff", 1), + "radiance-cooldown": buff("radiance-cooldown", "radiance", "cooldown", "Quickened Radiance", "☀", "−20% cooldown", "Radiance cooldown is multiplied by 0.8 per rank.", "#ffd66b"), + "radiance-renew": buff("radiance-renew", "radiance", "trigger-renew", "Radiant Renewal", "❈", "Radiance applies Renew", "Radiance applies Renew to every living party member.", "#d4e978", 1), + "radiance-shield": buff("radiance-shield", "radiance", "absorb", "Radiant Aegis", "◎", "+9 party absorption", "Radiance grants 9 base absorption to every living ally per rank.", "#ffe58c"), + "barrier-cooldown": buff("barrier-cooldown", "barrier", "cooldown", "Hallowed Ground", "◉", "−20% cooldown", "Barrier cooldown is multiplied by 0.8 per rank.", "#e7cb62"), + "barrier-duration": buff("barrier-duration", "barrier", "duration", "Lingering Barrier", "⌛", "+2s duration", "Barrier remains active 2 seconds longer per rank.", "#cdbd69"), + "barrier-regen": buff("barrier-regen", "barrier", "barrier-healing", "Restorative Ground", "✚", "+3 healing per second", "Living allies inside Barrier heal every second per rank.", "#84d69a"), }; -export function countRunBuff(buffs: readonly RunBuffId[], buffId: RunBuffId) { - return buffs.reduce((count, current) => count + Number(current === buffId), 0); +export function runBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): number { + return Math.max(0, Math.min(RUN_BUFFS[buffId].maxRank, Math.floor(ranks[buffId] ?? 0))); } -export function applyRunBuffsToParty(party: PartyMember[], buffs: readonly RunBuffId[]) { - const vitalityMultiplier = 1 + countRunBuff(buffs, "vital-bloom") * 0.12; - return party.map((member) => { - const maxHp = Math.round(member.maxHp * vitalityMultiplier); - return { ...member, maxHp, hp: maxHp }; - }); +export function effectiveRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId, passiveInfusionId: RunBuffId | null = null): number { + return Math.min(RUN_BUFFS[buffId].maxRank, runBuffRank(ranks, buffId) + Number(passiveInfusionId === buffId)); } -export function runMaxMana(buffs: readonly RunBuffId[]) { - return 100 + countRunBuff(buffs, "deep-wells") * 20; +export function increaseRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): RunBuffRanks { + const current = runBuffRank(ranks, buffId); + if (current >= RUN_BUFFS[buffId].maxRank) return { ...ranks }; + return { ...ranks, [buffId]: current + 1 }; } -export function runHealingMultiplier(buffs: readonly RunBuffId[]) { - return 1 + countRunBuff(buffs, "restoring-grace") * 0.15; +export function selectRunBuffDraft( + ranks: RunBuffRanks, + passiveInfusionId: RunBuffId | null = null, + random: () => number = Math.random, + count = 3, +): RunBuffId[] { + const pool = RUN_BUFF_ORDER.filter((id) => effectiveRunBuffRank(ranks, id, passiveInfusionId) < RUN_BUFFS[id].maxRank); + const choices: RunBuffId[] = []; + while (choices.length < count && pool.length > 0) { + const sample = random(); + const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0; + const index = Math.floor(randomValue * pool.length); + choices.push(pool[index]); + pool.splice(index, 1); + } + return choices; +} + +export function compileRunModifiers(ranks: RunBuffRanks, passiveInfusionId: RunBuffId | null = null): CompiledRunModifiers { + const rank = (id: RunBuffId) => effectiveRunBuffRank(ranks, id, passiveInfusionId); + return { + mendExtraTargets: rank("mend-echo"), + mendManaMultiplier: 0.75 ** rank("mend-efficiency"), + mendCastTimeMultiplier: 0.75 ** rank("mend-cast-speed"), + renewExtraTargets: rank("renew-spread"), + renewDurationBonus: rank("renew-duration") * 2, + renewHealingMultiplier: 1 + rank("renew-potency") * 0.2, + shieldExtraTargets: rank("shield-echo"), + shieldAbsorbMultiplier: 1 + rank("shield-potency") * 0.25, + shieldDamageTakenMultiplier: 1 - rank("shield-guard") * 0.08, + purifyAppliesRenew: rank("purify-renew") > 0, + purifyAppliesShield: rank("purify-shield") > 0, + purifyExtraTargets: rank("purify-chain"), + radianceCooldownMultiplier: 0.8 ** rank("radiance-cooldown"), + radianceAppliesRenew: rank("radiance-renew") > 0, + radianceAbsorb: rank("radiance-shield") * 9, + barrierCooldownMultiplier: 0.8 ** rank("barrier-cooldown"), + barrierDurationBonus: rank("barrier-duration") * 2, + barrierHealingPerSecond: rank("barrier-regen") * 3, + }; +} + +export function runAbilityManaCost(abilityId: AbilityId, baseCost: number, modifiers: CompiledRunModifiers): number { + if (baseCost <= 0) return 0; + const multiplier = abilityId === "mend" ? modifiers.mendManaMultiplier : 1; + return Math.max(1, Math.ceil(baseCost * multiplier)); +} + +export function runAbilityCastTime(abilityId: AbilityId, baseCastTime: number, modifiers: CompiledRunModifiers): number { + return abilityId === "mend" ? baseCastTime * modifiers.mendCastTimeMultiplier : baseCastTime; +} + +export function runAbilityCooldown(abilityId: AbilityId, baseCooldown: number, modifiers: CompiledRunModifiers): number { + if (abilityId === "radiance") return baseCooldown * modifiers.radianceCooldownMultiplier; + if (abilityId === "barrier") return baseCooldown * modifiers.barrierCooldownMultiplier; + return baseCooldown; +} + +export function formatRunBuffEffect(buffId: RunBuffId, requestedRank: number): string { + const rank = Math.max(1, Math.min(RUN_BUFFS[buffId].maxRank, requestedRank)); + const reduced = (multiplier: number) => `${Math.round((1 - multiplier ** rank) * 100)}% less`; + switch (buffId) { + case "mend-echo": return `${rank} secondary ${rank === 1 ? "ally" : "allies"} at 50% healing`; + case "mend-efficiency": return `${reduced(0.75)} Mend mana cost`; + case "mend-cast-speed": return `${reduced(0.75)} Mend cast time`; + case "renew-spread": return `${rank} additional Renew ${rank === 1 ? "target" : "targets"}`; + case "renew-duration": return `+${rank * 2}s Renew duration`; + case "renew-potency": return `+${rank * 20}% Renew tick healing`; + case "shield-echo": return `${rank} secondary Shield ${rank === 1 ? "target" : "targets"} at 50% power`; + case "shield-potency": return `+${rank * 25}% healer absorption`; + case "shield-guard": return `${rank * 8}% less damage while shielded`; + case "purify-renew": return "Purify applies Renew"; + case "purify-shield": return "Purify grants 50% Shield"; + case "purify-chain": return "Purify cleanses one additional ally"; + case "radiance-cooldown": return `${reduced(0.8)} Radiance cooldown`; + case "radiance-renew": return "Radiance applies Renew party-wide"; + case "radiance-shield": return `+${rank * 9} base party absorption`; + case "barrier-cooldown": return `${reduced(0.8)} Barrier cooldown`; + case "barrier-duration": return `+${rank * 2}s Barrier duration`; + case "barrier-regen": return `${rank * 3} Barrier healing per second`; + } } export function bossHealthMultiplier(round: number) { diff --git a/src/game/store.test.ts b/src/game/store.test.ts index ca19162..0a9bd9c 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -3,9 +3,25 @@ import { BULL_CHARGE } from "./bossMechanics"; import { distance, pointToSegmentDistance } from "./geometry"; import { barrierProtects, useGameStore } from "./store"; import { createClassInventory, HEALER_CLASSES } from "./healers"; -import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa"; +import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; import { ARENA_CENTER, isInsideArena } from "./arena"; import { BOSS_DEFINITIONS } from "./bossCatalog"; +import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike"; +import type { RunBuffRanks } from "./types"; + +function startBuffedEncounter(runBuffRanks: RunBuffRanks) { + useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest")); + useGameStore.setState({ runBuffRanks, runModifiers: compileRunModifiers(runBuffRanks) }); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + })); +} + +function testDebuff(id: string) { + return { id, name: id, expiresAt: 10, nextTickAt: 9, tickDamage: 1 }; +} describe("Disc Priest combat simulation", () => { beforeEach(() => { @@ -106,15 +122,16 @@ describe("Disc Priest combat simulation", () => { }); it("Purify removes Ember Brand from selected ally", () => { - useGameStore.getState().tick(2); - useGameStore.getState().tick(2); - useGameStore.getState().tick(1.1); - const branded = useGameStore.getState().party.find((member) => member.id === "nia")!; + useGameStore.setState((state) => ({ + bossMotion: { ...state.bossMotion, mechanicCount: 3, nextMechanicAt: state.time }, + })); + useGameStore.getState().tick(0.1); + const branded = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Ember Brand"))!; expect(branded.debuffs).toHaveLength(1); - useGameStore.getState().selectMember("nia"); + useGameStore.getState().selectMember(branded.id); expect(useGameStore.getState().castAbility("purify")).toBe(true); - expect(useGameStore.getState().party.find((member) => member.id === "nia")?.debuffs).toHaveLength(0); + expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0); }); it("Radiance heals every living party member", () => { @@ -131,8 +148,7 @@ describe("Disc Priest combat simulation", () => { it("reduces damage by 30% for party members inside Barrier", () => { useGameStore.getState().setPlayerPosition([0, -3.7]); useGameStore.setState((state) => ({ - boss: { ...state.boss, nextNovaAt: 999, nextBrandAt: 999 }, - bossMotion: { ...state.bossMotion, nextChargeAt: 999 }, + boss: { ...state.boss }, })); expect(useGameStore.getState().castAbility("barrier")).toBe(true); useGameStore.getState().tick(2); @@ -190,40 +206,40 @@ describe("Disc Priest combat simulation", () => { }); it("telegraphs, executes, and recovers from a Bull charge", () => { - while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1); + while (useGameStore.getState().bossMotion.mode !== "telegraph") useGameStore.getState().tick(0.1); const telegraph = useGameStore.getState().bossMotion; expect(telegraph.mode).toBe("telegraph"); - expect(telegraph.chargeTargetId).toBe("nia"); + const targetId = telegraph.chargeTargetId; const midpoint: [number, number] = [ (telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2, (telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2, ]; useGameStore.setState((state) => ({ - partyPositions: { ...state.partyPositions, nia: midpoint }, - party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member), + partyPositions: { ...state.partyPositions, [targetId]: midpoint }, + party: state.party.map((member) => member.id === targetId ? { ...member, knockedUntil: state.time + 10 } : member), })); while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1); - for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes("nia"); step += 1) { + for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes(targetId); step += 1) { useGameStore.getState().tick(0.05); } const hitState = useGameStore.getState(); - expect(hitState.bossMotion.chargeHitIds).toContain("nia"); - expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1); + expect(hitState.bossMotion.chargeHitIds).toContain(targetId); + expect(hitState.party.find((member) => member.id === targetId)!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1); for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) { useGameStore.getState().tick(0.1); } const recovered = useGameStore.getState(); expect(recovered.bossMotion.mode).toBe("holding"); - expect(recovered.bossMotion.nextChargeAt).toBeGreaterThan(recovered.time); + expect(recovered.bossMotion.nextMechanicAt).toBeGreaterThan(recovered.time); }); it("moves every mobile AI party member out of the charge lane before impact", () => { useGameStore.setState((state) => ({ - boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 }, + boss: { ...state.boss, nextMeleeAt: 999 }, })); while (useGameStore.getState().bossMotion.mode !== "telegraph") { useGameStore.getState().tick(0.1); @@ -260,7 +276,7 @@ describe("Disc Priest combat simulation", () => { it("marks a stack target after three charges and splits 200 pounce damage", () => { useGameStore.setState((state) => ({ - boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 }, + boss: { ...state.boss, nextMeleeAt: 999 }, playerPosition: [0, 0], partyPositions: { aelia: [0, 0], @@ -269,18 +285,13 @@ describe("Disc Priest combat simulation", () => { orin: [0, 0], vale: [0, 0], }, - bossMotion: { - ...state.bossMotion, - mode: "returning", - position: [0, -1], - chargesSincePounce: 3, - }, + bossMotion: { ...state.bossMotion, activeMechanicId: null, mode: "holding", position: [0, -1], mechanicCount: 1, nextMechanicAt: state.time }, })); const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp])); useGameStore.getState().tick(0.05); expect(useGameStore.getState().bossMotion.mode).toBe("stacking"); - expect(useGameStore.getState().bossMotion.pounceTargetId).toBe("aelia"); + expect(useGameStore.getState().bossMotion.pounceTargetId).toBeTruthy(); expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2); for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) { @@ -292,7 +303,7 @@ describe("Disc Priest combat simulation", () => { useGameStore.getState().tick(0.05); } const impacted = useGameStore.getState(); - expect(impacted.bossMotion.mode).toBe("returning"); + expect(impacted.bossMotion.mode).toBe("holding"); for (const member of impacted.party) { expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3); } @@ -345,7 +356,7 @@ describe("Broodfang encounter", () => { useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 }, bossMotion: { - ...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time), + ...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time), nextMechanicAt: 999, }, })); @@ -356,8 +367,8 @@ describe("Broodfang encounter", () => { useGameStore.getState().tick(1); const secondTickHp = useGameStore.getState().party[0].hp; - expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage); - expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage); + expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage); + expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage); useGameStore.getState().setPlayerPosition([6, 6]); useGameStore.getState().tick(1); @@ -418,6 +429,129 @@ describe("PVE dual-boss encounter", () => { }); }); +describe("Roguelike ability buffs", () => { + it("reduces Mend cost and cast time while echoing to lowest-health allies", () => { + startBuffedEncounter({ "mend-echo": 2, "mend-efficiency": 3, "mend-cast-speed": 3 }); + useGameStore.setState((state) => ({ + party: state.party.map((member) => ({ + ...member, + hp: member.id === "aelia" ? 90 : member.id === "brann" ? 60 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : 30, + })), + })); + useGameStore.getState().selectMember("brann"); + + expect(useGameStore.getState().castAbility("mend")).toBe(true); + expect(useGameStore.getState().mana).toBe(97); + expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3); + useGameStore.getState().tick(0.22); + + const party = useGameStore.getState().party; + expect(party.find((member) => member.id === "brann")?.hp).toBe(98); + expect(party.find((member) => member.id === "nia")?.hp).toBe(29); + expect(party.find((member) => member.id === "orin")?.hp).toBe(39); + expect(party.find((member) => member.id === "vale")?.hp).toBe(30); + }); + + it("spreads longer, stronger Renew effects without recursive targeting", () => { + startBuffedEncounter({ "renew-spread": 2, "renew-duration": 2, "renew-potency": 2 }); + useGameStore.setState((state) => ({ + party: state.party.map((member) => ({ + ...member, + hp: member.id === "brann" ? 50 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp, + })), + })); + useGameStore.getState().selectMember("brann"); + expect(useGameStore.getState().castAbility("renew")).toBe(true); + + let party = useGameStore.getState().party; + expect(party.filter((member) => member.renewExpiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]); + useGameStore.getState().tick(1.01); + party = useGameStore.getState().party; + expect(party.find((member) => member.id === "brann")?.hp).toBeCloseTo(59.8); + expect(party.find((member) => member.id === "nia")?.hp).toBeCloseTo(19.8); + expect(party.find((member) => member.id === "orin")?.hp).toBeCloseTo(29.8); + }); + + it("strengthens and echoes Shield to deterministic secondary targets", () => { + startBuffedEncounter({ "shield-echo": 2, "shield-potency": 2 }); + useGameStore.setState((state) => ({ + party: state.party.map((member) => ({ + ...member, + hp: member.id === "brann" ? 70 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp, + })), + })); + useGameStore.getState().selectMember("brann"); + expect(useGameStore.getState().castAbility("shield")).toBe(true); + + const party = useGameStore.getState().party; + expect(party.find((member) => member.id === "brann")?.absorb).toBe(54); + expect(party.find((member) => member.id === "nia")?.absorb).toBe(27); + expect(party.find((member) => member.id === "orin")?.absorb).toBe(27); + }); + + it("chains Purify and applies triggered Renew and half-strength Shield", () => { + startBuffedEncounter({ "purify-renew": 1, "purify-shield": 1, "purify-chain": 1 }); + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "brann" + ? { ...member, hp: 70, debuffs: [testDebuff("tank-mark")] } + : member.id === "nia" + ? { ...member, hp: 10, debuffs: [testDebuff("ranger-mark")] } + : member.id === "orin" + ? { ...member, hp: 20, debuffs: [testDebuff("mage-mark")] } + : member), + })); + useGameStore.getState().selectMember("brann"); + expect(useGameStore.getState().castAbility("purify")).toBe(true); + + const party = useGameStore.getState().party; + for (const id of ["brann", "nia"] as const) { + const member = party.find((candidate) => candidate.id === id)!; + expect(member.debuffs).toEqual([]); + expect(member.renewExpiresAt).toBe(8); + expect(member.absorb).toBe(18); + } + expect(party.find((member) => member.id === "orin")?.debuffs).toHaveLength(1); + }); + + it("applies Radiance cooldown, party Renew, and party absorption", () => { + startBuffedEncounter({ "radiance-cooldown": 3, "radiance-renew": 1, "radiance-shield": 2 }); + useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 30) })) })); + expect(useGameStore.getState().castAbility("radiance")).toBe(true); + + const state = useGameStore.getState(); + expect(state.cooldowns.radiance).toBeCloseTo(14 * 0.8 ** 3); + expect(state.party.every((member) => member.renewExpiresAt === 8)).toBe(true); + expect(state.party.every((member) => member.absorb === 18)).toBe(true); + }); + + it("extends, quickens, and pulses healing from Barrier", () => { + startBuffedEncounter({ "barrier-cooldown": 3, "barrier-duration": 3, "barrier-regen": 3 }); + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 50 } : member), + })); + expect(useGameStore.getState().castAbility("barrier")).toBe(true); + expect(useGameStore.getState().cooldowns.barrier).toBeCloseTo(60 * 0.8 ** 3); + expect(useGameStore.getState().barrier.expiresAt).toBe(14); + useGameStore.getState().tick(1.01); + expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBe(59); + expect(useGameStore.getState().barrier.nextHealAt).toBe(2); + }); + + it("reduces incoming damage while absorption is present", () => { + startBuffedEncounter({ "shield-guard": 3 }); + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "aelia" ? { + ...member, + hp: 50, + absorb: 100, + debuffs: [{ id: "pulse", name: "Pulse", expiresAt: 2, nextTickAt: 0.5, tickDamage: 10 }], + } : member), + })); + useGameStore.getState().tick(1); + expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.absorb).toBeCloseTo(92.4); + }); +}); + describe("Roguelike rounds", () => { beforeEach(() => { useGameStore.getState().configureHealer( @@ -445,23 +579,43 @@ describe("Roguelike rounds", () => { expect(useGameStore.getState().phase).toBe("intermission"); expect(useGameStore.getState().round).toBe(1); expect(useGameStore.getState().time).toBe(intermissionTime); - expect(useGameStore.getState().chooseRunBuff("vital-bloom")).toBe(true); + const chosenBuffId = useGameStore.getState().draftBuffIds[0]!; + expect(chosenBuffId).toBeDefined(); + expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true); const roundTwo = useGameStore.getState(); const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id]; expect(roundTwo.phase).toBe("combat"); expect(roundTwo.round).toBe(2); - expect(roundTwo.runBuffs).toEqual(["vital-bloom"]); + expect(roundTwo.runBuffRanks[chosenBuffId]).toBe(1); expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true); expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1)); expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1)); - expect(roundTwo.party[0].maxHp).toBe(112); + expect(roundTwo.party[0].maxHp).toBe(100); }); it("rejects buff claims outside intermission", () => { - expect(useGameStore.getState().chooseRunBuff("deep-wells")).toBe(false); + expect(useGameStore.getState().chooseRunBuff("mend-efficiency")).toBe(false); expect(useGameStore.getState().round).toBe(1); }); + + it("offers explicit continuation after every buff reaches maximum rank", () => { + const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks; + useGameStore.setState({ + phase: "intermission", + runBuffRanks: maxedRanks, + runModifiers: compileRunModifiers(maxedRanks), + draftBuffIds: [], + selectedRunBuffId: null, + }); + + expect(useGameStore.getState().continueRoguelikeRound()).toBe(true); + expect(useGameStore.getState().phase).toBe("combat"); + expect(useGameStore.getState().round).toBe(2); + expect(useGameStore.getState().runBuffRanks).toEqual(maxedRanks); + expect(useGameStore.getState().draftBuffIds).toEqual([]); + expect(useGameStore.getState().continueRoguelikeRound()).toBe(false); + }); }); describe("shared arena boundary", () => { @@ -473,8 +627,8 @@ describe("shared arena boundary", () => { it("constrains player, party, and boss positions to the same room", () => { useGameStore.getState().setPlayerPosition([100, 100]); useGameStore.setState((state) => ({ - boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 }, - bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 }, + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, position: [100, -100] }, partyPositions: { ...state.partyPositions, brann: [50, 50], @@ -533,6 +687,7 @@ describe("Tempestscale encounter", () => { partyPositions: { ...state.partyPositions, aelia: [0, 1] }, bossMotion: { ...state.bossMotion, + activeMechanicId: "storm-breath", mode: "breath_sweeping", position: [0, -2.8], phaseStartedAt: state.time, diff --git a/src/game/store.ts b/src/game/store.ts index 02cb0ce..c9389ed 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -15,13 +15,16 @@ import { createClassInventory, HEALER_CLASSES } from "./healers"; import { combatFormation, updatePartyPositions } from "./partyBehaviors"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; import { - RUN_BUFF_ORDER, RUN_BUFFS, - applyRunBuffsToParty, bossHealthMultiplier, - runHealingMultiplier, - runMaxMana, + compileRunModifiers, + increaseRunBuffRank, + runAbilityCastTime, + runAbilityCooldown, + runAbilityManaCost, + selectRunBuffDraft, selectRandomBossPair, + type CompiledRunModifiers, } from "./roguelike"; import { createDefaultGearProgress, type GearProgress } from "./progression/gear"; import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects"; @@ -41,6 +44,7 @@ import type { MemberId, PartyMember, RunBuffId, + RunBuffRanks, RunMode, ScenePulse, WorldPosition, @@ -68,9 +72,11 @@ export interface GameState { phase: GamePhase; runMode: RunMode; round: number; - runBuffs: RunBuffId[]; + runBuffRanks: RunBuffRanks; draftBuffIds: RunBuffId[]; - selectedRunBuffId: RunBuffId; + selectedRunBuffId: RunBuffId | null; + passiveRunBuffId: RunBuffId | null; + runModifiers: CompiledRunModifiers; healingMultiplier: number; difficultySlug: DifficultySlug; difficultyDamageMultiplier: number; @@ -112,6 +118,7 @@ export interface GameState { setPauseSelection: (selection: "resume" | "exit") => void; setSelectedRunBuff: (buffId: RunBuffId) => void; chooseRunBuff: (buffId: RunBuffId) => boolean; + continueRoguelikeRound: () => boolean; } const emptyCooldowns = (): Record => ({ @@ -143,7 +150,6 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo motion.chargeEnd[0] += offset; motion.pounceCenter[0] += offset; const stagger = index * 2.4; - if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger; if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger; return constrainBossMotion(motion); } @@ -154,8 +160,6 @@ function createEncounterBoss(bossId: BossId, index: number, count: number, healt boss.hp = boss.maxHp; const stagger = index * 0.8; if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger; - if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4; - if (Number.isFinite(boss.nextBrandAt)) boss.nextBrandAt += index * 2.4; return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) }; } @@ -190,6 +194,45 @@ export function barrierProtects(position: WorldPosition, barrier: BarrierState, return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS; } +function lowestHealthIndexes( + party: readonly PartyMember[], + excludedIndex: number, + count: number, + predicate: (member: PartyMember) => boolean = () => true, +): number[] { + return party + .map((member, index) => ({ member, index })) + .filter(({ member, index }) => index !== excludedIndex && member.hp > 0 && predicate(member)) + .sort((left, right) => (left.member.hp / left.member.maxHp) - (right.member.hp / right.member.maxHp) || left.index - right.index) + .slice(0, count) + .map(({ index }) => index); +} + +function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers) { + if (party[index].hp <= 0) return; + party[index] = { + ...party[index], + renewExpiresAt: time + 8 + modifiers.renewDurationBonus, + renewNextTickAt: time + 1, + }; +} + +function addHealerAbsorb( + party: PartyMember[], + index: number, + baseAmount: number, + healingPower: number, + modifiers: CompiledRunModifiers, +) { + if (party[index].hp <= 0 || baseAmount <= 0) return 0; + const amount = baseAmount * healingPower * modifiers.shieldAbsorbMultiplier; + party[index] = { + ...party[index], + absorb: Math.min(party[index].maxHp, party[index].absorb + amount), + }; + return amount; +} + function damageMemberAt( member: PartyMember, amount: number, @@ -201,6 +244,7 @@ function damageMemberAt( incomingDamageMultiplier = 1, gearModifiers?: EncounterGearModifiers, kind: "direct" | "hazard" = "direct", + shieldDamageTakenMultiplier = 1, ) { amount *= incomingDamageMultiplier; if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1; @@ -211,7 +255,8 @@ function damageMemberAt( barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0, protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0, ); - return damageMember(member, amount * (1 - reduction)); + const shieldMultiplier = member.absorb > 0 ? shieldDamageTakenMultiplier : 1; + return damageMember(member, amount * (1 - reduction) * shieldMultiplier); } function addLog( @@ -232,7 +277,7 @@ function initialState( requestedBossIds: BossId | readonly BossId[] = "bulldrome", runMode: RunMode = "encounter", round = 1, - runBuffs: RunBuffId[] = [], + runBuffRanks: RunBuffRanks = {}, gearProgress: GearProgress = createDefaultGearProgress(), requestedDifficultySlug: DifficultySlug = "initiate", ) { @@ -248,11 +293,10 @@ function initialState( const primary = encounterBosses[0]; const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId); const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null; - const effectiveRunBuffs = passiveInfusionId && !runBuffs.includes(passiveInfusionId) - ? [passiveInfusionId, ...runBuffs] - : runBuffs; - const party = applyGearHealth(applyRunBuffsToParty(freshParty(healerClassId, playerName), effectiveRunBuffs), gearModifiers); - const maxMana = runMaxMana(effectiveRunBuffs); + const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId); + const draftBuffIds = runMode === "roguelike" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : []; + const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers); + const maxMana = 100; return { bossId: primary.boss.id, paused: false, @@ -262,10 +306,12 @@ function initialState( phase: "briefing" as GamePhase, runMode, round, - runBuffs: [...runBuffs], - draftBuffIds: [...RUN_BUFF_ORDER], - selectedRunBuffId: RUN_BUFF_ORDER[0], - healingMultiplier: runHealingMultiplier(effectiveRunBuffs) * gearModifiers.aelia.healingPower, + runBuffRanks: { ...runBuffRanks }, + draftBuffIds, + selectedRunBuffId: draftBuffIds[0] ?? null, + passiveRunBuffId: passiveInfusionId, + runModifiers, + healingMultiplier: gearModifiers.aelia.healingPower, difficultySlug, difficultyDamageMultiplier: difficulty.damageMultiplier, gearProgress, @@ -290,7 +336,7 @@ function initialState( scenePulse: { id: 0, kind: "mend" as const }, playerPosition: [0, 4.5] as [number, number], activeCast: null as ActiveCast | null, - barrier: { center: [0, 4.5], expiresAt: 0 } as BarrierState, + barrier: { center: [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState, }; } @@ -298,14 +344,14 @@ export const useGameStore = create((set, get) => ({ ...initialState(), configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => { - set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, [], gearProgress, difficultySlug)); + set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug)); }, startEncounter: () => { - const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffs, gearProgress, difficultySlug } = get(); + const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug } = get(); const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; set({ - ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffs, gearProgress, difficultySlug), + ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug), phase: "combat", activeTab: "combat", combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }], @@ -314,7 +360,7 @@ export const useGameStore = create((set, get) => ({ restart: () => { const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get(); - set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, [], gearProgress, difficultySlug)); + set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, {}, gearProgress, difficultySlug)); }, selectMember: (selectedMemberId) => set({ selectedMemberId }), @@ -341,18 +387,38 @@ export const useGameStore = create((set, get) => ({ chooseRunBuff: (buffId) => { const state = get(); if (state.phase !== "intermission" || !state.draftBuffIds.includes(buffId)) return false; - const runBuffs = [...state.runBuffs, buffId]; + const runBuffRanks = increaseRunBuffRank(state.runBuffRanks, buffId); const round = state.round + 1; const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; const bossIds = selectRandomBossPair(previousBossIds); + const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name; set({ - ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffs, state.gearProgress, state.difficultySlug), + ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffRanks, state.gearProgress, state.difficultySlug), phase: "combat", activeTab: "combat", combatLog: [{ id: Date.now(), time: 0, - message: `${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`, + message: `${abilityName}: ${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`, + tone: "good", + }], + }); + return true; + }, + continueRoguelikeRound: () => { + const state = get(); + if (state.phase !== "intermission" || state.draftBuffIds.length > 0) return false; + const round = state.round + 1; + const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; + const bossIds = selectRandomBossPair(previousBossIds); + set({ + ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, state.runBuffRanks, state.gearProgress, state.difficultySlug), + phase: "combat", + activeTab: "combat", + combatLog: [{ + id: Date.now(), + time: 0, + message: `All blessings mastered. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`, tone: "good", }], }); @@ -381,12 +447,13 @@ export const useGameStore = create((set, get) => ({ if (state.activeCast) return false; const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId]; + const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers); const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId); const selected = state.party[selectedIndex]; if (state.cooldowns[abilityId] > state.time + 0.01) return false; if (state.globalCooldownUntil > state.time + 0.001) return false; - if (state.mana < ability.mana) { + if (state.mana < manaCost) { set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") }); return false; } @@ -402,9 +469,9 @@ export const useGameStore = create((set, get) => ({ abilityId: "mend", targetId: selected.id, startedAt: state.time, - completesAt: state.time + (ability.castTime ?? 0.5), + completesAt: state.time + runAbilityCastTime("mend", ability.castTime ?? 0.5, state.runModifiers), }, - mana: Math.max(0, state.mana - ability.mana), + mana: Math.max(0, state.mana - manaCost), globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS, combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`), }); @@ -419,48 +486,73 @@ export const useGameStore = create((set, get) => ({ switch (abilityId) { case "renew": - party[selectedIndex] = { - ...party[selectedIndex], - renewExpiresAt: state.time + 8, - renewNextTickAt: state.time + 1, - }; + applyRenewAt(party, selectedIndex, state.time, state.runModifiers); + for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) { + applyRenewAt(party, index, state.time, state.runModifiers); + } message = `${ability.name} placed on ${selected.name}.`; break; - case "shield": - party[selectedIndex] = { - ...party[selectedIndex], - absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36 * state.gearModifiers.aelia.healingPower), - }; - message = `${selected.name} gains ${Math.round(36 * state.gearModifiers.aelia.healingPower)} absorption.`; + case "shield": { + const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower, state.runModifiers); + for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) { + addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers); + } + message = `${selected.name} gains ${Math.round(amount)} absorption.`; break; - case "purify": - { - const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name); - const primaryDispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames); + } + case "purify": { + const cleanseIndexes = [ + selectedIndex, + ...lowestHealthIndexes(party, selectedIndex, state.runModifiers.purifyExtraTargets, (member) => member.debuffs.length > 0), + ]; + const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name); + for (const index of cleanseIndexes) { + const target = party[index]; + const dispelledNames = target.debuffs.map((debuff) => debuff.name); + const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames); bossMotion = primaryDispel.motion; - additionalBosses = state.additionalBosses.map((entry) => { - const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames); + additionalBosses = additionalBosses.map((entry) => { + const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledNames); return { ...entry, motion: dispel.motion }; }); - message = dispelledNames.includes("Widow Venom") - ? "Widow Venom purged. A venom pool forms where the target stood." - : `${dispelledNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`; + party[index] = { ...party[index], debuffs: [] }; + if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers); + if (state.runModifiers.purifyAppliesShield) { + addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers); + } } - party[selectedIndex] = { ...party[selectedIndex], debuffs: [] }; + message = primaryNames.includes("Widow Venom") + ? "Widow Venom purged. A venom pool forms where the target stood." + : `${primaryNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`; break; + } case "radiance": party = party.map((member) => healMember(member, 22 * state.healingMultiplier)); + if (state.runModifiers.radianceAppliesRenew) { + for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers); + } + if (state.runModifiers.radianceAbsorb > 0) { + for (let index = 0; index < party.length; index += 1) { + addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower, state.runModifiers); + } + } message = `${ability.name} heals the full party.`; break; case "barrier": - barrier = { center: [...state.partyPositions.aelia], expiresAt: state.time + 8 }; - message = `${ability.name} protects a 3m circle for 8 seconds.`; + barrier = { + center: [...state.partyPositions.aelia], + expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus, + nextHealAt: state.time + 1, + }; + message = `${ability.name} protects a 3m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`; break; } const cooldowns = { ...state.cooldowns, - [abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown * state.gearModifiers.aelia.cooldown : 0, + [abilityId]: ability.cooldown > 0 + ? state.time + runAbilityCooldown(abilityId, ability.cooldown, state.runModifiers) * state.gearModifiers.aelia.cooldown + : 0, }; const pulse: ScenePulse = { id: state.scenePulse.id + 1, @@ -475,7 +567,7 @@ export const useGameStore = create((set, get) => ({ barrier, bossMotion, additionalBosses, - mana: Math.max(0, state.mana - ability.mana), + mana: Math.max(0, state.mana - manaCost), combatLog: addLog(state.combatLog, state.time, message, "good"), scenePulse: pulse, }); @@ -504,7 +596,7 @@ export const useGameStore = create((set, get) => ({ let activeCast = state.activeCast ? { ...state.activeCast } : null; let partyCombat = state.partyCombat; let partyDamageEvents = state.partyDamageEvents; - const barrier = state.barrier; + let barrier = { ...state.barrier }; if (activeCast && activeCast.completesAt <= time) { const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId); @@ -512,6 +604,9 @@ export const useGameStore = create((set, get) => ({ if (target?.hp > 0) { const healing = 38 * state.healingMultiplier; party[targetIndex] = healMember(target, healing); + for (const index of lowestHealthIndexes(party, targetIndex, state.runModifiers.mendExtraTargets)) { + party[index] = healMember(party[index], healing * 0.5); + } const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name; combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good"); pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id }; @@ -525,7 +620,7 @@ export const useGameStore = create((set, get) => ({ let tickAt = next.renewNextTickAt; const lastTickAt = Math.min(time, next.renewExpiresAt); while (tickAt <= lastTickAt + 0.001) { - next = healMember(next, 7 * state.healingMultiplier); + next = healMember(next, 7 * state.healingMultiplier * state.runModifiers.renewHealingMultiplier); tickAt += 1; } next = { @@ -539,7 +634,7 @@ export const useGameStore = create((set, get) => ({ .map((debuff) => { let updated = { ...debuff }; while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) { - next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers); + next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier); updated.nextTickAt += 1; } return updated; @@ -548,6 +643,17 @@ export const useGameStore = create((set, get) => ({ return { ...next, debuffs: activeDebuffs }; }); + if (state.runModifiers.barrierHealingPerSecond > 0) { + while (barrier.nextHealAt <= time && barrier.nextHealAt < barrier.expiresAt) { + const pulseAt = barrier.nextHealAt; + const healing = state.runModifiers.barrierHealingPerSecond * state.healingMultiplier; + party = party.map((member) => barrierProtects(state.partyPositions[member.id], barrier, pulseAt) + ? healMember(member, healing) + : member); + barrier.nextHealAt += 1; + } + } + const livingMotions = [ ...(boss.hp > 0 ? [bossMotion] : []), ...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion), @@ -574,8 +680,7 @@ export const useGameStore = create((set, get) => ({ partyPositions, time, delta: time - oldTime, - allowPooledMechanics: encounterBosses.length === 1, - damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind), + damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier), }); encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) }; party = mechanicResult.party.map((member) => { @@ -638,6 +743,7 @@ export const useGameStore = create((set, get) => ({ activeCast, combatLog, scenePulse: pulse, + barrier, }); }, })); @@ -658,6 +764,7 @@ export type GameSnapshot = Omit; export function getGameSnapshot(): GameSnapshot { @@ -677,6 +784,7 @@ export function getGameSnapshot(): GameSnapshot { setPauseSelection: _setPauseSelection, setSelectedRunBuff: _setSelectedRunBuff, chooseRunBuff: _chooseRunBuff, + continueRoguelikeRound: _continueRoguelikeRound, ...snapshot } = useGameStore.getState(); return snapshot; diff --git a/src/game/types.ts b/src/game/types.ts index 1031a6b..e23478f 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -28,16 +28,66 @@ export type BossId = | "moonfang-wolf" | "frostmaw-yeti" | "rimeclaw-yeti"; +export type BossMechanicId = + | "basic-melee" + | "bull-charge" + | "crushing-pounce" + | "cinder-nova" + | "ember-brand" + | "binding-web" + | "venom-purge" + | "storm-breath" + | "stormfall" + | "elemental-beam" + | "guardian-cross" + | "destruction-rush" + | "ruin-quake" + | "destruction-pulse" + | "ricochet-rush" + | "meteor-slam" + | "burrow-rush" + | "hourglass-eruption" + | "sidewinder-rush" + | "crushing-tide" + | "vine-scissors" + | "haunting-rifts" + | "tri-burst" + | "ultimate-skyfall" + | "meteor-spread" + | "hollow-collapse" + | "aetheric-soak" + | "prism-beam" + | "memory-sequence" + | "soul-siphon"; +export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; export type RunMode = "encounter" | "roguelike"; -export type RunBuffId = "vital-bloom" | "deep-wells" | "restoring-grace"; +export type RunBuffId = + | "mend-echo" + | "mend-efficiency" + | "mend-cast-speed" + | "renew-spread" + | "renew-duration" + | "renew-potency" + | "shield-echo" + | "shield-potency" + | "shield-guard" + | "purify-renew" + | "purify-shield" + | "purify-chain" + | "radiance-cooldown" + | "radiance-renew" + | "radiance-shield" + | "barrier-cooldown" + | "barrier-duration" + | "barrier-regen"; +export type RunBuffRanks = Partial>; export type BottomTab = "combat" | "map" | "pack"; export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash"; export type BossMotionMode = | "holding" | "telegraph" | "charging" - | "returning" | "stacking" | "pouncing" | "tethering" @@ -45,35 +95,12 @@ export type BossMotionMode = | "breath_telegraph" | "breath_sweeping" | "skyfall" - | "mantis_sidestep" | "mantis_line_telegraph" | "mantis_cross_telegraph" - | "mantis_recover" - | "ram_charge_telegraph" - | "ram_charging" - | "ram_quake" - | "ram_shatter" - | "ram_recover" - | "cinderback_curl" - | "cinderback_ricochet" | "cinderback_slam" - | "cinderback_recover" - | "sandglass_burrow_telegraph" - | "sandglass_burrowing" - | "sandglass_eruption" | "sandglass_hourglass" - | "sandglass_recover" - | "crab_scuttle_telegraph" - | "crab_scuttling" - | "crab_tidal_burst" - | "crab_recover" - | "ghost_soul_cross" - | "ghost_soul_cross_followup" - | "ghost_haunting" - | "ghost_recover" | "golem_shockwave" - | "golem_crownfall" - | "golem_recover"; + | "golem_crownfall"; export type WorldPosition = [number, number]; export type CircleHazardKind = @@ -194,13 +221,11 @@ export interface BossState { maxHp: number; hp: number; nextMeleeAt: number; - nextNovaAt: number; - nextBrandAt: number; - brandCount: number; } export interface BossMotionState { bossId: BossId; + activeMechanicId: BossMechanicId | null; formationOffsetX: number; mode: BossMotionMode; position: WorldPosition; @@ -209,12 +234,9 @@ export interface BossMotionState { chargeTargetId: MemberId; chargeHitIds: MemberId[]; phaseEndsAt: number; - nextChargeAt: number; chargeCount: number; - chargesSincePounce: number; pounceTargetId: MemberId; pounceCenter: WorldPosition; - pounceCount: number; nextMechanicAt: number; mechanicCount: number; phaseStartedAt: number; @@ -227,7 +249,6 @@ export interface BossMotionState { breathEndAngle: number; hazards: CircleHazard[]; slashLanes: SlashLane[]; - nextPoolMechanicAt: number; poolMechanicCount: number; poolTelegraphs: PoolTelegraph[]; } @@ -257,6 +278,7 @@ export interface ActiveCast { export interface BarrierState { center: WorldPosition; expiresAt: number; + nextHealAt: number; } export interface ScenePulse { diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index dd7f267..db8dbf5 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -6,7 +6,8 @@ import type { AbilityId } from "./types"; function cycleRunBuff(direction: 1 | -1) { const store = useGameStore.getState(); - const currentIndex = store.draftBuffIds.indexOf(store.selectedRunBuffId); + if (store.draftBuffIds.length === 0) return; + const currentIndex = store.selectedRunBuffId ? store.draftBuffIds.indexOf(store.selectedRunBuffId) : -1; const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length; store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); } @@ -44,7 +45,10 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault(); if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1); - if (key === "enter") store.chooseRunBuff(store.selectedRunBuffId); + if (key === "enter") { + if (store.selectedRunBuffId) store.chooseRunBuff(store.selectedRunBuffId); + else store.continueRoguelikeRound(); + } if (key === "escape") exitRef.current?.(); return; } @@ -97,7 +101,10 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (store.phase === "intermission") { if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1); - if (!repeat && token === "Button0") store.chooseRunBuff(store.selectedRunBuffId); + if (!repeat && token === "Button0") { + if (store.selectedRunBuffId) store.chooseRunBuff(store.selectedRunBuffId); + else store.continueRoguelikeRound(); + } if (!repeat && token === "Button1") exitRef.current?.(); return; } diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index 8033f84..976604b 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -122,6 +122,8 @@ export function BottomDisplayApp() { selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }), selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }), selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }), + selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }), + selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }), upgradeSelectedGear: () => { postFrontend({ name: "upgradeSelectedGear" }); return false; @@ -155,6 +157,10 @@ export function BottomDisplayApp() { postCommand({ name: "chooseRunBuff", buffId }); return false; }, + continueRoguelikeRound: () => { + postCommand({ name: "continueRoguelikeRound" }); + return false; + }, }); channel.onmessage = (event: MessageEvent) => { if (event.data.type === "authoritative-ready") { diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index e2f6c64..26f028a 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { diffBottomGameSnapshot, type BottomGameSnapshot } from "./dualScreenSync"; +import { diffBottomGameSnapshot, executeFrontendCommand, executeGameCommand, type BottomGameSnapshot } from "./dualScreenSync"; +import { useGameStore } from "../game/store"; +import { useFrontendStore } from "../frontend/store"; function snapshot(): BottomGameSnapshot { return { @@ -8,16 +10,24 @@ function snapshot(): BottomGameSnapshot { healerClassId: "priest", phase: "combat", round: 1, + runModifiers: { + mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1, + renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1, + shieldExtraTargets: 0, shieldAbsorbMultiplier: 1, shieldDamageTakenMultiplier: 1, + purifyAppliesRenew: false, purifyAppliesShield: false, purifyExtraTargets: 0, + radianceCooldownMultiplier: 1, radianceAppliesRenew: false, radianceAbsorb: 0, + barrierCooldownMultiplier: 1, barrierDurationBonus: 0, barrierHealingPerSecond: 0, + }, time: 10, party: [], - boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 }, + boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1 }, additionalBosses: [], partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] }, bossMotion: { - bossId: "bulldrome", formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0, - nextChargeAt: Infinity, chargeCount: 0, chargesSincePounce: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], pounceCount: 0, + bossId: "bulldrome", activeMechanicId: null, formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0, + chargeCount: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0, - breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], nextPoolMechanicAt: Infinity, poolMechanicCount: 0, poolTelegraphs: [], + breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], poolMechanicCount: 0, poolTelegraphs: [], }, partyCombat: { combatants: { @@ -39,7 +49,7 @@ function snapshot(): BottomGameSnapshot { inventory: [], playerPosition: [0, 0], activeCast: null, - barrier: { center: [0, 0], expiresAt: 0 }, + barrier: { center: [0, 0], expiresAt: 0, nextHealAt: 0 }, }; } @@ -54,4 +64,24 @@ describe("dual-screen game snapshots", () => { const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 }; expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 }); }); + + it("routes maxed-run continuation and passive filter commands", () => { + const originalContinue = useGameStore.getState().continueRoguelikeRound; + const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility; + const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; + const calls: string[] = []; + useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } }); + useFrontendStore.setState({ + selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, + selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); }, + }); + + executeGameCommand({ name: "continueRoguelikeRound" }); + executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" }); + executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); + expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]); + + useGameStore.setState({ continueRoguelikeRound: originalContinue }); + useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive }); + }); }); diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index e8548cb..62f8fc0 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -22,7 +22,8 @@ export type GameCommand = | { name: "setPaused"; paused: boolean } | { name: "setPauseSelection"; selection: "resume" | "exit" } | { name: "setSelectedRunBuff"; buffId: RunBuffId } - | { name: "chooseRunBuff"; buffId: RunBuffId }; + | { name: "chooseRunBuff"; buffId: RunBuffId } + | { name: "continueRoguelikeRound" }; export type FrontendCommand = | { name: "signIn"; username: string; password: string } @@ -44,6 +45,8 @@ export type FrontendCommand = | { name: "selectGearSlot"; slotId: GearSlotId } | { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" } | { name: "selectInfusion"; infusionId: string } + | { name: "selectPassiveAbility"; abilityId: AbilityId } + | { name: "selectPassiveInfusion"; passiveId: RunBuffId } | { name: "upgradeSelectedGear" } | { name: "equipSelectedInfusion" } | { name: "equipPassiveInfusion"; passiveId: RunBuffId } @@ -82,6 +85,7 @@ export function executeGameCommand(command: GameCommand) { case "setPauseSelection": game.setPauseSelection(command.selection); break; case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break; case "chooseRunBuff": game.chooseRunBuff(command.buffId); break; + case "continueRoguelikeRound": game.continueRoguelikeRound(); break; } } @@ -107,6 +111,8 @@ export function executeFrontendCommand(command: FrontendCommand) { case "selectGearSlot": frontend.selectGearSlot(command.slotId); break; case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break; case "selectInfusion": frontend.selectInfusion(command.infusionId); break; + case "selectPassiveAbility": frontend.selectPassiveAbility(command.abilityId); break; + case "selectPassiveInfusion": frontend.selectPassiveInfusion(command.passiveId); break; case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break; case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break; case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; @@ -130,6 +136,7 @@ export type BottomGameSnapshot = Pick; const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ - "bossId", "paused", "healerClassId", "phase", "round", "time", "party", "boss", "additionalBosses", + "bossId", "paused", "healerClassId", "phase", "round", "runModifiers", "time", "party", "boss", "additionalBosses", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns", "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", ]; @@ -181,6 +188,7 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot { healerClassId: state.healerClassId, phase: state.phase, round: state.round, + runModifiers: state.runModifiers, time: state.time, party: state.party, boss: state.boss, diff --git a/src/styles.css b/src/styles.css index dc1e6c7..44ddfdd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1117,6 +1117,8 @@ button:focus-visible { .buff-draft > header h2 { margin: 2px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; } .buff-draft > header p { margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); } .buff-choice-grid { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.5%; } +.buff-choice-grid.choice-count-1 { grid-template-columns: minmax(0, 360px); justify-content: center; } +.buff-choice-grid.choice-count-2 { width: min(640px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); justify-self: center; } .buff-choice-grid button { min-width: 0; display: grid; @@ -1138,6 +1140,7 @@ button:focus-visible { .buff-choice-grid button strong { overflow: hidden; font-family: "Cinzel", serif; font-size: clamp(9px, 2cqw, 12px); text-overflow: ellipsis; white-space: nowrap; } .buff-choice-grid button > b { grid-column: 1 / -1; color: #edf5f1; font-size: clamp(8px, 1.7cqw, 10px); } .buff-choice-grid button > p { grid-column: 1 / -1; margin: 0; color: #71867e; font-size: clamp(7px, 1.45cqw, 9px); line-height: 1.25; } +.buff-choice-grid .buff-mastery-continue { grid-column: 1 / -1; width: min(420px, 100%); justify-self: center; align-self: center; } .buff-draft > footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #6e827a; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; } .buff-draft > footer b { color: #dce8e3; } .buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); } @@ -1908,7 +1911,18 @@ button:focus-visible { .boss-picker-heading > div { display: flex; gap: 4px; } .boss-picker-heading button { min-width: 72px; padding: 2px 6px; border: 1px solid var(--line); color: #a9bbb4; background: rgba(6,18,16,0.82); font-size: 7px; text-transform: uppercase; } .boss-picker-heading button:disabled { opacity: 0.32; } -.boss-choice-grid { display: grid; grid-template-columns: repeat(var(--boss-grid-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; } +.boss-group-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; } +.boss-group-choice { min-height: 30px; display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 5px; padding: 4px 6px; border: 1px solid var(--line); color: #9db2aa; background: rgba(6,18,16,0.82); text-align: left; } +.boss-group-choice > b { display: grid; width: 19px; height: 19px; place-items: center; border: 1px solid #527066; border-radius: 50%; color: var(--gold); font-family: "Cinzel", serif; font-size: 9px; } +.boss-group-choice > span { display: grid; min-width: 0; } +.boss-group-choice strong { font-size: 7px; } +.boss-group-choice small { overflow: hidden; color: #6f867d; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; } +.boss-group-choice.is-selected { border-color: var(--gold); background: rgba(87,69,25,0.22); color: #e8d68d; } +.boss-group-choice.is-selected > b { border-color: var(--gold); background: rgba(232,200,114,0.14); } +.boss-group-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding-top: 3px; color: #d6e3de; } +.boss-group-heading > span { font-family: "Cinzel", serif; font-size: 10px; } +.boss-group-heading > small { color: #71867e; font-size: 7px; text-transform: uppercase; } +.boss-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; } .boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; } .boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; } .boss-choice > span { display: grid; min-width: 0; } @@ -2058,7 +2072,15 @@ button:focus-visible { .mode-hero p { font-size: 7px; } .mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; } .boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; } - .boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; } + .boss-group-grid { gap: 3px; } + .boss-group-choice { min-height: 23px; grid-template-columns: 16px minmax(0, 1fr); gap: 3px; padding: 2px 3px; } + .boss-group-choice > b { width: 15px; height: 15px; font-size: 6px; } + .boss-group-choice strong { font-size: 5px; } + .boss-group-choice small { font-size: 4px; } + .boss-group-heading { padding-top: 1px; } + .boss-group-heading > span { font-size: 7px; } + .boss-group-heading > small { font-size: 4px; } + .boss-choice-grid { gap: 3px; } .boss-picker-heading { min-height: 11px; } .boss-picker-heading > span { font-size: 4px; } .boss-picker-heading button { min-width: 42px; padding: 1px 3px; font-size: 4px; } @@ -2101,7 +2123,7 @@ button:focus-visible { .gear-mode-tabs { display: flex; gap: 4px; } .gear-mode-tabs button { padding: 6px 8px; color: #7f958c; font-size: 7px; font-weight: 700; text-transform: uppercase; } .gear-mode-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.2); } -.gear-workshop-layout { height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; } +.gear-workshop-layout { box-sizing: border-box; height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; } .gear-owner-list, .gear-slot-list { display: grid; align-content: start; gap: 5px; } .gear-owner-list button, @@ -2132,23 +2154,27 @@ button:focus-visible { .gear-infusion-options, .gear-passive-options { display: grid; gap: 4px; margin-top: 9px; } .gear-infusion-options button, -.gear-passive-options button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; } +.gear-passive-choice-list button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; } .gear-infusion-options button > i, -.gear-passive-options button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; } +.gear-passive-choice-list button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; } .gear-infusion-options button > span, -.gear-passive-options button > span { min-width: 0; display: grid; } +.gear-passive-choice-list button > span { min-width: 0; display: grid; } .gear-infusion-options button strong, -.gear-passive-options button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } +.gear-passive-choice-list button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } .gear-infusion-options button small, -.gear-passive-options button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; } +.gear-passive-choice-list button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; } .gear-infusion-options button > b, -.gear-passive-options button > b { color: var(--gold); font-size: 9px; } +.gear-passive-choice-list button > b { color: var(--gold); font-size: 9px; } .gear-infusion-options button.is-selected { border-color: var(--gold); background: rgba(72,58,21,.16); } .gear-infusion-options button.is-equipped, -.gear-passive-options button.is-equipped { box-shadow: inset 3px 0 #67c89e; } -.gear-passive-options { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.gear-passive-options > span { grid-column: 1 / -1; color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } -.gear-passive-options button { min-width: 0; grid-template-columns: 18px 1fr 10px; } +.gear-passive-choice-list button.is-equipped { box-shadow: inset 3px 0 #67c89e; } +.gear-passive-options > span { color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.gear-passive-ability-filter { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; } +.gear-passive-ability-filter button { min-width: 0; min-height: 22px; padding: 2px 4px; color: #829890; font-size: 6px; } +.gear-passive-ability-filter button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.16); } +.gear-passive-choice-list { display: grid; gap: 3px; } +.gear-passive-choice-list button { min-width: 0; grid-template-columns: 18px 1fr 10px; } +.gear-passive-choice-list button.is-selected { border-color: #70b99d; background: rgba(46,103,81,.14); } .gear-context { padding: 0 5.5% 18px; } .gear-context .context-header { margin: 0 -5.8%; } @@ -2165,6 +2191,44 @@ button:focus-visible { .gear-upgrade-action:disabled { border-color: #42554e; color: #71827c; background: #13201c; } .gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; } .gear-upgrade-action small { font-size: clamp(6px, 1.35cqw, 8px); } +.gear-passive-context-action { position: absolute; right: 5.5%; bottom: 57px; left: 5.5%; min-height: 56px; display: grid; align-content: center; padding: 9px 13px; border: 1px solid #67c89e; color: #dce8e3; background: rgba(20,55,43,.72); } +.gear-passive-context-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; } +.gear-passive-context-action small { color: #8aa198; font-size: clamp(6px, 1.35cqw, 8px); } + +@media (min-width: 761px) and (max-width: 1000px) and (max-height: 650px) { + .gear-surface { padding: 0 12px; } + .gear-surface .front-screen-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; } + .gear-mode-tabs { gap: 2px; } + .gear-mode-tabs button { padding: 3px 4px; font-size: 4px; } + .gear-workshop-layout { height: calc(100% - 81px); grid-template-columns: 27% 31% 1fr; gap: 4px; padding-top: 5px; } + .gear-owner-list, + .gear-slot-list { gap: 2px; } + .gear-owner-list button, + .gear-slot-list button { min-height: 27px; gap: 3px; padding: 2px 4px; } + .gear-slot-list button { grid-template-columns: 16px 1fr 17px; } + .gear-owner-list strong, + .gear-slot-list strong { font-size: 5px; } + .gear-owner-list small, + .gear-slot-list small { font-size: 4px; } + .gear-slot-list button > i { width: 14px; height: 14px; font-size: 6px; } + .gear-preview { padding: 6px; } + .gear-preview h2 { margin: 3px 0; font-size: 8px; } + .gear-preview p { font-size: 5px; } + .gear-preview > span { font-size: 4px; } + .gear-infusion-options, + .gear-passive-options { gap: 2px; margin-top: 3px; } + .gear-infusion-options button, + .gear-passive-choice-list button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; } + .gear-infusion-options button > i, + .gear-passive-choice-list button > i { font-size: 6px; } + .gear-infusion-options button strong, + .gear-passive-choice-list button strong { font-size: 4px; } + .gear-infusion-options button small, + .gear-passive-choice-list button small, + .gear-passive-options > span { font-size: 3px; } + .gear-passive-ability-filter { gap: 2px; } + .gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; } +} .reward-summary { display: grid; gap: 4px; margin: 8px 0; } .reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; } @@ -2191,7 +2255,13 @@ button:focus-visible { .difficulty-picker > span { font-size: 4px; } .mode-dungeons .mode-hero { display: none; } .mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; } - .mode-dungeons .boss-choice-grid { gap: 2px; } + .mode-dungeons .boss-group-grid, .mode-dungeons .boss-choice-grid { gap: 2px; } + .mode-dungeons .boss-group-choice { min-height: 22px; grid-template-columns: 15px minmax(0, 1fr); gap: 2px; padding: 2px; } + .mode-dungeons .boss-group-choice > b { width: 14px; height: 14px; font-size: 6px; } + .mode-dungeons .boss-group-choice strong { font-size: 5px; } + .mode-dungeons .boss-group-choice small { display: none; } + .mode-dungeons .boss-group-heading > span { font-size: 6px; } + .mode-dungeons .boss-group-heading > small { font-size: 4px; } .mode-dungeons .boss-choice { min-width: 0; min-height: 27px; grid-template-columns: 15px 1fr 7px; gap: 2px; padding: 2px 3px; } .mode-dungeons .boss-choice > i { width: 14px; height: 14px; font-size: 6px; } .mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; } @@ -2224,12 +2294,14 @@ button:focus-visible { .gear-infusion-options, .gear-passive-options { gap: 2px; margin-top: 3px; } .gear-infusion-options button, - .gear-passive-options button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; } + .gear-passive-choice-list button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; } .gear-infusion-options button > i, - .gear-passive-options button > i { font-size: 6px; } + .gear-passive-choice-list button > i { font-size: 6px; } .gear-infusion-options button strong, - .gear-passive-options button strong { font-size: 4px; } + .gear-passive-choice-list button strong { font-size: 4px; } .gear-infusion-options button small, - .gear-passive-options button small, + .gear-passive-choice-list button small, .gear-passive-options > span { font-size: 3px; } + .gear-passive-ability-filter { gap: 2px; } + .gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; } }