diff --git a/client/src/contexts/GameDirector.tsx b/client/src/contexts/GameDirector.tsx index c494975c..bf32a025 100644 --- a/client/src/contexts/GameDirector.tsx +++ b/client/src/contexts/GameDirector.tsx @@ -665,6 +665,10 @@ export const GameDirector = ({ children }: PropsWithChildren) => { })); } + if (action.type === "add_extra_life" || action.type === "apply_poison") { + setApplyingPotions(false); + } + setActionFailed(); return false; } diff --git a/client/src/hooks/useAutopilotOrchestrator.test.tsx b/client/src/hooks/useAutopilotOrchestrator.test.tsx new file mode 100644 index 00000000..61d2c41e --- /dev/null +++ b/client/src/hooks/useAutopilotOrchestrator.test.tsx @@ -0,0 +1,219 @@ +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Beast, GameAction, Summit } from "@/types/game"; + +const hoisted = vi.hoisted(() => ({ + executeGameActionMock: vi.fn(), + tokenBalances: { + current: {} as Record, + }, +})); + +vi.mock("@/contexts/GameDirector", () => ({ + MAX_BEASTS_PER_ATTACK: 295, + useGameDirector: () => ({ + executeGameAction: hoisted.executeGameActionMock, + }), +})); + +vi.mock("@/contexts/controller", () => ({ + useController: () => ({ + tokenBalances: hoisted.tokenBalances.current, + }), +})); + +import { useAutopilotStore } from "@/stores/autopilotStore"; +import { useGameStore } from "@/stores/gameStore"; +import { useAutopilotOrchestrator } from "./useAutopilotOrchestrator"; + +function makeBeast(overrides: Partial = {}): Beast { + return { + id: 1, + name: "Warlock", + prefix: "Agony", + suffix: "Bane", + power: 50, + tier: 1, + type: "Magic", + level: 10, + health: 100, + shiny: 0, + animated: 0, + token_id: 1001, + current_health: 100, + bonus_health: 0, + current_level: 10, + bonus_xp: 0, + attack_streak: 0, + last_death_timestamp: 0, + revival_count: 0, + revival_time: 86400000, + extra_lives: 0, + captured_summit: false, + used_revival_potion: false, + used_attack_potion: false, + max_attack_streak: false, + summit_held_seconds: 0, + spirit: 0, + luck: 0, + specials: false, + wisdom: false, + diplomacy: false, + kills_claimed: 0, + rewards_earned: 0, + rewards_claimed: 0, + ...overrides, + }; +} + +function makeSummit( + beastOverrides: Partial = {}, + summitOverrides: Partial = {}, +): Summit { + return { + beast: makeBeast({ + token_id: 2001, + power: 75, + current_health: 100, + extra_lives: 2, + ...beastOverrides, + }), + block_timestamp: 0, + owner: "0xabc", + poison_count: 0, + poison_timestamp: 0, + ...summitOverrides, + }; +} + +function Probe() { + useAutopilotOrchestrator(); + return null; +} + +const initialGameState = useGameStore.getState(); +const initialAutopilotState = useAutopilotStore.getState(); +let renderer: ReactTestRenderer | null = null; + +function configureAutopilot(overrides: { + summit?: Summit; + applyingPotions?: boolean; + poisonStrategy?: "conservative" | "aggressive"; +} = {}) { + const attacker = makeBeast({ + token_id: 3001, + power: 500, + current_health: 100, + health: 100, + extra_lives: 0, + }); + + useGameStore.setState({ + ...initialGameState, + summit: overrides.summit ?? makeSummit(), + collection: [attacker], + attackMode: "autopilot", + autopilotEnabled: true, + attackInProgress: false, + applyingPotions: overrides.applyingPotions ?? false, + selectedBeasts: [], + autopilotLog: "", + }, true); + + useAutopilotStore.setState({ + ...initialAutopilotState, + attackStrategy: "never", + poisonStrategy: overrides.poisonStrategy ?? "conservative", + poisonTotalMax: 10, + poisonPotionsUsed: 0, + poisonConservativeExtraLivesTrigger: 1, + poisonConservativeAmount: 1, + poisonAggressiveAmount: 1, + poisonMinPower: 0, + poisonMinHealth: 0, + targetedPoisonPlayers: [], + targetedPoisonBeasts: [], + ignoredPlayers: [], + skipSharedDiplomacy: false, + maxBeastsPerAttack: 295, + }, true); +} + +async function renderHook() { + await act(async () => { + renderer = create(); + }); +} + +async function rerenderHook() { + await act(async () => { + renderer?.update(); + }); +} + +function poisonActions(): GameAction[] { + return hoisted.executeGameActionMock.mock.calls + .map(([action]) => action as GameAction) + .filter((action) => action.type === "apply_poison"); +} + +describe("useAutopilotOrchestrator poison triggers", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.executeGameActionMock.mockResolvedValue(true); + hoisted.tokenBalances.current = { + POISON: 10, + REVIVE: 0, + }; + }); + + afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount(); + }); + renderer = null; + } + + useGameStore.setState(initialGameState, true); + useAutopilotStore.setState(initialAutopilotState, true); + }); + + it("applies conservative poison after an in-progress potion action clears", async () => { + configureAutopilot({ applyingPotions: true }); + + await renderHook(); + + expect(poisonActions()).toHaveLength(0); + + await act(async () => { + useGameStore.getState().setApplyingPotions(false); + }); + + expect(poisonActions()).toMatchObject([ + { type: "apply_poison", beastId: 2001, count: 1 }, + ]); + }); + + it("applies aggressive poison when poison balance arrives after the summit update", async () => { + hoisted.tokenBalances.current = { + POISON: 0, + REVIVE: 0, + }; + configureAutopilot({ poisonStrategy: "aggressive" }); + + await renderHook(); + + expect(poisonActions()).toHaveLength(0); + + hoisted.tokenBalances.current = { + POISON: 10, + REVIVE: 0, + }; + await rerenderHook(); + + expect(poisonActions()).toMatchObject([ + { type: "apply_poison", beastId: 2001, count: 1 }, + ]); + }); +}); diff --git a/client/src/hooks/useAutopilotOrchestrator.ts b/client/src/hooks/useAutopilotOrchestrator.ts index ec4004fa..8b63d8de 100644 --- a/client/src/hooks/useAutopilotOrchestrator.ts +++ b/client/src/hooks/useAutopilotOrchestrator.ts @@ -58,8 +58,10 @@ export function useAutopilotOrchestrator() { const [triggerAutopilot, setTriggerAutopilot] = useReducer((x: number) => x + 1, 0); const poisonedTokenIdRef = React.useRef(null); + const targetedPoisonKeyRef = React.useRef(null); const isSavage = Boolean(collection.find(beast => beast.token_id === summit?.beast?.token_id)); + const poisonBalance = tokenBalances?.["POISON"] || 0; 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); @@ -104,16 +106,30 @@ export function useAutopilotOrchestrator() { const handleApplyPoison = (amount: number, beastId?: number): boolean => { const targetId = beastId ?? summit?.beast?.token_id; - if (!targetId || applyingPotions || amount === 0) return false; + if (targetId === undefined || applyingPotions || amount === 0) return false; setApplyingPotions(true); setAutopilotLog('Applying poison...'); - executeGameAction({ + void executeGameAction({ type: 'apply_poison', beastId: targetId, count: amount, + }).then((result) => { + if (result) return; + + if (poisonedTokenIdRef.current === targetId) { + poisonedTokenIdRef.current = null; + } + targetedPoisonKeyRef.current = null; + }).catch(() => { + if (poisonedTokenIdRef.current === targetId) { + poisonedTokenIdRef.current = null; + } + targetedPoisonKeyRef.current = null; + setApplyingPotions(false); }); + return true; }; @@ -161,8 +177,7 @@ export function useAutopilotOrchestrator() { 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); + const amount = Math.min(beastAmount, poisonBalance, remainingCap); if (amount > 0) { handleApplyPoison(amount, currentSummit.beast.token_id); poisonedThisSequence.add(currentSummit.beast.token_id); @@ -170,8 +185,7 @@ export function useAutopilotOrchestrator() { } 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); + const amount = Math.min(playerAmount, poisonBalance, remainingCap); if (amount > 0) { handleApplyPoison(amount, currentSummit.beast.token_id); poisonedThisSequence.add(currentSummit.beast.token_id); @@ -200,6 +214,8 @@ export function useAutopilotOrchestrator() { setAttackPotionsUsed(() => 0); setExtraLifePotionsUsed(() => 0); setPoisonPotionsUsed(() => 0); + poisonedTokenIdRef.current = null; + targetedPoisonKeyRef.current = null; setAutopilotEnabled(true); }; @@ -219,10 +235,16 @@ export function useAutopilotOrchestrator() { if (attackMode !== 'autopilot' && autopilotEnabled) { setAutopilotEnabled(false); poisonedTokenIdRef.current = null; + targetedPoisonKeyRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [attackMode]); + useEffect(() => { + poisonedTokenIdRef.current = null; + targetedPoisonKeyRef.current = null; + }, [summit?.beast?.token_id]); + // Diplomacy / ignored player memos const summitSharesDiplomacy = useMemo(() => { if (!skipSharedDiplomacy || !summit?.beast) return false; @@ -275,9 +297,11 @@ export function useAutopilotOrchestrator() { 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); + const amount = Math.min(beastAmount, poisonBalance, remainingCap); + const poisonKey = `beast:${summit.beast.token_id}:${beastAmount}:${poisonTotalMax}`; + if (amount > 0 && targetedPoisonKeyRef.current !== poisonKey && handleApplyPoison(amount, summit.beast.token_id)) { + targetedPoisonKeyRef.current = poisonKey; + } return; } @@ -286,9 +310,11 @@ export function useAutopilotOrchestrator() { 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); + const amount = Math.min(playerAmount, poisonBalance, remainingCap); + const poisonKey = `player:${summit.beast.token_id}:${summit.owner.toLowerCase()}:${playerAmount}:${poisonTotalMax}`; + if (amount > 0 && targetedPoisonKeyRef.current !== poisonKey && handleApplyPoison(amount, summit.beast.token_id)) { + targetedPoisonKeyRef.current = poisonKey; + } return; } @@ -305,17 +331,35 @@ export function useAutopilotOrchestrator() { 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); + const amount = Math.min(poisonAggressiveAmount, poisonBalance, 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]); + }, [ + summit?.beast?.token_id, + summit?.beast?.power, + summit?.beast?.current_health, + summit?.owner, + autopilotEnabled, + attackInProgress, + applyingPotions, + collection, + targetedPoisonPlayers, + targetedPoisonBeasts, + poisonStrategy, + poisonTotalMax, + poisonPotionsUsed, + poisonAggressiveAmount, + poisonMinPower, + poisonMinHealth, + poisonBalance, + shouldSkipSummit, + ]); // Main autopilot attack + conservative poison + extra life logic useEffect(() => { - if (!autopilotEnabled || attackInProgress || !collectionWithCombat || !summit) return; + if (!autopilotEnabled || attackInProgress || applyingPotions || !collectionWithCombat || !summit) return; const myBeast = collection.find((beast: Beast) => beast.token_id === summit?.beast.token_id); @@ -339,10 +383,10 @@ export function useAutopilotOrchestrator() { && (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; + return; } } @@ -376,7 +420,36 @@ export function useAutopilotOrchestrator() { }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [collectionWithCombat, autopilotEnabled, summit?.beast.extra_lives, triggerAutopilot]); + }, [ + collectionWithCombat, + collection, + autopilotEnabled, + attackInProgress, + applyingPotions, + summit?.beast?.token_id, + summit?.beast?.extra_lives, + summit?.beast?.current_health, + summit?.beast?.power, + summit?.poison_count, + summit?.owner, + shouldSkipSummit, + extraLifeStrategy, + extraLifeMax, + extraLifeTotalMax, + extraLifeReplenishTo, + extraLifePotionsUsed, + poisonStrategy, + poisonConservativeExtraLivesTrigger, + poisonConservativeAmount, + poisonMinPower, + poisonMinHealth, + poisonTotalMax, + poisonPotionsUsed, + poisonBalance, + attackStrategy, + maxBeastsPerAttack, + triggerAutopilot, + ]); // Re-trigger autopilot when summit beast is about to die (0 extra lives, 1 HP) useEffect(() => {