diff --git a/client/src/components/ActionBar.test.tsx b/client/src/components/ActionBar.test.tsx index b968c026..327b7eff 100644 --- a/client/src/components/ActionBar.test.tsx +++ b/client/src/components/ActionBar.test.tsx @@ -56,6 +56,15 @@ const mockAutopilotState = { poisonConservativeExtraLivesTrigger: 0, poisonConservativeAmount: 0, poisonAggressiveAmount: 0, + poisonMinPower: 0, + poisonMinHealth: 0, + targetedPoisonPlayers: [], + targetedPoisonBeasts: [], + questMode: false, + questFilters: [], + maxBeastsPerAttack: 295, + skipSharedDiplomacy: false, + ignoredPlayers: [], }; vi.mock("@/contexts/controller", () => ({ @@ -92,6 +101,20 @@ vi.mock("../utils/beasts", () => ({ isBeastLocked: vi.fn(() => false), })); +vi.mock("@/hooks/useAutopilotOrchestrator", () => ({ + useAutopilotOrchestrator: () => ({ + collectionWithCombat: [], + isSavage: false, + enableAttack: false, + revivalPotionsRequired: 0, + autopilotLog: "", + startAutopilot: vi.fn(), + stopAutopilot: vi.fn(), + handleApplyExtraLife: vi.fn(), + handleApplyPoison: vi.fn(), + }), +})); + vi.mock("./dialogs/AutopilotConfigModal", () => ({ default: () => null, })); diff --git a/client/src/components/ActionBar.tsx b/client/src/components/ActionBar.tsx index 1ff71fba..08f45294 100644 --- a/client/src/components/ActionBar.tsx +++ b/client/src/components/ActionBar.tsx @@ -3,7 +3,8 @@ import { useGameDirector } from '@/contexts/GameDirector'; import { useQuestGuide } from '@/contexts/QuestGuide'; import { useAutopilotStore } from '@/stores/autopilotStore'; import { useGameStore } from '@/stores/gameStore'; -import type { Beast, selection } from '@/types/game'; +import { useAutopilotOrchestrator } from '@/hooks/useAutopilotOrchestrator'; +import type { Beast } from '@/types/game'; import AddIcon from '@mui/icons-material/Add'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp'; @@ -13,22 +14,17 @@ import { Box, Button, IconButton, Menu, MenuItem, Slider, TextField, Tooltip, Typography } from '@mui/material'; -import React, { useEffect, useMemo, useReducer, useState } from 'react'; +import React, { useState } from 'react'; import { isBrowser } from 'react-device-detect'; import attackPotionIcon from '../assets/images/attack-potion.png'; import heart from '../assets/images/heart.png'; import lifePotionIcon from '../assets/images/life-potion.png'; import poisonPotionIcon from '../assets/images/poison-potion.png'; import revivePotionIcon from '../assets/images/revive-potion.png'; -import { - calculateBattleResult, calculateOptimalAttackPotions, calculateRevivalRequired, - getBeastCurrentHealth, getBeastRevivalTime, isBeastLocked -} from '../utils/beasts'; import { gameColors } from '../utils/themes'; import AutopilotConfigModal from './dialogs/AutopilotConfigModal'; import BeastDexModal from './dialogs/BeastDexModal'; import BeastUpgradeModal from './dialogs/BeastUpgradeModal'; -import { MAX_BEASTS_PER_ATTACK } from '@/contexts/GameDirector'; type PotionSelection = 'extraLife' | 'poison'; @@ -49,41 +45,36 @@ function ActionBar() { const { selectedBeasts, summit, attackInProgress, - applyingPotions, setApplyingPotions, appliedPoisonCount, setAppliedPoisonCount, setBattleEvents, setAttackInProgress, - collection, collectionSyncing, setSelectedBeasts, attackMode, setAttackMode, autopilotLog, setAutopilotLog, - autopilotEnabled, setAutopilotEnabled, appliedExtraLifePotions, setAppliedExtraLifePotions } = useGameStore(); + applyingPotions, appliedPoisonCount, setAppliedPoisonCount, + collection, collectionSyncing, setSelectedBeasts, attackMode, setAttackMode, + autopilotEnabled, appliedExtraLifePotions, setAppliedExtraLifePotions } = useGameStore(); const { - attackStrategy, extraLifeStrategy, - extraLifeMax, extraLifeTotalMax, - extraLifeReplenishTo, extraLifePotionsUsed, useRevivePotions, revivePotionMax, - revivePotionMaxPerBeast, useAttackPotions, attackPotionMax, - attackPotionMaxPerBeast, revivePotionsUsed, attackPotionsUsed, - setRevivePotionsUsed, - setAttackPotionsUsed, - setExtraLifePotionsUsed, - setPoisonPotionsUsed, poisonStrategy, poisonTotalMax, poisonPotionsUsed, - poisonConservativeExtraLivesTrigger, - poisonConservativeAmount, - poisonAggressiveAmount, - poisonMinPower, - poisonMinHealth, - maxBeastsPerAttack, - skipSharedDiplomacy, - ignoredPlayers, } = useAutopilotStore(); + const { + collectionWithCombat, + isSavage, + enableAttack, + revivalPotionsRequired, + autopilotLog, + startAutopilot, + stopAutopilot, + handleApplyExtraLife, + handleApplyPoison, + } = useAutopilotOrchestrator(); + const [anchorEl, setAnchorEl] = useState(null); const [potion, setPotion] = useState(null) const [attackDropdownAnchor, setAttackDropdownAnchor] = useState(null); @@ -91,8 +82,6 @@ function ActionBar() { const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [upgradeBeast, setUpgradeBeast] = useState(null); const [beastDexFilterIds, setBeastDexFilterIds] = useState(null); - const [triggerAutopilot, setTriggerAutopilot] = useReducer((x) => x + 1, 0); - const poisonedTokenIdRef = React.useRef(null); const handleClick = (event: React.MouseEvent, potion: PotionSelection) => { setAnchorEl(event.currentTarget); @@ -131,275 +120,6 @@ function ActionBar() { }); } - const collectionWithCombat = useMemo(() => { - if (summit && collection.length > 0) { - const revivePotionsEnabled = autopilotEnabled && useRevivePotions && revivePotionsUsed < revivePotionMax; - const attackPotionsEnabled = autopilotEnabled && useAttackPotions && attackPotionsUsed < attackPotionMax; - - let filtered = collection.map((beast: Beast) => { - const newBeast = { ...beast } - newBeast.revival_time = getBeastRevivalTime(newBeast); - newBeast.current_health = getBeastCurrentHealth(beast); - newBeast.combat = calculateBattleResult(newBeast, summit, 0); - return newBeast - }).filter((beast: Beast) => !isBeastLocked(beast)); - - filtered = filtered.sort( - (a: Beast, b: Beast) => - (b.combat?.score ?? Number.NEGATIVE_INFINITY) - (a.combat?.score ?? Number.NEGATIVE_INFINITY) - ); - - if (revivePotionsEnabled) { - let revivePotionsRemaining = revivePotionMax - revivePotionsUsed; - filtered = filtered.map((beast: Beast) => { - if (beast.current_health === 0) { - if (beast.revival_count >= revivePotionsRemaining || beast.revival_count >= revivePotionMaxPerBeast) { - return null; - } else { - revivePotionsRemaining -= beast.revival_count + 1; - } - } - return beast; - }).filter((beast): beast is Beast => beast !== null); - } else { - filtered = filtered.filter((beast: Beast) => beast.current_health > 0); - } - - if (attackPotionsEnabled && filtered.length > 0) { - const attackSelection: selection[number] = [filtered[0], 1, 0]; - const attackPotions = calculateOptimalAttackPotions( - attackSelection, - summit, - Math.min(attackPotionMax - attackPotionsUsed, attackPotionMaxPerBeast, 255) - ); - const newCombat = calculateBattleResult(filtered[0], summit, attackPotions); - filtered[0].combat = newCombat; - } - - return filtered - } - - return []; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [summit?.beast?.token_id, collection.length, revivePotionsUsed, attackPotionsUsed, useRevivePotions, useAttackPotions]); - - const handleAttackUntilCapture = async (extraLifePotions: number) => { - if (!enableAttack) return; - - setBattleEvents([]); - setAttackInProgress(true); - - const allBeasts: [Beast, number, number][] = collectionWithCombat.map((beast: Beast) => [beast, 1, beast.combat?.attackPotions || 0]); - - // Split into batches of MAX_BEASTS_PER_ATTACK - const batches: [Beast, number, number][][] = []; - for (let i = 0; i < allBeasts.length; i += MAX_BEASTS_PER_ATTACK) { - batches.push(allBeasts.slice(i, i + MAX_BEASTS_PER_ATTACK)); - } - - // Process one batch at a time, stopping if executeGameAction returns false - for (const batch of batches) { - const result = await executeGameAction({ - type: 'attack_until_capture', - beasts: batch, - extraLifePotions - }); - - if (!result) { - return; - } - } - - setAttackInProgress(false); - } - - const handleApplyExtraLife = (amount: number) => { - if (!summit?.beast || !isSavage || applyingPotions || amount === 0) return; - - setApplyingPotions(true); - setAutopilotLog('Adding extra lives...') - - executeGameAction({ - type: 'add_extra_life', - beastId: summit.beast.token_id, - extraLifePotions: amount, - }); - } - - const handleApplyPoison = (amount: number): boolean => { - if (!summit?.beast || applyingPotions || amount === 0) return false; - - setApplyingPotions(true); - setAutopilotLog('Applying poison...') - - executeGameAction({ - type: 'apply_poison', - beastId: summit.beast.token_id, - count: amount, - }); - return true; - } - - const isSavage = Boolean(collection.find(beast => beast.token_id === summit?.beast?.token_id)) - const revivalPotionsRequired = calculateRevivalRequired(selectedBeasts); - - useEffect(() => { - if (attackMode === 'autopilot') { - setSelectedBeasts([]); - setAppliedExtraLifePotions(0); - } - - if (attackMode !== 'autopilot' && autopilotEnabled) { - setAutopilotEnabled(false); - poisonedTokenIdRef.current = null; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [attackMode]); - - const summitSharesDiplomacy = useMemo(() => { - if (!skipSharedDiplomacy || !summit?.beast) return false; - return collection.some( - (beast: Beast) => - beast.diplomacy && - beast.prefix === summit.beast.prefix && - beast.suffix === summit.beast.suffix, - ); - }, [skipSharedDiplomacy, summit?.beast?.token_id, collection.length]); - - const summitOwnerIgnored = useMemo(() => { - if (ignoredPlayers.length === 0 || !summit?.owner) return false; - const ownerNormalized = summit.owner.replace(/^0x0+/, '0x').toLowerCase(); - return ignoredPlayers.some((p) => p.address === ownerNormalized); - }, [ignoredPlayers, summit?.owner]); - - const shouldSkipSummit = summitSharesDiplomacy || summitOwnerIgnored; - - useEffect(() => { - if (autopilotEnabled && !attackInProgress && !applyingPotions) { - if (summitSharesDiplomacy) { - setAutopilotLog('Ignoring shared diplomacy'); - } else if (summitOwnerIgnored) { - const owner = summit?.owner?.replace(/^0x0+/, '0x').toLowerCase(); - const player = ignoredPlayers.find((p) => p.address === owner); - setAutopilotLog(`Ignoring ${player?.name ?? 'player'}`); - } else { - setAutopilotLog('Waiting for trigger...'); - } - } else if (attackInProgress) { - setAutopilotLog('Attacking...') - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autopilotEnabled, attackInProgress, applyingPotions, summitSharesDiplomacy, summitOwnerIgnored]) - - useEffect(() => { - if (!autopilotEnabled || poisonStrategy !== 'aggressive') return; - if (shouldSkipSummit) return; - const myBeast = collection.find((beast: Beast) => beast.token_id === summit?.beast.token_id); - if (myBeast) return; - - // Reset tracked token when summit beast changes - if (poisonedTokenIdRef.current !== summit?.beast.token_id) { - poisonedTokenIdRef.current = null; - } - if (poisonedTokenIdRef.current === summit?.beast.token_id) return; - - if (poisonMinPower > 0 && summit && summit.beast.power < poisonMinPower) return; - if (poisonMinHealth > 0 && summit && summit.beast.current_health < poisonMinHealth) return; - - const remainingCap = Math.max(0, poisonTotalMax - poisonPotionsUsed); - const poisonBalance = tokenBalances?.["POISON"] || 0; - const amount = Math.min(poisonAggressiveAmount, poisonBalance, remainingCap); - if (amount > 0 && summit && handleApplyPoison(amount)) { - poisonedTokenIdRef.current = summit.beast.token_id; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [summit?.beast?.token_id]); - - useEffect(() => { - if (!autopilotEnabled || attackInProgress || !collectionWithCombat || !summit) return; - - const myBeast = collection.find((beast: Beast) => beast.token_id === summit?.beast.token_id); - - if (myBeast) { - if (extraLifeStrategy === 'aggressive' && myBeast.extra_lives >= 0 && myBeast.extra_lives < extraLifeReplenishTo) { - const extraLifePotions = Math.min(extraLifeTotalMax - extraLifePotionsUsed, extraLifeReplenishTo - myBeast.extra_lives); - if (extraLifePotions > 0) { - handleApplyExtraLife(extraLifePotions); - } - } - - return; - }; - - if (shouldSkipSummit) return; - - if (poisonStrategy === 'conservative' - && summit.beast.extra_lives >= poisonConservativeExtraLivesTrigger - && summit.poison_count < poisonConservativeAmount - && poisonedTokenIdRef.current !== summit.beast.token_id - && (poisonMinPower <= 0 || summit.beast.power >= poisonMinPower) - && (poisonMinHealth <= 0 || summit.beast.current_health >= poisonMinHealth)) { - const remainingCap = Math.max(0, poisonTotalMax - poisonPotionsUsed); - const poisonBalance = tokenBalances?.["POISON"] || 0; - const amount = Math.min(poisonConservativeAmount - summit.poison_count, poisonBalance, remainingCap); - if (amount > 0 && handleApplyPoison(amount)) { - poisonedTokenIdRef.current = summit.beast.token_id; - } - } - - let extraLifePotions = 0; - if (extraLifeStrategy === 'after_capture') { - extraLifePotions = Math.min(extraLifeTotalMax - extraLifePotionsUsed, extraLifeMax); - } else if (extraLifeStrategy === 'aggressive') { - extraLifePotions = Math.min(extraLifeTotalMax - extraLifePotionsUsed, extraLifeReplenishTo); - } - - if (attackStrategy === 'never') { - return; - } else if (attackStrategy === 'all_out') { - handleAttackUntilCapture(extraLifePotions); - } else if (attackStrategy === 'guaranteed') { - const beasts = collectionWithCombat.slice(0, maxBeastsPerAttack) - - const totalSummitHealth = ((summit.beast.health + summit.beast.bonus_health) * summit.beast.extra_lives) + summit.beast.current_health; - const totalEstimatedDamage = beasts.reduce((acc, beast) => acc + (beast.combat?.estimatedDamage ?? 0), 0) - if (totalEstimatedDamage < (totalSummitHealth * 1.1)) { - return; - } - - executeGameAction({ - type: 'attack', - beasts: beasts.map((beast: Beast) => ([beast, 1, beast.combat?.attackPotions || 0])), - safeAttack: false, - vrf: true, - extraLifePotions: extraLifePotions, - attackPotions: beasts[0]?.combat?.attackPotions || 0 - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [collectionWithCombat, autopilotEnabled, summit?.beast.extra_lives, triggerAutopilot]); - - - useEffect(() => { - if (autopilotEnabled && !attackInProgress && summit?.beast.extra_lives === 0 && summit?.beast.current_health === 1) { - setTriggerAutopilot(); - } - }, [autopilotEnabled, summit?.beast.current_health]); - - const startAutopilot = () => { - setRevivePotionsUsed(() => 0); - setAttackPotionsUsed(() => 0); - setExtraLifePotionsUsed(() => 0); - setPoisonPotionsUsed(() => 0); - setAutopilotEnabled(true); - } - - const stopAutopilot = () => { - setAutopilotEnabled(false); - } - - const hasEnoughRevivePotions = (tokenBalances["REVIVE"] || 0) >= revivalPotionsRequired; - const enableAttack = (attackMode === 'autopilot' && !attackInProgress) || ((!isSavage || attackMode !== 'safe') && summit?.beast && !attackInProgress && selectedBeasts.length > 0 && hasEnoughRevivePotions); const highlightAttackButton = attackMode === 'autopilot' ? true : enableAttack; const enableExtraLifePotion = tokenBalances["EXTRA LIFE"] > 0; diff --git a/client/src/components/dialogs/AutopilotConfigModal.tsx b/client/src/components/dialogs/AutopilotConfigModal.tsx index 6dc3c0bb..d0f48e23 100644 --- a/client/src/components/dialogs/AutopilotConfigModal.tsx +++ b/client/src/components/dialogs/AutopilotConfigModal.tsx @@ -1,14 +1,16 @@ import type { AttackStrategy, ExtraLifeStrategy, - PoisonStrategy} from '@/stores/autopilotStore'; + PoisonStrategy, + TargetedPoisonBeast, +} from '@/stores/autopilotStore'; import { useAutopilotStore, } from '@/stores/autopilotStore'; import { gameColors } from '@/utils/themes'; import CloseIcon from '@mui/icons-material/Close'; import TuneIcon from '@mui/icons-material/Tune'; -import { Box, Button, CircularProgress, Dialog, IconButton, Switch, TextField, Typography } from '@mui/material'; +import { Box, Button, Checkbox, CircularProgress, Dialog, FormControlLabel, IconButton, Switch, TextField, Typography } from '@mui/material'; import { useController } from '@/contexts/controller'; import { lookupUsernames } from '@cartridge/controller'; import React from 'react'; @@ -84,6 +86,236 @@ const POISON_OPTIONS: { }, ]; +const QUEST_OPTIONS: { id: string; label: string; description: string }[] = [ + { id: 'attack_summit', label: 'First Blood', description: 'Prioritize beasts that have never attacked the Summit.' }, + { id: 'max_attack_streak', label: 'Consistency is Key', description: 'Prioritize beasts that haven\'t reached max attack streak of 10.' }, + { id: 'take_summit', label: 'Summit Conqueror', description: 'Prioritize beasts that haven\'t captured the Summit.' }, + { id: 'hold_summit_10s', label: 'Iron Grip', description: 'Prioritize beasts that haven\'t held the Summit for 10 seconds.' }, + { id: 'revival_potion', label: 'Second Wind', description: 'Prioritize beasts that haven\'t used a revival potion.' }, + { id: 'attack_potion', label: 'A Vital Boost', description: 'Prioritize beasts that haven\'t used an attack potion.' }, +]; + +interface TargetedPoisonSectionProps { + players: { name: string; address: string; amount: number }[]; + onAdd: (player: { name: string; address: string; amount: number }) => void; + onRemove: (address: string) => void; + onAmountChange: (address: string, amount: number) => void; + poisonAvailable: number; +} + +function TargetedPoisonSection({ players, onAdd, onRemove, onAmountChange, poisonAvailable }: TargetedPoisonSectionProps) { + const [input, setInput] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [resolved, setResolved] = React.useState(null); + const [defaultAmount, setDefaultAmount] = React.useState(100); + const debounceRef = React.useRef | null>(null); + + React.useEffect(() => { + setResolved(null); + setError(null); + const username = input.trim(); + if (!username) { setLoading(false); return; } + setLoading(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + try { + const result = await lookupUsernames([username]); + const address = result.get(username); + if (input.trim() !== username) return; + if (address) { setResolved(address); setError(null); } + else { setResolved(null); setError('Player not found'); } + } catch { setResolved(null); setError('Lookup failed'); } + finally { setLoading(false); } + }, 400); + return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [input]); + + const handleAdd = () => { + const username = input.trim(); + if (!username || !resolved) return; + onAdd({ name: username, address: resolved, amount: defaultAmount }); + setInput(''); + setResolved(null); + }; + + return ( + + + Targeted Poison Players + + Autopilot will poison the Summit whenever any of these players hold it. + + + + + setInput(e.target.value)} + sx={styles.ignoredInput} + /> + { + let v = Number.parseInt(e.target.value, 10); + if (Number.isNaN(v)) v = 1; + setDefaultAmount(Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); + }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} + sx={{ ...styles.numberField, width: 80 }} + /> + {loading && } + + {error && !loading && input.trim() && ( + {error} + )} + {resolved && !loading && ( + + {input.trim()} + Click to add ({defaultAmount} poison) + + )} + + {players.length > 0 && ( + + {players.map((player) => ( + + + {player.name} + onRemove(player.address)} sx={styles.ignoredPlayerRemove}> + + + + Poison + { + let v = Number.parseInt(e.target.value, 10); + if (Number.isNaN(v)) v = 1; + onAmountChange(player.address, Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); + }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} + sx={{ ...styles.numberField, width: 80 }} + /> + + ))} + + )} + + ); +} + +interface TargetedPoisonBeastSectionProps { + beasts: TargetedPoisonBeast[]; + onAdd: (beast: TargetedPoisonBeast) => void; + onRemove: (tokenId: number) => void; + onAmountChange: (tokenId: number, amount: number) => void; + poisonAvailable: number; +} + +function TargetedPoisonBeastSection({ beasts, onAdd, onRemove, onAmountChange, poisonAvailable }: TargetedPoisonBeastSectionProps) { + const [tokenIdInput, setTokenIdInput] = React.useState(''); + const [nameInput, setNameInput] = React.useState(''); + const [defaultAmount, setDefaultAmount] = React.useState(100); + + const handleAdd = () => { + const tokenId = Number.parseInt(tokenIdInput.trim(), 10); + if (!Number.isFinite(tokenId) || tokenId <= 0) return; + const name = nameInput.trim() || `Beast #${tokenId}`; + onAdd({ tokenId, name, amount: defaultAmount }); + setTokenIdInput(''); + setNameInput(''); + }; + + return ( + + + Targeted Poison Beasts + + Autopilot will poison the Summit whenever any of these beasts hold it (overrides player targeting). + + + + setTokenIdInput(e.target.value)} + inputProps={{ min: 1, step: 1 }} + sx={{ ...styles.numberField, width: 100 }} + /> + setNameInput(e.target.value)} + sx={{ ...styles.ignoredInput, flex: 1 }} + /> + { + let v = Number.parseInt(e.target.value, 10); + if (Number.isNaN(v)) v = 1; + setDefaultAmount(Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); + }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} + sx={{ ...styles.numberField, width: 80 }} + /> + + + {beasts.length > 0 && ( + + {beasts.map((beast) => ( + + + {beast.name} (#{beast.tokenId}) + onRemove(beast.tokenId)} sx={styles.ignoredPlayerRemove}> + + + + Poison + { + let v = Number.parseInt(e.target.value, 10); + if (Number.isNaN(v)) v = 1; + onAmountChange(beast.tokenId, Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); + }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} + sx={{ ...styles.numberField, width: 80 }} + /> + + ))} + + )} + + ); +} + function AutopilotConfigModal(props: AutopilotConfigModalProps) { const { open, close } = props; @@ -137,6 +369,18 @@ function AutopilotConfigModal(props: AutopilotConfigModalProps) { ignoredPlayers, addIgnoredPlayer, removeIgnoredPlayer, + targetedPoisonPlayers, + addTargetedPoisonPlayer, + removeTargetedPoisonPlayer, + setTargetedPoisonAmount, + targetedPoisonBeasts, + addTargetedPoisonBeast, + removeTargetedPoisonBeast, + setTargetedPoisonBeastAmount, + questMode, + setQuestMode, + questFilters, + setQuestFilters, resetToDefaults, } = useAutopilotStore(); @@ -199,6 +443,14 @@ function AutopilotConfigModal(props: AutopilotConfigModalProps) { setResolvedAddress(null); }; + const handleToggleQuestFilter = (questId: string) => { + if (questFilters.includes(questId)) { + setQuestFilters(questFilters.filter((f) => f !== questId)); + } else { + setQuestFilters([...questFilters, questId]); + } + }; + const reviveAvailable = tokenBalances?.['REVIVE'] ?? 0; const attackAvailable = tokenBalances?.['ATTACK'] ?? 0; const extraLifeAvailable = tokenBalances?.['EXTRA LIFE'] ?? 0; @@ -755,6 +1007,68 @@ function AutopilotConfigModal(props: AutopilotConfigModalProps) { )} + + + + + + + + + setQuestMode(!questMode)}> + setQuestMode(e.target.checked)} + onClick={(e) => e.stopPropagation()} + sx={styles.switch} + /> + + Quest Mode + + Prioritize beasts that haven't completed specific quests. + + + + {questMode && ( + + {QUEST_OPTIONS.map((quest) => ( + handleToggleQuestFilter(quest.id)} + sx={{ + color: `${gameColors.accentGreen}60`, + '&.Mui-checked': { color: gameColors.brightGreen }, + padding: '4px 8px', + }} + size="small" + /> + } + label={ + + {quest.label} + {quest.description} + + } + /> + ))} + + )} + {/* Footer */} diff --git a/client/src/hooks/useAutopilotOrchestrator.ts b/client/src/hooks/useAutopilotOrchestrator.ts new file mode 100644 index 00000000..85028f9a --- /dev/null +++ b/client/src/hooks/useAutopilotOrchestrator.ts @@ -0,0 +1,401 @@ +import { useController } from '@/contexts/controller'; +import { MAX_BEASTS_PER_ATTACK, useGameDirector } from '@/contexts/GameDirector'; +import { useAutopilotStore } from '@/stores/autopilotStore'; +import { useGameStore } from '@/stores/gameStore'; +import type { Beast } from '@/types/game'; +import React, { useEffect, useMemo, useReducer } from 'react'; +import { + calculateRevivalRequired, + isOwnerIgnored, isOwnerTargetedForPoison, getTargetedPoisonAmount, + isBeastTargetedForPoison, getTargetedBeastPoisonAmount, + hasDiplomacyMatch, selectOptimalBeasts, +} from '../utils/beasts'; + +export function useAutopilotOrchestrator() { + const { executeGameAction } = useGameDirector(); + const { tokenBalances } = useController(); + + const { selectedBeasts, summit, + attackInProgress, + applyingPotions, setApplyingPotions, setBattleEvents, setAttackInProgress, + collection, setSelectedBeasts, attackMode, autopilotLog, setAutopilotLog, + autopilotEnabled, setAutopilotEnabled, setAppliedExtraLifePotions } = useGameStore(); + const { + attackStrategy, + extraLifeStrategy, + extraLifeMax, + extraLifeTotalMax, + extraLifeReplenishTo, + extraLifePotionsUsed, + useRevivePotions, + revivePotionMax, + revivePotionMaxPerBeast, + useAttackPotions, + attackPotionMax, + attackPotionMaxPerBeast, + revivePotionsUsed, + attackPotionsUsed, + setRevivePotionsUsed, + setAttackPotionsUsed, + setExtraLifePotionsUsed, + setPoisonPotionsUsed, + poisonStrategy, + poisonTotalMax, + poisonPotionsUsed, + poisonConservativeExtraLivesTrigger, + poisonConservativeAmount, + poisonAggressiveAmount, + poisonMinPower, + poisonMinHealth, + maxBeastsPerAttack, + skipSharedDiplomacy, + ignoredPlayers, + targetedPoisonPlayers, + targetedPoisonBeasts, + questMode, + questFilters, + } = useAutopilotStore(); + + const [triggerAutopilot, setTriggerAutopilot] = useReducer((x: number) => x + 1, 0); + const poisonedTokenIdRef = React.useRef(null); + + const isSavage = Boolean(collection.find(beast => beast.token_id === summit?.beast?.token_id)); + const revivalPotionsRequired = calculateRevivalRequired(selectedBeasts); + const hasEnoughRevivePotions = (tokenBalances["REVIVE"] || 0) >= revivalPotionsRequired; + const enableAttack = (attackMode === 'autopilot' && !attackInProgress) || ((!isSavage || attackMode !== 'safe') && summit?.beast && !attackInProgress && selectedBeasts.length > 0 && hasEnoughRevivePotions); + + // ── Beast selection ────────────────────────────────────────────────── + + const collectionWithCombat = useMemo(() => { + if (summit && collection.length > 0) { + return selectOptimalBeasts(collection, summit, { + useRevivePotions, + revivePotionMax, + revivePotionMaxPerBeast, + revivePotionsUsed, + useAttackPotions, + attackPotionMax, + attackPotionMaxPerBeast, + attackPotionsUsed, + autopilotEnabled, + questMode, + questFilters, + }); + } + + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [summit?.beast?.token_id, summit?.beast?.extra_lives, summit?.beast?.current_health, collection.length, revivePotionsUsed, attackPotionsUsed, useRevivePotions, useAttackPotions, questMode, questFilters, maxBeastsPerAttack, attackStrategy, autopilotEnabled]); + + // ── Handlers ───────────────────────────────────────────────────────── + + const handleApplyExtraLife = (amount: number) => { + if (!summit?.beast || !isSavage || applyingPotions || amount === 0) return; + + setApplyingPotions(true); + setAutopilotLog('Adding extra lives...'); + + executeGameAction({ + type: 'add_extra_life', + beastId: summit.beast.token_id, + extraLifePotions: amount, + }); + }; + + const handleApplyPoison = (amount: number, beastId?: number): boolean => { + const targetId = beastId ?? summit?.beast?.token_id; + if (!targetId || applyingPotions || amount === 0) return false; + + setApplyingPotions(true); + setAutopilotLog('Applying poison...'); + + executeGameAction({ + type: 'apply_poison', + beastId: targetId, + count: amount, + }); + return true; + }; + + const handleAttackUntilCapture = async (extraLifePotions: number) => { + const { attackInProgress: alreadyAttacking, applyingPotions: alreadyApplying } = useGameStore.getState(); + if (!enableAttack || alreadyAttacking || alreadyApplying) return; + + setBattleEvents([]); + setAttackInProgress(true); + + const allBeasts: [Beast, number, number][] = collectionWithCombat.map((beast: Beast) => [beast, 1, beast.combat?.attackPotions || 0]); + + const batches: [Beast, number, number][][] = []; + for (let i = 0; i < allBeasts.length; i += MAX_BEASTS_PER_ATTACK) { + batches.push(allBeasts.slice(i, i + MAX_BEASTS_PER_ATTACK)); + } + + const poisonedThisSequence = new Set(); + + for (const batch of batches) { + // Between batches: check if summit changed to an ignored or diplomacy-matched player + const currentSummit = useGameStore.getState().summit; + if (currentSummit) { + const { ignoredPlayers: ig, skipSharedDiplomacy: skipDip, targetedPoisonPlayers: tpp } = useAutopilotStore.getState(); + const currentCollection = useGameStore.getState().collection; + const isMyBeast = currentCollection.some((b: Beast) => b.token_id === currentSummit.beast.token_id); + + if (isMyBeast) { + setAutopilotLog('Summit captured — halting attack'); + break; + } + if (isOwnerIgnored(currentSummit.owner, ig)) { + setAutopilotLog('Halted: ignored player took summit'); + break; + } + if (skipDip && hasDiplomacyMatch(currentCollection, currentSummit.beast)) { + setAutopilotLog('Halted: shared diplomacy'); + break; + } + + // Fire targeted poison between batches (once per target per sequence) + if (!poisonedThisSequence.has(currentSummit.beast.token_id)) { + const { poisonTotalMax: ptm, poisonPotionsUsed: ppu, targetedPoisonBeasts: tpb } = useAutopilotStore.getState(); + const isBeastTarget = tpb.length > 0 && isBeastTargetedForPoison(currentSummit.beast.token_id, tpb); + if (isBeastTarget) { + const beastAmount = getTargetedBeastPoisonAmount(currentSummit.beast.token_id, tpb); + const remainingCap = Math.max(0, ptm - ppu); + const pb = tokenBalances?.["POISON"] || 0; + const amount = Math.min(beastAmount, pb, remainingCap); + if (amount > 0) { + handleApplyPoison(amount, currentSummit.beast.token_id); + poisonedThisSequence.add(currentSummit.beast.token_id); + } + } else if (tpp.length > 0 && isOwnerTargetedForPoison(currentSummit.owner, tpp)) { + const playerAmount = getTargetedPoisonAmount(currentSummit.owner, tpp); + const remainingCap = Math.max(0, ptm - ppu); + const pb = tokenBalances?.["POISON"] || 0; + const amount = Math.min(playerAmount, pb, remainingCap); + if (amount > 0) { + handleApplyPoison(amount, currentSummit.beast.token_id); + poisonedThisSequence.add(currentSummit.beast.token_id); + } + } + } + } + + const result = await executeGameAction({ + type: 'attack_until_capture', + beasts: batch, + extraLifePotions + }); + + if (!result) { + setAttackInProgress(false); + return; + } + } + + setAttackInProgress(false); + }; + + const startAutopilot = () => { + setRevivePotionsUsed(() => 0); + setAttackPotionsUsed(() => 0); + setExtraLifePotionsUsed(() => 0); + setPoisonPotionsUsed(() => 0); + setAutopilotEnabled(true); + }; + + const stopAutopilot = () => { + setAutopilotEnabled(false); + }; + + // ── Effects ────────────────────────────────────────────────────────── + + // Reset state when attack mode changes + useEffect(() => { + if (attackMode === 'autopilot') { + setSelectedBeasts([]); + setAppliedExtraLifePotions(0); + } + + if (attackMode !== 'autopilot' && autopilotEnabled) { + setAutopilotEnabled(false); + poisonedTokenIdRef.current = null; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [attackMode]); + + // Diplomacy / ignored player memos + const summitSharesDiplomacy = useMemo(() => { + if (!skipSharedDiplomacy || !summit?.beast) return false; + return collection.some( + (beast: Beast) => + beast.diplomacy && + beast.prefix === summit.beast.prefix && + beast.suffix === summit.beast.suffix, + ); + }, [skipSharedDiplomacy, summit?.beast?.token_id, collection.length]); + + const summitOwnerIgnored = useMemo(() => { + if (ignoredPlayers.length === 0 || !summit?.owner) return false; + const ownerNormalized = summit.owner.replace(/^0x0+/, '0x').toLowerCase(); + return ignoredPlayers.some((p) => p.address === ownerNormalized); + }, [ignoredPlayers, summit?.owner]); + + const shouldSkipSummit = summitSharesDiplomacy || summitOwnerIgnored; + + // Autopilot status log + useEffect(() => { + if (autopilotEnabled && !attackInProgress && !applyingPotions) { + if (summitSharesDiplomacy) { + setAutopilotLog('Ignoring shared diplomacy'); + } else if (summitOwnerIgnored) { + const owner = summit?.owner?.replace(/^0x0+/, '0x').toLowerCase(); + const player = ignoredPlayers.find((p) => p.address === owner); + setAutopilotLog(`Ignoring ${player?.name ?? 'player'}`); + } else { + setAutopilotLog('Waiting for trigger...'); + } + } else if (attackInProgress) { + setAutopilotLog('Attacking...'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autopilotEnabled, attackInProgress, applyingPotions, summitSharesDiplomacy, summitOwnerIgnored]); + + // Targeted + aggressive poison on summit change or config change + useEffect(() => { + if (!autopilotEnabled || !summit?.beast) return; + + const { attackInProgress: attacking, applyingPotions: applying } = useGameStore.getState(); + if (attacking || applying) return; + + const myBeast = collection.find((beast: Beast) => beast.token_id === summit.beast.token_id); + if (myBeast) return; + + // Beast-level targeted poison (highest priority) + const isBeastTarget = targetedPoisonBeasts.length > 0 && isBeastTargetedForPoison(summit.beast.token_id, targetedPoisonBeasts); + if (isBeastTarget) { + const beastAmount = getTargetedBeastPoisonAmount(summit.beast.token_id, targetedPoisonBeasts); + const remainingCap = Math.max(0, poisonTotalMax - poisonPotionsUsed); + const pb = tokenBalances?.["POISON"] || 0; + const amount = Math.min(beastAmount, pb, remainingCap); + if (amount > 0) handleApplyPoison(amount, summit.beast.token_id); + return; + } + + // Player-level targeted poison + const isTargeted = targetedPoisonPlayers.length > 0 && isOwnerTargetedForPoison(summit.owner, targetedPoisonPlayers); + if (isTargeted) { + const playerAmount = getTargetedPoisonAmount(summit.owner, targetedPoisonPlayers); + const remainingCap = Math.max(0, poisonTotalMax - poisonPotionsUsed); + const pb = tokenBalances?.["POISON"] || 0; + const amount = Math.min(playerAmount, pb, remainingCap); + if (amount > 0) handleApplyPoison(amount, summit.beast.token_id); + return; + } + + if (poisonStrategy !== 'aggressive') return; + if (shouldSkipSummit) return; + + // Reset tracked token when summit beast changes + if (poisonedTokenIdRef.current !== summit.beast.token_id) { + poisonedTokenIdRef.current = null; + } + if (poisonedTokenIdRef.current === summit.beast.token_id) return; + + if (poisonMinPower > 0 && summit.beast.power < poisonMinPower) return; + if (poisonMinHealth > 0 && summit.beast.current_health < poisonMinHealth) return; + + const remainingCap = Math.max(0, poisonTotalMax - poisonPotionsUsed); + const pb = tokenBalances?.["POISON"] || 0; + const amount = Math.min(poisonAggressiveAmount, pb, remainingCap); + if (amount > 0 && handleApplyPoison(amount, summit.beast.token_id)) { + poisonedTokenIdRef.current = summit.beast.token_id; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [summit?.beast?.token_id, autopilotEnabled, targetedPoisonPlayers, targetedPoisonBeasts, poisonTotalMax]); + + // Main autopilot attack + conservative poison + extra life logic + useEffect(() => { + if (!autopilotEnabled || attackInProgress || !collectionWithCombat || !summit) return; + + const myBeast = collection.find((beast: Beast) => beast.token_id === summit?.beast.token_id); + + if (myBeast) { + if (extraLifeStrategy === 'aggressive' && myBeast.extra_lives >= 0 && myBeast.extra_lives < extraLifeReplenishTo) { + const extraLifePotions = Math.min(extraLifeTotalMax - extraLifePotionsUsed, extraLifeReplenishTo - myBeast.extra_lives); + if (extraLifePotions > 0) { + handleApplyExtraLife(extraLifePotions); + } + } + + return; + } + + if (shouldSkipSummit) return; + + if (poisonStrategy === 'conservative' + && summit.beast.extra_lives >= poisonConservativeExtraLivesTrigger + && summit.poison_count < poisonConservativeAmount + && poisonedTokenIdRef.current !== summit.beast.token_id + && (poisonMinPower <= 0 || summit.beast.power >= poisonMinPower) + && (poisonMinHealth <= 0 || summit.beast.current_health >= poisonMinHealth)) { + const remainingCap = Math.max(0, poisonTotalMax - poisonPotionsUsed); + const poisonBalance = tokenBalances?.["POISON"] || 0; + const amount = Math.min(poisonConservativeAmount - summit.poison_count, poisonBalance, remainingCap); + if (amount > 0 && handleApplyPoison(amount)) { + poisonedTokenIdRef.current = summit.beast.token_id; + } + } + + let extraLifePotions = 0; + if (extraLifeStrategy === 'after_capture') { + extraLifePotions = Math.min(extraLifeTotalMax - extraLifePotionsUsed, extraLifeMax); + } else if (extraLifeStrategy === 'aggressive') { + extraLifePotions = Math.min(extraLifeTotalMax - extraLifePotionsUsed, extraLifeReplenishTo); + } + + if (attackStrategy === 'never') { + return; + } else if (attackStrategy === 'all_out') { + handleAttackUntilCapture(extraLifePotions); + } else if (attackStrategy === 'guaranteed') { + const beasts = collectionWithCombat.slice(0, maxBeastsPerAttack); + + const totalSummitHealth = ((summit.beast.health + summit.beast.bonus_health) * summit.beast.extra_lives) + summit.beast.current_health; + const totalEstimatedDamage = beasts.reduce((acc, beast) => acc + (beast.combat?.estimatedDamage ?? 0), 0); + if (totalEstimatedDamage < (totalSummitHealth * 1.1)) { + return; + } + + executeGameAction({ + type: 'attack', + beasts: beasts.map((beast: Beast) => ([beast, 1, beast.combat?.attackPotions || 0])), + safeAttack: false, + vrf: true, + extraLifePotions: extraLifePotions, + attackPotions: beasts[0]?.combat?.attackPotions || 0 + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collectionWithCombat, autopilotEnabled, summit?.beast.extra_lives, triggerAutopilot]); + + // Re-trigger autopilot when summit beast is about to die (0 extra lives, 1 HP) + useEffect(() => { + if (autopilotEnabled && !attackInProgress && summit?.beast.extra_lives === 0 && summit?.beast.current_health === 1) { + setTriggerAutopilot(); + } + }, [autopilotEnabled, summit?.beast.current_health]); + + // ── Return values needed by ActionBar UI ───────────────────────────── + + return { + collectionWithCombat, + isSavage, + enableAttack, + revivalPotionsRequired, + autopilotLog, + startAutopilot, + stopAutopilot, + handleApplyExtraLife, + handleApplyPoison, + }; +} diff --git a/client/src/stores/autopilotStore.ts b/client/src/stores/autopilotStore.ts index 1b8de153..6d7e3ffb 100644 --- a/client/src/stores/autopilotStore.ts +++ b/client/src/stores/autopilotStore.ts @@ -7,6 +7,18 @@ export interface IgnoredPlayer { address: string; } +export interface TargetedPoisonPlayer { + name: string; + address: string; + amount: number; // poison potions to apply when this player holds summit +} + +export interface TargetedPoisonBeast { + tokenId: number; + name: string; // display name, e.g. "'Skull Peak' Manticore" + amount: number; +} + export type ExtraLifeStrategy = 'disabled' | 'after_capture' | 'aggressive'; export type PoisonStrategy = 'disabled' | 'conservative' | 'aggressive'; @@ -64,6 +76,15 @@ interface AutopilotConfig { poisonMinPower: number; // Only poison when summit beast health >= this value (0 = no threshold) poisonMinHealth: number; + + // Quest mode: prioritize beasts that haven't completed specific quests + questMode: boolean; + // Quest IDs to prioritize, e.g. ['take_summit', 'revival_potion', 'attack_potion'] + questFilters: string[]; + // Players whose summit beasts should always be poisoned (with custom amounts) + targetedPoisonPlayers: TargetedPoisonPlayer[]; + // Specific beasts (by token ID) that should always be poisoned when on summit + targetedPoisonBeasts: TargetedPoisonBeast[]; } type AutopilotPersistedConfig = AutopilotConfig; @@ -78,6 +99,10 @@ type AutopilotConfigStorageShape = Partial & { poisonTotalMax?: unknown; poisonMinPower?: unknown; poisonMinHealth?: unknown; + questMode?: unknown; + questFilters?: unknown; + targetedPoisonPlayers?: unknown; + targetedPoisonBeasts?: unknown; }; interface AutopilotState extends AutopilotPersistedConfig, AutopilotSessionCounters { @@ -106,6 +131,14 @@ interface AutopilotState extends AutopilotPersistedConfig, AutopilotSessionCount setPoisonAggressiveAmount: (poisonAggressiveAmount: number) => void; setPoisonMinPower: (poisonMinPower: number) => void; setPoisonMinHealth: (poisonMinHealth: number) => void; + setQuestMode: (questMode: boolean) => void; + setQuestFilters: (questFilters: string[]) => void; + addTargetedPoisonPlayer: (player: TargetedPoisonPlayer) => void; + removeTargetedPoisonPlayer: (address: string) => void; + setTargetedPoisonAmount: (address: string, amount: number) => void; + addTargetedPoisonBeast: (beast: TargetedPoisonBeast) => void; + removeTargetedPoisonBeast: (tokenId: number) => void; + setTargetedPoisonBeastAmount: (tokenId: number, amount: number) => void; /** * "Used" fields are counters. * - If passed a number, it is treated as an amount to ADD. @@ -142,6 +175,10 @@ const DEFAULT_CONFIG: AutopilotPersistedConfig = { poisonAggressiveAmount: 100, poisonMinPower: 0, poisonMinHealth: 0, + questMode: false, + questFilters: [], + targetedPoisonPlayers: [], + targetedPoisonBeasts: [], }; const DEFAULT_SESSION_COUNTERS: AutopilotSessionCounters = { @@ -272,6 +309,29 @@ function loadConfigFromStorage(): AutopilotPersistedConfig | null { parsed.poisonMinHealth, DEFAULT_CONFIG.poisonMinHealth, ), + questMode: + typeof parsed.questMode === 'boolean' + ? parsed.questMode + : DEFAULT_CONFIG.questMode, + questFilters: Array.isArray(parsed.questFilters) + ? parsed.questFilters.filter((f): f is string => typeof f === 'string') + : DEFAULT_CONFIG.questFilters, + targetedPoisonPlayers: Array.isArray(parsed.targetedPoisonPlayers) + ? (parsed.targetedPoisonPlayers as TargetedPoisonPlayer[]) + .filter( + (p): p is TargetedPoisonPlayer => + typeof p === 'object' && p !== null && typeof p.name === 'string' && typeof p.address === 'string', + ) + .map((p) => ({ ...p, amount: sanitizeNonNegativeInt(p.amount, 100) })) + : DEFAULT_CONFIG.targetedPoisonPlayers, + targetedPoisonBeasts: Array.isArray(parsed.targetedPoisonBeasts) + ? (parsed.targetedPoisonBeasts as TargetedPoisonBeast[]) + .filter( + (b): b is TargetedPoisonBeast => + typeof b === 'object' && b !== null && typeof b.tokenId === 'number' && typeof b.name === 'string', + ) + .map((b) => ({ ...b, amount: sanitizeNonNegativeInt(b.amount, 100) })) + : DEFAULT_CONFIG.targetedPoisonBeasts, }; } catch { return null; @@ -362,6 +422,10 @@ export const useAutopilotStore = create((set, get) => { partial.poisonMinHealth ?? get().poisonMinHealth, DEFAULT_CONFIG.poisonMinHealth, ), + questMode: partial.questMode ?? get().questMode, + questFilters: partial.questFilters ?? get().questFilters, + targetedPoisonPlayers: partial.targetedPoisonPlayers ?? get().targetedPoisonPlayers, + targetedPoisonBeasts: partial.targetedPoisonBeasts ?? get().targetedPoisonBeasts, }; try { @@ -445,6 +509,49 @@ export const useAutopilotStore = create((set, get) => { set(() => persist({ poisonMinPower })), setPoisonMinHealth: (poisonMinHealth: number) => set(() => persist({ poisonMinHealth })), + setQuestMode: (questMode: boolean) => + set(() => persist({ questMode })), + setQuestFilters: (questFilters: string[]) => + set(() => persist({ questFilters: questFilters.filter((f) => typeof f === 'string') })), + addTargetedPoisonPlayer: (player: TargetedPoisonPlayer) => + set(() => { + const current = get().targetedPoisonPlayers; + const normalized = player.address.replace(/^0x0+/, '0x').toLowerCase(); + if (current.some((p) => p.address === normalized)) return {}; + return persist({ targetedPoisonPlayers: [...current, { name: player.name, address: normalized, amount: Math.max(1, Math.floor(player.amount)) }] }); + }), + removeTargetedPoisonPlayer: (address: string) => + set(() => { + const normalized = address.replace(/^0x0+/, '0x').toLowerCase(); + return persist({ targetedPoisonPlayers: get().targetedPoisonPlayers.filter((p) => p.address !== normalized) }); + }), + setTargetedPoisonAmount: (address: string, amount: number) => + set(() => { + const normalized = address.replace(/^0x0+/, '0x').toLowerCase(); + return persist({ + targetedPoisonPlayers: get().targetedPoisonPlayers.map((p) => + p.address === normalized ? { ...p, amount: Math.max(1, Math.floor(amount)) } : p, + ), + }); + }), + addTargetedPoisonBeast: (beast: TargetedPoisonBeast) => + set(() => { + const current = get().targetedPoisonBeasts; + if (current.some((b) => b.tokenId === beast.tokenId)) return {}; + return persist({ targetedPoisonBeasts: [...current, { ...beast, amount: Math.max(1, Math.floor(beast.amount)) }] }); + }), + removeTargetedPoisonBeast: (tokenId: number) => + set(() => { + return persist({ targetedPoisonBeasts: get().targetedPoisonBeasts.filter((b) => b.tokenId !== tokenId) }); + }), + setTargetedPoisonBeastAmount: (tokenId: number, amount: number) => + set(() => { + return persist({ + targetedPoisonBeasts: get().targetedPoisonBeasts.map((b) => + b.tokenId === tokenId ? { ...b, amount: Math.max(1, Math.floor(amount)) } : b, + ), + }); + }), setRevivePotionsUsed: (update) => updateCounter('revivePotionsUsed', update), setAttackPotionsUsed: (update) => updateCounter('attackPotionsUsed', update), setExtraLifePotionsUsed: (update) => updateCounter('extraLifePotionsUsed', update), diff --git a/client/src/utils/beasts.test.ts b/client/src/utils/beasts.test.ts index e94f8531..fe7285cb 100644 --- a/client/src/utils/beasts.test.ts +++ b/client/src/utils/beasts.test.ts @@ -15,6 +15,9 @@ import { calculateOptimalAttackPotions, calculateMaxAttackPotions, calculateRevivalRequired, + selectOptimalBeasts, + streakUrgencyScore, + questUrgencyScore, BEAST_LOCK_DURATION_MS, } from "./beasts"; @@ -819,3 +822,201 @@ describe("calculateRevivalRequired", () => { expect(calculateRevivalRequired([])).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// streakUrgencyScore +// --------------------------------------------------------------------------- +describe("streakUrgencyScore", () => { + it("returns 0 for a beast that already completed the quest", () => { + const beast = makeBeast({ max_attack_streak: true, attack_streak: 10 }); + expect(streakUrgencyScore(beast)).toBe(0); + }); + + it("returns 0 for a beast with attack_streak 0", () => { + const beast = makeBeast({ attack_streak: 0 }); + expect(streakUrgencyScore(beast)).toBe(0); + }); + + it("returns 0 for a beast whose streak has already reset (>48h since death)", () => { + const now = Date.now() / 1000; + const beast = makeBeast({ + attack_streak: 5, + last_death_timestamp: now - 86400 * 3, // 72h ago + }); + expect(streakUrgencyScore(beast)).toBe(0); + }); + + it("returns higher score for higher streak (both at same time remaining)", () => { + const now = Date.now() / 1000; + const recentDeath = now - 86400; // 24h ago = 24h remaining + const beastStreak3 = makeBeast({ attack_streak: 3, last_death_timestamp: recentDeath }); + const beastStreak8 = makeBeast({ attack_streak: 8, last_death_timestamp: recentDeath }); + expect(streakUrgencyScore(beastStreak8)).toBeGreaterThan(streakUrgencyScore(beastStreak3)); + }); + + it("returns higher score when streak is about to expire vs plenty of time", () => { + const now = Date.now() / 1000; + const beastSafeTime = makeBeast({ + attack_streak: 5, + last_death_timestamp: now - 3600, // 1h ago = 47h remaining + }); + const beastUrgent = makeBeast({ + attack_streak: 5, + last_death_timestamp: now - 86400 * 2 + 3600, // 1h remaining + }); + expect(streakUrgencyScore(beastUrgent)).toBeGreaterThan(streakUrgencyScore(beastSafeTime)); + }); + + it("beast at streak 9 with 2h left scores very high", () => { + const now = Date.now() / 1000; + const beast = makeBeast({ + attack_streak: 9, + last_death_timestamp: now - 86400 * 2 + 7200, // 2h remaining + }); + const score = streakUrgencyScore(beast); + // Progress: 9/10 * 50 = 45. Time: ~(1 - 7200/172800) * 50 ≈ 47.9. Total ≈ 92.9 + expect(score).toBeGreaterThan(85); + }); +}); + +// --------------------------------------------------------------------------- +// questUrgencyScore +// --------------------------------------------------------------------------- +describe("questUrgencyScore", () => { + it("returns 0 when max_attack_streak is not in quest filters", () => { + const now = Date.now() / 1000; + const beast = makeBeast({ attack_streak: 9, last_death_timestamp: now - 86400 }); + expect(questUrgencyScore(beast, ["take_summit", "attack_summit"])).toBe(0); + }); + + it("returns streak urgency when max_attack_streak is in quest filters", () => { + const now = Date.now() / 1000; + const beast = makeBeast({ attack_streak: 7, last_death_timestamp: now - 86400 }); + const score = questUrgencyScore(beast, ["max_attack_streak"]); + expect(score).toBeGreaterThan(0); + expect(score).toBe(streakUrgencyScore(beast)); + }); +}); + +// --------------------------------------------------------------------------- +// selectOptimalBeasts — quest mode streak urgency integration +// --------------------------------------------------------------------------- +describe("selectOptimalBeasts quest mode", () => { + const baseConfig = { + useRevivePotions: false, + revivePotionMax: 0, + revivePotionMaxPerBeast: 0, + revivePotionsUsed: 0, + useAttackPotions: false, + attackPotionMax: 0, + attackPotionMaxPerBeast: 0, + attackPotionsUsed: 0, + autopilotEnabled: true, + questMode: true, + questFilters: ["max_attack_streak"], + }; + + it("prioritizes beast with higher streak when both need the quest", () => { + const now = Date.now() / 1000; + const summit = makeSummit({ power: 10, current_health: 50, health: 50, extra_lives: 0 }); + + const beastLowStreak = makeBeast({ + token_id: 1, + power: 50, + current_health: 100, + health: 100, + attack_streak: 2, + last_death_timestamp: now - 86400, + }); + const beastHighStreak = makeBeast({ + token_id: 2, + power: 50, + current_health: 100, + health: 100, + attack_streak: 8, + last_death_timestamp: now - 86400, + }); + + const result = selectOptimalBeasts([beastLowStreak, beastHighStreak], summit, baseConfig); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].token_id).toBe(2); // high streak beast first + }); + + it("prioritizes beast with expiring streak over safe streak", () => { + const now = Date.now() / 1000; + const summit = makeSummit({ power: 10, current_health: 50, health: 50, extra_lives: 0 }); + + const beastSafe = makeBeast({ + token_id: 1, + power: 50, + current_health: 100, + health: 100, + attack_streak: 5, + last_death_timestamp: now - 3600, // 47h remaining + }); + const beastExpiring = makeBeast({ + token_id: 2, + power: 50, + current_health: 100, + health: 100, + attack_streak: 5, + last_death_timestamp: now - 86400 * 2 + 3600, // 1h remaining + }); + + const result = selectOptimalBeasts([beastSafe, beastExpiring], summit, baseConfig); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].token_id).toBe(2); // expiring beast first + }); + + it("does not boost completed beasts even with high streak", () => { + const now = Date.now() / 1000; + const summit = makeSummit({ power: 10, current_health: 50, health: 50, extra_lives: 0 }); + + const beastCompleted = makeBeast({ + token_id: 1, + power: 50, + current_health: 100, + health: 100, + attack_streak: 10, + max_attack_streak: true, + last_death_timestamp: now - 86400, + }); + const beastNeeds = makeBeast({ + token_id: 2, + power: 50, + current_health: 100, + health: 100, + attack_streak: 3, + last_death_timestamp: now - 86400, + }); + + const result = selectOptimalBeasts([beastCompleted, beastNeeds], summit, baseConfig); + expect(result.length).toBeGreaterThanOrEqual(2); + // Beast that still needs the quest should be prioritized + expect(result[0].token_id).toBe(2); + }); + + it("Summit Conqueror prioritizes beasts that haven't captured summit", () => { + const summit = makeSummit({ power: 10, current_health: 50, health: 50, extra_lives: 0 }); + + const beastCaptured = makeBeast({ + token_id: 1, + power: 50, + current_health: 100, + health: 100, + captured_summit: true, + }); + const beastNever = makeBeast({ + token_id: 2, + power: 50, + current_health: 100, + health: 100, + captured_summit: false, + }); + + const config = { ...baseConfig, questFilters: ["take_summit"] }; + const result = selectOptimalBeasts([beastCaptured, beastNever], summit, config); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].token_id).toBe(2); + }); +}); diff --git a/client/src/utils/beasts.ts b/client/src/utils/beasts.ts index bbb98dba..0bd86ba8 100644 --- a/client/src/utils/beasts.ts +++ b/client/src/utils/beasts.ts @@ -1,4 +1,5 @@ import type { Beast, Combat, Summit, selection } from '@/types/game'; +import type { IgnoredPlayer, TargetedPoisonPlayer } from '@/stores/autopilotStore'; import { BEAST_NAMES, BEAST_TIERS, BEAST_TYPES, ITEM_NAME_PREFIXES, ITEM_NAME_SUFFIXES } from './BeastData'; import type { SoundName } from '@/contexts/sound'; import * as starknet from "@scure/starknet"; @@ -357,3 +358,400 @@ export const calculateRevivalRequired = (selectedBeasts: selection) => { } }, 0); } + +function normalizeAddress(address: string): string { + return address.replace(/^0x0+/, '0x').toLowerCase(); +} + +export function isOwnerIgnored(summitOwner: string, ignoredPlayers: IgnoredPlayer[]): boolean { + if (ignoredPlayers.length === 0) return false; + const normalized = normalizeAddress(summitOwner); + return ignoredPlayers.some((p) => p.address === normalized); +} + +export function isOwnerTargetedForPoison(summitOwner: string, targetedPlayers: TargetedPoisonPlayer[]): boolean { + if (targetedPlayers.length === 0) return false; + const normalized = normalizeAddress(summitOwner); + return targetedPlayers.some((p) => p.address === normalized); +} + +export function getTargetedPoisonAmount(summitOwner: string, targetedPlayers: { address: string; amount: number }[]): number { + if (targetedPlayers.length === 0) return 0; + const normalized = normalizeAddress(summitOwner); + const match = targetedPlayers.find((p) => p.address === normalized); + return match?.amount ?? 0; +} + +export function isBeastTargetedForPoison(beastTokenId: number, targetedBeasts: { tokenId: number }[]): boolean { + return targetedBeasts.length > 0 && targetedBeasts.some((b) => b.tokenId === beastTokenId); +} + +export function getTargetedBeastPoisonAmount(beastTokenId: number, targetedBeasts: { tokenId: number; amount: number }[]): number { + if (targetedBeasts.length === 0) return 0; + return targetedBeasts.find((b) => b.tokenId === beastTokenId)?.amount ?? 0; +} + +export function hasDiplomacyMatch(playerBeasts: Beast[], summitBeast: Beast): boolean { + return playerBeasts.some( + (beast) => beast.diplomacy && beast.prefix === summitBeast.prefix && beast.suffix === summitBeast.suffix + ); +} + +export interface SelectedBeast { + beast: Beast; + reviveCost: number; + attackPotions: number; +} + +export interface SelectOptimalBeastsConfig { + useRevivePotions: boolean; + revivePotionMax: number; + revivePotionMaxPerBeast: number; + revivePotionsUsed: number; + useAttackPotions: boolean; + attackPotionMax: number; + attackPotionMaxPerBeast: number; + attackPotionsUsed: number; + autopilotEnabled: boolean; + questMode: boolean; + questFilters: string[]; + maxBeasts?: number; // limit total beasts selected +} + +export function selectOptimalBeasts( + collection: Beast[], + summit: Summit, + config: SelectOptimalBeastsConfig, +): Beast[] { + const revivePotionsEnabled = config.autopilotEnabled && config.useRevivePotions && config.revivePotionsUsed < config.revivePotionMax; + const attackPotionsEnabled = config.autopilotEnabled && config.useAttackPotions && config.attackPotionsUsed < config.attackPotionMax; + + // Compute combat and filter locked beasts + let filtered = collection.map((beast: Beast) => { + const newBeast = { ...beast }; + newBeast.revival_time = getBeastRevivalTime(newBeast); + newBeast.current_health = getBeastCurrentHealth(beast); + newBeast.combat = calculateBattleResult(newBeast, summit, 0); + return newBeast; + }).filter((beast: Beast) => !isBeastLocked(beast)); + + // Separate alive and dead pools + const alive = filtered.filter((b) => b.current_health > 0); + const dead = filtered.filter((b) => b.current_health === 0); + + // Build quest predicate set for prioritization + const questPredicates = config.questMode + ? config.questFilters.map(questNeedsPredicate).filter((p): p is (b: Beast) => boolean => p !== null) + : []; + const needsAnyQuest = (b: Beast) => questPredicates.some((p) => p(b)); + + // Sort both by combat score desc, with quest-needing beasts boosted + const hasStreakQuest = config.questMode && config.questFilters.includes('max_attack_streak'); + const sortWithQuestBoost = (a: Beast, b: Beast) => { + if (questPredicates.length > 0) { + const aNeeds = needsAnyQuest(a); + const bNeeds = needsAnyQuest(b); + if (aNeeds && !bNeeds) { + // Boost a if its damage is at least 50% of b's + if ((a.combat?.estimatedDamage ?? 0) >= (b.combat?.estimatedDamage ?? 0) * 0.5) return -1; + } + if (bNeeds && !aNeeds) { + if ((b.combat?.estimatedDamage ?? 0) >= (a.combat?.estimatedDamage ?? 0) * 0.5) return 1; + } + // Both need quests — prefer higher urgency (e.g. streak about to expire) + if (aNeeds && bNeeds && hasStreakQuest) { + const aUrgency = questUrgencyScore(a, config.questFilters); + const bUrgency = questUrgencyScore(b, config.questFilters); + if (aUrgency !== bUrgency) return bUrgency - aUrgency; + } + } + return (b.combat?.score ?? -Infinity) - (a.combat?.score ?? -Infinity); + }; + alive.sort(sortWithQuestBoost); + dead.sort(sortWithQuestBoost); + + if (!revivePotionsEnabled) { + // No revives — just use alive beasts, optionally with attack potions on top beast + filtered = config.maxBeasts ? alive.slice(0, config.maxBeasts) : alive; + + if (attackPotionsEnabled && filtered.length > 0) { + const attackSelection: selection[number] = [filtered[0], 1, 0]; + const potions = calculateOptimalAttackPotions( + attackSelection, + summit, + Math.min(config.attackPotionMax - config.attackPotionsUsed, config.attackPotionMaxPerBeast, 255), + ); + filtered[0] = { ...filtered[0], combat: calculateBattleResult(filtered[0], summit, potions) }; + } + + // Quest boosting for alive-only path + if (config.questMode && config.questFilters.length > 0) { + filtered = applyQuestBoost(filtered, dead, summit, config, attackPotionsEnabled); + } + + return filtered; + } + + // Cost-aware selection with revives + let reviveBudget = config.revivePotionMax - config.revivePotionsUsed; + const attackBudget = attackPotionsEnabled + ? config.attackPotionMax - config.attackPotionsUsed + : 0; + + // Revive potions are ~10x more expensive than attack potions in practice. + // Weight revive cost accordingly so the algorithm prefers cheap revive + attack potions + // over expensive revive alone. + const REVIVE_WEIGHT = 10; + + interface Candidate { + beast: Beast; + damage: number; + reviveCost: number; + attackPotions: number; + weightedCost: number; // reviveCost * REVIVE_WEIGHT + attackPotions + } + + const buildCandidate = (beast: Beast, reviveCost: number): Candidate => { + const baseDamage = beast.combat?.estimatedDamage ?? 0; + let bestDamage = baseDamage; + let bestPotions = 0; + + if (attackPotionsEnabled && attackBudget > 0) { + const maxPotions = Math.min(attackBudget, config.attackPotionMaxPerBeast, 255); + const potions = calculateOptimalAttackPotions([beast, 1, 0], summit, maxPotions); + if (potions > 0) { + const boostedCombat = calculateBattleResult(beast, summit, potions); + if (boostedCombat.estimatedDamage > bestDamage) { + bestDamage = boostedCombat.estimatedDamage; + bestPotions = potions; + } + } + } + + return { + beast, + damage: bestDamage, + reviveCost, + attackPotions: bestPotions, + weightedCost: reviveCost * REVIVE_WEIGHT + bestPotions, + }; + }; + + const candidates: Candidate[] = []; + + for (const beast of alive) { + candidates.push(buildCandidate(beast, 0)); + } + + for (const beast of dead) { + const reviveCost = beast.revival_count + 1; + if (reviveCost > reviveBudget || reviveCost > config.revivePotionMaxPerBeast) continue; + candidates.push(buildCandidate(beast, reviveCost)); + } + + // Compute the summit damage threshold to inform selection. + // In "guaranteed" mode the autopilot requires totalEstimatedDamage >= summitHealth * 1.1. + const summitHealth = ((summit.beast.health + summit.beast.bonus_health) * summit.beast.extra_lives) + + Math.max(1, summit.beast.current_health || 0); + + // The minimum damage a beast must deal to be worth considering for the first slot. + // Any beast above this threshold can contribute to taking the summit. + const damageThreshold = summitHealth * 1.1; + + // Quest-aware sorting: if quest mode is on, beasts that can pass the threshold AND + // satisfy a quest get a boost. This way a free alive beast that needs "Summit Conqueror" + // sorts ahead of an expensive revived beast when both can take the summit. + const questActive = config.questMode && questPredicates.length > 0; + + candidates.sort((a, b) => { + // Both can solo the summit — prefer quest beasts, then cheaper, then higher damage + const aCanSolo = a.damage >= damageThreshold; + const bCanSolo = b.damage >= damageThreshold; + + if (aCanSolo && bCanSolo) { + // If quest mode: prefer beast that needs a quest + if (questActive) { + const aQuest = needsAnyQuest(a.beast); + const bQuest = needsAnyQuest(b.beast); + if (aQuest && !bQuest) return -1; + if (bQuest && !aQuest) return 1; + // Both need quests — prefer higher urgency (streak about to expire) + if (aQuest && bQuest && hasStreakQuest) { + const aUrg = questUrgencyScore(a.beast, config.questFilters); + const bUrg = questUrgencyScore(b.beast, config.questFilters); + if (aUrg !== bUrg) return bUrg - aUrg; + } + } + // Both can solo — prefer cheaper + if (a.weightedCost !== b.weightedCost) return a.weightedCost - b.weightedCost; + return b.damage - a.damage; + } + + // One can solo, one can't — prefer the one that can solo + if (aCanSolo && !bCanSolo) return -1; + if (bCanSolo && !aCanSolo) return 1; + + // Neither can solo — sort by damage descending, with cost tiebreaker for similar damage + const maxDmg = Math.max(a.damage, b.damage); + const minDmg = Math.min(a.damage, b.damage); + if (maxDmg > 0 && minDmg / maxDmg >= 0.8) { + // If quest mode: prefer beast that needs a quest + if (questActive) { + const aQuest = needsAnyQuest(a.beast); + const bQuest = needsAnyQuest(b.beast); + if (aQuest && !bQuest) return -1; + if (bQuest && !aQuest) return 1; + // Both need quests — prefer higher urgency + if (aQuest && bQuest && hasStreakQuest) { + const aUrg = questUrgencyScore(a.beast, config.questFilters); + const bUrg = questUrgencyScore(b.beast, config.questFilters); + if (aUrg !== bUrg) return bUrg - aUrg; + } + } + if (a.weightedCost !== b.weightedCost) return a.weightedCost - b.weightedCost; + } + return b.damage - a.damage; + }); + + const maxBeasts = config.maxBeasts ?? Infinity; + const selected: Beast[] = []; + let usedAttackBudget = 0; + + for (const candidate of candidates) { + if (selected.length >= maxBeasts) break; + if (candidate.reviveCost > reviveBudget) continue; + if (candidate.attackPotions > attackBudget - usedAttackBudget) { + // Try without attack potions + candidate.attackPotions = 0; + candidate.damage = candidate.beast.combat?.estimatedDamage ?? 0; + } + + reviveBudget -= candidate.reviveCost; + usedAttackBudget += candidate.attackPotions; + + const beastCopy = { ...candidate.beast }; + if (candidate.attackPotions > 0) { + beastCopy.combat = calculateBattleResult(beastCopy, summit, candidate.attackPotions); + } + selected.push(beastCopy); + } + + return selected; +} + +function questNeedsPredicate(quest: string): ((beast: Beast) => boolean) | null { + switch (quest) { + case 'attack_summit': return (b) => b.bonus_xp === 0; + case 'max_attack_streak': return (b) => !b.max_attack_streak; + case 'take_summit': return (b) => !b.captured_summit; + case 'hold_summit_10s': return (b) => b.summit_held_seconds < 10; + case 'revival_potion': return (b) => !b.used_revival_potion; + case 'attack_potion': return (b) => !b.used_attack_potion; + default: return null; + } +} + +// Streak resets after 2 × BASE_REVIVAL_TIME_SECONDS (48h) since last death. +const STREAK_RESET_SECONDS = 86400 * 2; + +/** + * Score 0-100 for how urgently a beast needs to attack to preserve/complete its streak. + * - Progress component (0-50): higher streak = more to lose and closer to completing the quest. + * - Time pressure component (0-50): increases as the 48h reset window runs out. + * Returns 0 for beasts that have already completed the quest or have no active streak. + */ +export function streakUrgencyScore(beast: Beast): number { + if (beast.max_attack_streak) return 0; // quest already done + if (beast.attack_streak === 0) return 0; // no progress to lose + + const now = Date.now() / 1000; + const timeUntilReset = (beast.last_death_timestamp + STREAK_RESET_SECONDS) - now; + + if (timeUntilReset <= 0) return 0; // streak already reset + + // Progress: 0-50 based on how close to streak 10 + const progressScore = (beast.attack_streak / 10) * 50; + + // Time pressure: 0-50, increases as deadline approaches + const timeScore = Math.max(0, 1 - timeUntilReset / STREAK_RESET_SECONDS) * 50; + + return progressScore + timeScore; +} + +/** + * Aggregate urgency score across active quest filters. + * Currently only `max_attack_streak` has time-sensitive urgency. + */ +export function questUrgencyScore(beast: Beast, questFilters: string[]): number { + let maxScore = 0; + for (const quest of questFilters) { + if (quest === 'max_attack_streak') { + maxScore = Math.max(maxScore, streakUrgencyScore(beast)); + } + } + return maxScore; +} + +function applyQuestBoost( + selected: Beast[], + deadPool: Beast[], + summit: Summit, + config: SelectOptimalBeastsConfig, + attackPotionsEnabled: boolean, +): Beast[] { + const result = [...selected]; + const selectedIds = new Set(result.map((b) => b.token_id)); + + for (const quest of config.questFilters) { + const needsQuest = questNeedsPredicate(quest); + if (!needsQuest) continue; + + if (quest === 'revival_potion') { + // Special: include a dead beast that hasn't used revival potion + if (config.useRevivePotions) { + const alreadyIncluded = result.some((b) => needsQuest(b) && b.current_health === 0); + if (!alreadyIncluded) { + const reviveBudget = config.revivePotionMax - config.revivePotionsUsed; + const questCandidate = deadPool.find( + (b) => needsQuest(b) && !selectedIds.has(b.token_id) && (b.revival_count + 1) <= reviveBudget && (b.revival_count + 1) <= config.revivePotionMaxPerBeast + ); + if (questCandidate) { + const beastCopy = { ...questCandidate }; + beastCopy.combat = calculateBattleResult(beastCopy, summit, 0); + if ((beastCopy.combat?.estimatedDamage ?? 0) > 0) { + result.push(beastCopy); + selectedIds.add(beastCopy.token_id); + } + } + } + } + } else if (quest === 'attack_potion') { + // Special: ensure at least one beast gets attack potions + if (attackPotionsEnabled) { + const hasAttackPotions = result.some((b) => (b.combat?.attackPotions ?? 0) > 0); + if (!hasAttackPotions) { + const questBeast = result.find((b) => needsQuest(b)); + if (questBeast) { + const maxPotions = Math.min(config.attackPotionMax - config.attackPotionsUsed, config.attackPotionMaxPerBeast, 255); + if (maxPotions > 0) { + const idx = result.indexOf(questBeast); + const boosted = { ...questBeast }; + boosted.combat = calculateBattleResult(boosted, summit, Math.min(1, maxPotions)); + result[idx] = boosted; + } + } + } + } + } else { + // Generic: ensure at least one beast needing this quest is included + const alreadyIncluded = result.some(needsQuest); + if (alreadyIncluded) continue; + + // Try to swap in a viable beast from outside the selection + // For now, the quest boost is best-effort — beasts are already sorted by efficiency, + // so if none in the selection need this quest, the remaining beasts likely all completed it. + } + } + + return result; +}