From 7ab89609173965c337e1d12053a2ff8de8aaf584 Mon Sep 17 00:00:00 2001 From: loothero Date: Sun, 3 May 2026 18:23:42 -0700 Subject: [PATCH] fix autopilot poison triggers --- client/src/contexts/GameDirector.tsx | 4 + .../hooks/useAutopilotOrchestrator.test.tsx | 226 ++++++++++++++++ client/src/hooks/useAutopilotOrchestrator.ts | 249 ++++++++++++++++-- client/src/utils/beasts.test.ts | 66 +++++ client/src/utils/beasts.ts | 82 +++++- 5 files changed, 593 insertions(+), 34 deletions(-) create mode 100644 client/src/hooks/useAutopilotOrchestrator.test.tsx 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..a80ff8f9 --- /dev/null +++ b/client/src/hooks/useAutopilotOrchestrator.test.tsx @@ -0,0 +1,226 @@ +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Beast, 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 { useAutopilotOrchestrator } from "./useAutopilotOrchestrator"; +import { useAutopilotStore } from "@/stores/autopilotStore"; +import { useGameStore } from "@/stores/gameStore"; + +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: 10, + current_health: 100, + extra_lives: 1, + ...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(summit: Summit) { + const attacker = makeBeast({ + token_id: 3001, + power: 500, + current_health: 100, + health: 100, + extra_lives: 0, + }); + + useGameStore.setState({ + ...initialGameState, + summit, + collection: [attacker], + attackMode: "autopilot", + autopilotEnabled: true, + attackInProgress: false, + applyingPotions: false, + selectedBeasts: [], + autopilotLog: "", + }, true); + + useAutopilotStore.setState({ + ...initialAutopilotState, + attackStrategy: "guaranteed", + poisonStrategy: "conservative", + poisonTotalMax: 10, + poisonPotionsUsed: 0, + poisonConservativeExtraLivesTrigger: 1, + poisonConservativeAmount: 1, + poisonMinPower: 0, + poisonMinHealth: 0, + targetedPoisonPlayers: [], + targetedPoisonBeasts: [], + ignoredPlayers: [], + skipSharedDiplomacy: false, + maxBeastsPerAttack: 295, + }, true); +} + +async function renderHook() { + await act(async () => { + renderer = create(); + }); +} + +async function settlePoisonTransaction() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + useGameStore.getState().setApplyingPotions(false); + }); +} + +function actionTypes(): string[] { + return hoisted.executeGameActionMock.mock.calls.map(([action]) => action.type); +} + +describe("useAutopilotOrchestrator smart poison", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + 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); + vi.useRealTimers(); + }); + + it("does not immediately attack after Autopilot applies poison", async () => { + configureAutopilot(makeSummit()); + + await renderHook(); + await settlePoisonTransaction(); + + expect(actionTypes()).toEqual(["apply_poison"]); + expect(useGameStore.getState().autopilotLog).toMatch(/^Waiting for poison:/); + }); + + it("attacks when projected poison damage reaches 1 HP and 0 extra lives", async () => { + configureAutopilot(makeSummit({ current_health: 3, extra_lives: 1 })); + + await renderHook(); + await settlePoisonTransaction(); + + expect(actionTypes()).toEqual(["apply_poison"]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(102_000); + }); + + expect(actionTypes()).toContain("attack"); + }); + + it("clears the poison wait when the summit beast changes and evaluates the new summit", async () => { + configureAutopilot(makeSummit()); + + await renderHook(); + await settlePoisonTransaction(); + + expect(actionTypes()).toEqual(["apply_poison"]); + + await act(async () => { + useGameStore.getState().setSummit(makeSummit({ + token_id: 2002, + current_health: 1, + extra_lives: 0, + })); + }); + + expect(actionTypes()).toContain("attack"); + }); +}); diff --git a/client/src/hooks/useAutopilotOrchestrator.ts b/client/src/hooks/useAutopilotOrchestrator.ts index ec4004fa..46873cb3 100644 --- a/client/src/hooks/useAutopilotOrchestrator.ts +++ b/client/src/hooks/useAutopilotOrchestrator.ts @@ -2,15 +2,36 @@ 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 type { Beast, Summit } from '@/types/game'; import React, { useEffect, useMemo, useReducer } from 'react'; import { calculateRevivalRequired, isOwnerIgnored, isOwnerTargetedForPoison, getTargetedPoisonAmount, isBeastTargetedForPoison, getTargetedBeastPoisonAmount, - hasDiplomacyMatch, selectOptimalBeasts, + getPoisonFloorProjection, hasDiplomacyMatch, selectOptimalBeasts, } from '../utils/beasts'; +type SmartPoisonWaitTarget = { + tokenId: number; + summit: Summit; +}; + +type ApplyPoisonOptions = { + smartWait?: boolean; +}; + +function formatPoisonWait(seconds: number): string { + const safeSeconds = Math.max(0, Math.ceil(seconds)); + const minutes = Math.floor(safeSeconds / 60); + const remainder = safeSeconds % 60; + + if (minutes === 0) { + return `${remainder}s`; + } + + return `${minutes}m ${remainder}s`; +} + export function useAutopilotOrchestrator() { const { executeGameAction } = useGameDirector(); const { tokenBalances } = useController(); @@ -58,8 +79,12 @@ export function useAutopilotOrchestrator() { const [triggerAutopilot, setTriggerAutopilot] = useReducer((x: number) => x + 1, 0); const poisonedTokenIdRef = React.useRef(null); + const targetedPoisonKeyRef = React.useRef(null); + const smartPoisonWaitRef = React.useRef(null); + const smartPoisonTimerRef = React.useRef | null>(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); @@ -89,6 +114,47 @@ export function useAutopilotOrchestrator() { // ── Handlers ───────────────────────────────────────────────────────── + const clearSmartPoisonTimer = () => { + if (smartPoisonTimerRef.current !== null) { + clearTimeout(smartPoisonTimerRef.current); + smartPoisonTimerRef.current = null; + } + }; + + const clearSmartPoisonWait = () => { + smartPoisonWaitRef.current = null; + clearSmartPoisonTimer(); + }; + + const scheduleSmartPoisonCheck = (secondsUntilReady: number) => { + clearSmartPoisonTimer(); + + const delayMs = Math.max(250, Math.min(Math.ceil(secondsUntilReady), 1) * 1000); + smartPoisonTimerRef.current = setTimeout(() => { + smartPoisonTimerRef.current = null; + setTriggerAutopilot(); + }, delayMs); + }; + + const startSmartPoisonWait = (targetId: number, amount: number) => { + const currentSummit = useGameStore.getState().summit; + if (!currentSummit || currentSummit.beast.token_id !== targetId || amount <= 0) return; + + const nowSec = Math.floor(Date.now() / 1000); + smartPoisonWaitRef.current = { + tokenId: targetId, + summit: { + ...currentSummit, + poison_count: Math.max(0, currentSummit.poison_count || 0) + amount, + poison_timestamp: nowSec, + beast: { + ...currentSummit.beast, + }, + }, + }; + setTriggerAutopilot(); + }; + const handleApplyExtraLife = (amount: number) => { if (!summit?.beast || !isSavage || applyingPotions || amount === 0) return; @@ -102,18 +168,43 @@ export function useAutopilotOrchestrator() { }); }; - const handleApplyPoison = (amount: number, beastId?: number): boolean => { + const handleApplyPoison = (amount: number, beastId?: number, options: ApplyPoisonOptions = {}): 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) { + if (options.smartWait) { + startSmartPoisonWait(targetId, amount); + } + return; + } + + if (poisonedTokenIdRef.current === targetId) { + poisonedTokenIdRef.current = null; + } + targetedPoisonKeyRef.current = null; + if (smartPoisonWaitRef.current?.tokenId === targetId) { + clearSmartPoisonWait(); + } + }).catch(() => { + if (poisonedTokenIdRef.current === targetId) { + poisonedTokenIdRef.current = null; + } + targetedPoisonKeyRef.current = null; + if (smartPoisonWaitRef.current?.tokenId === targetId) { + clearSmartPoisonWait(); + } + setApplyingPotions(false); }); + return true; }; @@ -161,20 +252,22 @@ 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); + handleApplyPoison(amount, currentSummit.beast.token_id, { smartWait: attackStrategy !== 'never' }); poisonedThisSequence.add(currentSummit.beast.token_id); + setAttackInProgress(false); + return; } } 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); + handleApplyPoison(amount, currentSummit.beast.token_id, { smartWait: attackStrategy !== 'never' }); poisonedThisSequence.add(currentSummit.beast.token_id); + setAttackInProgress(false); + return; } } } @@ -200,15 +293,26 @@ export function useAutopilotOrchestrator() { setAttackPotionsUsed(() => 0); setExtraLifePotionsUsed(() => 0); setPoisonPotionsUsed(() => 0); + poisonedTokenIdRef.current = null; + targetedPoisonKeyRef.current = null; + clearSmartPoisonWait(); setAutopilotEnabled(true); }; const stopAutopilot = () => { + clearSmartPoisonWait(); setAutopilotEnabled(false); }; // ── Effects ────────────────────────────────────────────────────────── + useEffect(() => { + return () => { + clearSmartPoisonWait(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Reset state when attack mode changes useEffect(() => { if (attackMode === 'autopilot') { @@ -219,10 +323,25 @@ export function useAutopilotOrchestrator() { if (attackMode !== 'autopilot' && autopilotEnabled) { setAutopilotEnabled(false); poisonedTokenIdRef.current = null; + clearSmartPoisonWait(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [attackMode]); + useEffect(() => { + poisonedTokenIdRef.current = null; + targetedPoisonKeyRef.current = null; + clearSmartPoisonWait(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [summit?.beast?.token_id]); + + useEffect(() => { + if (!autopilotEnabled || attackStrategy === 'never') { + clearSmartPoisonWait(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autopilotEnabled, attackStrategy]); + // Diplomacy / ignored player memos const summitSharesDiplomacy = useMemo(() => { if (!skipSharedDiplomacy || !summit?.beast) return false; @@ -275,9 +394,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, { smartWait: attackStrategy !== 'never' })) { + targetedPoisonKeyRef.current = poisonKey; + } return; } @@ -286,9 +407,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, { smartWait: attackStrategy !== 'never' })) { + targetedPoisonKeyRef.current = poisonKey; + } return; } @@ -305,17 +428,36 @@ 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); - if (amount > 0 && handleApplyPoison(amount, summit.beast.token_id)) { + const amount = Math.min(poisonAggressiveAmount, poisonBalance, remainingCap); + if (amount > 0 && handleApplyPoison(amount, summit.beast.token_id, { smartWait: attackStrategy !== 'never' })) { 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, + attackStrategy, + ]); // 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); @@ -332,6 +474,31 @@ export function useAutopilotOrchestrator() { if (shouldSkipSummit) return; + let summitForAttack = summit; + const smartPoisonWait = smartPoisonWaitRef.current; + if (smartPoisonWait?.tokenId === summit.beast.token_id) { + const projection = getPoisonFloorProjection(smartPoisonWait.summit); + if (!projection.ready) { + if (projection.secondsUntilFloor !== null) { + setAutopilotLog(`Waiting for poison: ${formatPoisonWait(projection.secondsUntilFloor)}`); + scheduleSmartPoisonCheck(projection.secondsUntilFloor); + return; + } + + clearSmartPoisonWait(); + } else { + clearSmartPoisonWait(); + summitForAttack = { + ...summit, + beast: { + ...summit.beast, + current_health: projection.currentHealth, + extra_lives: projection.extraLives, + }, + }; + } + } + if (poisonStrategy === 'conservative' && summit.beast.extra_lives >= poisonConservativeExtraLivesTrigger && summit.poison_count < poisonConservativeAmount @@ -339,10 +506,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)) { + if (amount > 0 && handleApplyPoison(amount, undefined, { smartWait: attackStrategy !== 'never' })) { poisonedTokenIdRef.current = summit.beast.token_id; + return; } } @@ -360,7 +527,7 @@ export function useAutopilotOrchestrator() { } 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 totalSummitHealth = ((summitForAttack.beast.health + summitForAttack.beast.bonus_health) * summitForAttack.beast.extra_lives) + summitForAttack.beast.current_health; const totalEstimatedDamage = beasts.reduce((acc, beast) => acc + (beast.combat?.estimatedDamage ?? 0), 0); if (totalEstimatedDamage < (totalSummitHealth * 1.1)) { return; @@ -376,7 +543,37 @@ 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?.poison_timestamp, + 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(() => { diff --git a/client/src/utils/beasts.test.ts b/client/src/utils/beasts.test.ts index fe7285cb..97231135 100644 --- a/client/src/utils/beasts.test.ts +++ b/client/src/utils/beasts.test.ts @@ -11,6 +11,7 @@ import { isBeastLocked, getBeastLockedTimeRemaining, applyPoisonDamage, + getPoisonFloorProjection, calculateBattleResult, calculateOptimalAttackPotions, calculateMaxAttackPotions, @@ -561,6 +562,71 @@ describe("applyPoisonDamage", () => { }); }); +// --------------------------------------------------------------------------- +// getPoisonFloorProjection +// --------------------------------------------------------------------------- +describe("getPoisonFloorProjection", () => { + it("computes seconds until poison reaches 1 HP and 0 extra lives", () => { + const nowSec = 1_700_000_000; + const summit = makeSummit( + { current_health: 50, extra_lives: 2, health: 100, bonus_health: 0 }, + { poison_count: 10, poison_timestamp: nowSec }, + ); + + const result = getPoisonFloorProjection(summit, nowSec); + + expect(result.currentHealth).toBe(50); + expect(result.extraLives).toBe(2); + expect(result.ready).toBe(false); + expect(result.secondsUntilFloor).toBe(25); + }); + + it("returns ready immediately when poison has already reached 1 HP and 0 extra lives", () => { + const nowSec = 1_700_000_000; + const summit = makeSummit( + { current_health: 1, extra_lives: 0, health: 100, bonus_health: 0 }, + { poison_count: 4, poison_timestamp: nowSec - 30 }, + ); + + const result = getPoisonFloorProjection(summit, nowSec); + + expect(result.currentHealth).toBe(1); + expect(result.extraLives).toBe(0); + expect(result.ready).toBe(true); + expect(result.secondsUntilFloor).toBe(0); + }); + + it("treats missing poison as not ready", () => { + const nowSec = 1_700_000_000; + const summit = makeSummit( + { current_health: 1, extra_lives: 0, health: 100, bonus_health: 0 }, + { poison_count: 0, poison_timestamp: nowSec - 30 }, + ); + + const result = getPoisonFloorProjection(summit, nowSec); + + expect(result.currentHealth).toBe(1); + expect(result.extraLives).toBe(0); + expect(result.ready).toBe(false); + expect(result.secondsUntilFloor).toBeNull(); + }); + + it("treats missing poison timestamp as not ready", () => { + const nowSec = 1_700_000_000; + const summit = makeSummit( + { current_health: 1, extra_lives: 0, health: 100, bonus_health: 0 }, + { poison_count: 4, poison_timestamp: 0 }, + ); + + const result = getPoisonFloorProjection(summit, nowSec); + + expect(result.currentHealth).toBe(1); + expect(result.extraLives).toBe(0); + expect(result.ready).toBe(false); + expect(result.secondsUntilFloor).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // calculateBattleResult // --------------------------------------------------------------------------- diff --git a/client/src/utils/beasts.ts b/client/src/utils/beasts.ts index 0bd86ba8..f8727d76 100644 --- a/client/src/utils/beasts.ts +++ b/client/src/utils/beasts.ts @@ -259,32 +259,67 @@ export const getSpiritRevivalReductionSeconds = (points: number): number => { export function applyPoisonDamage( summit: Summit, ): { currentHealth: number; extraLives: number } { + const projection = getPoisonFloorProjection(summit); + return { + currentHealth: projection.currentHealth, + extraLives: projection.extraLives, + }; +} + +export interface PoisonFloorProjection { + currentHealth: number; + extraLives: number; + ready: boolean; + secondsUntilFloor: number | null; +} + +function projectPoisonDamage( + summit: Summit, + nowSec: number, +): { currentHealth: number; extraLives: number; totalPoolBefore: number; poisonDamage: number; poisonCount: number; hasActivePoison: boolean } { const count = Math.max(0, summit.poison_count || 0); const ts = Math.max(0, summit.poison_timestamp || 0); + const currentHealth = Math.max(0, summit.beast.current_health ?? 0); + const extraLives = Math.max(0, summit.beast.extra_lives ?? 0); + const maxHealth = Math.max(1, (summit.beast.health ?? 0) + (summit.beast.bonus_health ?? 0)); + const totalPoolBefore = extraLives * maxHealth + currentHealth; + if (count === 0 || ts === 0) { return { - currentHealth: Math.max(0, summit.beast.current_health ?? 0), - extraLives: Math.max(0, summit.beast.extra_lives ?? 0), + currentHealth, + extraLives, + totalPoolBefore, + poisonDamage: 0, + poisonCount: count, + hasActivePoison: false, }; } - const nowSec = Math.floor(Date.now() / 1000); const elapsedSeconds = Math.max(0, nowSec - ts); const poisonDamage = count * elapsedSeconds; if (poisonDamage <= 0) { return { - currentHealth: summit.beast.current_health, - extraLives: summit.beast.extra_lives, + currentHealth, + extraLives, + totalPoolBefore, + poisonDamage, + poisonCount: count, + hasActivePoison: true, }; } - const maxHealth = summit.beast.health + summit.beast.bonus_health; - const totalPoolBefore = summit.beast.extra_lives * maxHealth + summit.beast.current_health; const totalPoolAfter = totalPoolBefore - poisonDamage; if (totalPoolAfter <= 0) { - return { currentHealth: 1, extraLives: 0 }; + return { + currentHealth: 1, + extraLives: 0, + totalPoolBefore, + poisonDamage, + poisonCount: count, + hasActivePoison: true, + }; } const extraLivesAfter = Math.floor((totalPoolAfter - 1) / maxHealth); @@ -293,6 +328,37 @@ export function applyPoisonDamage( return { currentHealth: currentHealthAfter, extraLives: extraLivesAfter, + totalPoolBefore, + poisonDamage, + poisonCount: count, + hasActivePoison: true, + }; +} + +export function getPoisonFloorProjection( + summit: Summit, + nowSec = Math.floor(Date.now() / 1000), +): PoisonFloorProjection { + const projection = projectPoisonDamage(summit, nowSec); + + if (!projection.hasActivePoison) { + return { + currentHealth: projection.currentHealth, + extraLives: projection.extraLives, + ready: false, + secondsUntilFloor: null, + }; + } + + const damageNeededForFloor = Math.max(0, projection.totalPoolBefore - 1); + const remainingDamage = Math.max(0, damageNeededForFloor - projection.poisonDamage); + const secondsUntilFloor = Math.ceil(remainingDamage / projection.poisonCount); + + return { + currentHealth: projection.currentHealth, + extraLives: projection.extraLives, + ready: secondsUntilFloor === 0, + secondsUntilFloor, }; }