From cfa3dcc03f2edcc9a63f19de5c9d2838996b2ebe Mon Sep 17 00:00:00 2001 From: spaghettiOnToast Date: Mon, 9 Mar 2026 15:16:56 +0000 Subject: [PATCH 1/3] Restore custom autopilot features lost in upstream merge Re-adds targeted poison (per-player and per-beast with custom amounts), quest mode with filter-based beast prioritization, advanced beast selection via selectOptimalBeasts, and between-batch halt conditions in attack loop. Adds streak urgency scoring for "Consistency is Key" quest so beasts with higher streaks or expiring streaks (approaching 48h reset) are prioritized. Fixes isOwnerTargetedForPoison type signature (IgnoredPlayer -> TargetedPoisonPlayer). Co-Authored-By: Claude Opus 4.6 --- client/src/components/ActionBar.test.tsx | 9 + client/src/components/ActionBar.tsx | 158 ++++--- .../dialogs/AutopilotConfigModal.tsx | 314 +++++++++++++- client/src/stores/autopilotStore.ts | 107 +++++ client/src/utils/beasts.test.ts | 201 +++++++++ client/src/utils/beasts.ts | 398 ++++++++++++++++++ 6 files changed, 1128 insertions(+), 59 deletions(-) diff --git a/client/src/components/ActionBar.test.tsx b/client/src/components/ActionBar.test.tsx index b968c026..68e24fb6 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", () => ({ diff --git a/client/src/components/ActionBar.tsx b/client/src/components/ActionBar.tsx index 1ff71fba..6cf1646c 100644 --- a/client/src/components/ActionBar.tsx +++ b/client/src/components/ActionBar.tsx @@ -22,7 +22,10 @@ import poisonPotionIcon from '../assets/images/poison-potion.png'; import revivePotionIcon from '../assets/images/revive-potion.png'; import { calculateBattleResult, calculateOptimalAttackPotions, calculateRevivalRequired, - getBeastCurrentHealth, getBeastRevivalTime, isBeastLocked + getBeastCurrentHealth, getBeastRevivalTime, isBeastLocked, + isOwnerIgnored, isOwnerTargetedForPoison, getTargetedPoisonAmount, + isBeastTargetedForPoison, getTargetedBeastPoisonAmount, + hasDiplomacyMatch, selectOptimalBeasts, } from '../utils/beasts'; import { gameColors } from '../utils/themes'; import AutopilotConfigModal from './dialogs/AutopilotConfigModal'; @@ -82,6 +85,10 @@ function ActionBar() { maxBeastsPerAttack, skipSharedDiplomacy, ignoredPlayers, + targetedPoisonPlayers, + targetedPoisonBeasts, + questMode, + questFilters, } = useAutopilotStore(); const [anchorEl, setAnchorEl] = useState(null); @@ -133,58 +140,28 @@ 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 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, collection.length, revivePotionsUsed, attackPotionsUsed, useRevivePotions, useAttackPotions]); + }, [summit?.beast?.token_id, summit?.beast?.extra_lives, summit?.beast?.current_health, collection.length, revivePotionsUsed, attackPotionsUsed, useRevivePotions, useAttackPotions, questMode, questFilters, maxBeastsPerAttack, attackStrategy, autopilotEnabled]); const handleAttackUntilCapture = async (extraLifePotions: number) => { - if (!enableAttack) return; + const { attackInProgress: alreadyAttacking, applyingPotions: alreadyApplying } = useGameStore.getState(); + if (!enableAttack || alreadyAttacking || alreadyApplying) return; setBattleEvents([]); setAttackInProgress(true); @@ -199,6 +176,44 @@ function ActionBar() { // Process one batch at a time, stopping if executeGameAction returns false 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 if applicable + 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); + } 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); + } + } + const result = await executeGameAction({ type: 'attack_until_capture', beasts: batch, @@ -206,6 +221,7 @@ function ActionBar() { }); if (!result) { + setAttackInProgress(false); return; } } @@ -292,24 +308,52 @@ function ActionBar() { }, [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 (!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); + 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); + return; // targeted poison takes priority — don't double up with aggressive + } + + if (poisonStrategy !== 'aggressive') return; + if (shouldSkipSummit) return; + // Reset tracked token when summit beast changes - if (poisonedTokenIdRef.current !== summit?.beast.token_id) { + if (poisonedTokenIdRef.current !== summit.beast.token_id) { poisonedTokenIdRef.current = null; } - if (poisonedTokenIdRef.current === summit?.beast.token_id) return; + 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; + 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 poisonBalance = tokenBalances?.["POISON"] || 0; - const amount = Math.min(poisonAggressiveAmount, poisonBalance, remainingCap); - if (amount > 0 && summit && handleApplyPoison(amount)) { + const pb = tokenBalances?.["POISON"] || 0; + const amount = Math.min(poisonAggressiveAmount, pb, remainingCap); + if (amount > 0 && handleApplyPoison(amount)) { poisonedTokenIdRef.current = summit.beast.token_id; } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/client/src/components/dialogs/AutopilotConfigModal.tsx b/client/src/components/dialogs/AutopilotConfigModal.tsx index 6dc3c0bb..a7475405 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,232 @@ 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, poisonAvailable || 9999))); + }} + inputProps={{ min: 1, max: poisonAvailable || 9999, 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, poisonAvailable || 9999))); + }} + inputProps={{ min: 1, max: poisonAvailable || 9999, 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, poisonAvailable || 9999))); + }} + inputProps={{ min: 1, max: poisonAvailable || 9999, 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, poisonAvailable || 9999))); + }} + inputProps={{ min: 1, max: poisonAvailable || 9999, step: 1 }} + sx={{ ...styles.numberField, width: 80 }} + /> + + ))} + + )} + + ); +} + function AutopilotConfigModal(props: AutopilotConfigModalProps) { const { open, close } = props; @@ -137,6 +365,18 @@ function AutopilotConfigModal(props: AutopilotConfigModalProps) { ignoredPlayers, addIgnoredPlayer, removeIgnoredPlayer, + targetedPoisonPlayers, + addTargetedPoisonPlayer, + removeTargetedPoisonPlayer, + setTargetedPoisonAmount, + targetedPoisonBeasts, + addTargetedPoisonBeast, + removeTargetedPoisonBeast, + setTargetedPoisonBeastAmount, + questMode, + setQuestMode, + questFilters, + setQuestFilters, resetToDefaults, } = useAutopilotStore(); @@ -199,6 +439,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 +1003,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/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..598cbd2e 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.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; +} From 59598243a1347cbbbb24e3f7d2eb9945989b09ba Mon Sep 17 00:00:00 2001 From: spaghettiOnToast Date: Mon, 9 Mar 2026 15:25:07 +0000 Subject: [PATCH 2/3] Refactor: extract autopilot orchestration into useAutopilotOrchestrator hook Moves ~340 lines of autopilot logic (beast selection, attack batching, poison targeting, extra life management, all useEffects) from ActionBar into a dedicated hook. ActionBar is now UI-only. Co-Authored-By: Claude Opus 4.6 --- client/src/components/ActionBar.test.tsx | 14 + client/src/components/ActionBar.tsx | 360 +---------------- client/src/hooks/useAutopilotOrchestrator.ts | 390 +++++++++++++++++++ 3 files changed, 422 insertions(+), 342 deletions(-) create mode 100644 client/src/hooks/useAutopilotOrchestrator.ts diff --git a/client/src/components/ActionBar.test.tsx b/client/src/components/ActionBar.test.tsx index 68e24fb6..327b7eff 100644 --- a/client/src/components/ActionBar.test.tsx +++ b/client/src/components/ActionBar.test.tsx @@ -101,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 6cf1646c..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,25 +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, - isOwnerIgnored, isOwnerTargetedForPoison, getTargetedPoisonAmount, - isBeastTargetedForPoison, getTargetedBeastPoisonAmount, - hasDiplomacyMatch, selectOptimalBeasts, -} 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'; @@ -52,45 +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, - targetedPoisonPlayers, - targetedPoisonBeasts, - questMode, - questFilters, } = 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); @@ -98,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); @@ -138,312 +120,6 @@ function ActionBar() { }); } - 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]); - - 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]); - - // 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) { - // 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 if applicable - 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); - } 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); - } - } - - const result = await executeGameAction({ - type: 'attack_until_capture', - beasts: batch, - extraLifePotions - }); - - if (!result) { - setAttackInProgress(false); - 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 || !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); - 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); - return; // targeted poison takes priority — don't double up with aggressive - } - - 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)) { - 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/hooks/useAutopilotOrchestrator.ts b/client/src/hooks/useAutopilotOrchestrator.ts new file mode 100644 index 00000000..1d438068 --- /dev/null +++ b/client/src/hooks/useAutopilotOrchestrator.ts @@ -0,0 +1,390 @@ +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): 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 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)); + } + + 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 if applicable + 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); + } 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); + } + } + + 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 + 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); + 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); + 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)) { + poisonedTokenIdRef.current = summit.beast.token_id; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [summit?.beast?.token_id]); + + // 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, + }; +} From a55e2d2490cf46cec8f25014148b1488afe26667 Mon Sep 17 00:00:00 2001 From: spaghettiOnToast Date: Mon, 9 Mar 2026 15:57:00 +0000 Subject: [PATCH 3/3] Fix stale closures, duplicate poison, and diplomacy matching in autopilot - handleApplyPoison accepts explicit beastId to avoid stale summit closure - Track poisoned targets per attack sequence to prevent duplicate spending - Add config deps to targeted poison effect so it triggers on setting changes - Disable poison amount inputs when balance is 0, fix max fallback to 9999 - Require beast.diplomacy flag in hasDiplomacyMatch Co-Authored-By: Claude Opus 4.6 --- .../dialogs/AutopilotConfigModal.tsx | 20 ++++--- client/src/hooks/useAutopilotOrchestrator.ts | 57 +++++++++++-------- client/src/utils/beasts.ts | 2 +- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/client/src/components/dialogs/AutopilotConfigModal.tsx b/client/src/components/dialogs/AutopilotConfigModal.tsx index a7475405..d0f48e23 100644 --- a/client/src/components/dialogs/AutopilotConfigModal.tsx +++ b/client/src/components/dialogs/AutopilotConfigModal.tsx @@ -160,13 +160,14 @@ function TargetedPoisonSection({ players, onAdd, onRemove, onAmountChange, poiso { let v = Number.parseInt(e.target.value, 10); if (Number.isNaN(v)) v = 1; - setDefaultAmount(Math.max(1, Math.min(v, poisonAvailable || 9999))); + setDefaultAmount(Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); }} - inputProps={{ min: 1, max: poisonAvailable || 9999, step: 1 }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} sx={{ ...styles.numberField, width: 80 }} /> {loading && } @@ -195,13 +196,14 @@ function TargetedPoisonSection({ players, onAdd, onRemove, onAmountChange, poiso { let v = Number.parseInt(e.target.value, 10); if (Number.isNaN(v)) v = 1; - onAmountChange(player.address, Math.max(1, Math.min(v, poisonAvailable || 9999))); + onAmountChange(player.address, Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); }} - inputProps={{ min: 1, max: poisonAvailable || 9999, step: 1 }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} sx={{ ...styles.numberField, width: 80 }} /> @@ -262,13 +264,14 @@ function TargetedPoisonBeastSection({ beasts, onAdd, onRemove, onAmountChange, p { let v = Number.parseInt(e.target.value, 10); if (Number.isNaN(v)) v = 1; - setDefaultAmount(Math.max(1, Math.min(v, poisonAvailable || 9999))); + setDefaultAmount(Math.max(1, Math.min(v, Math.max(poisonAvailable, 1)))); }} - inputProps={{ min: 1, max: poisonAvailable || 9999, step: 1 }} + inputProps={{ min: 1, max: Math.max(poisonAvailable, 1), step: 1 }} sx={{ ...styles.numberField, width: 80 }} />