From 7b81f6cb3325349d4eed4daebc743a88fe53b62c Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 16:38:42 -0700 Subject: [PATCH] fix: resolve auto-boss signature mismatch, expose full CP, cap auto-buy, show unlock hints Closes #148: clear stale signature after each boss fight so subsequent auto-boss pre-saves don't send a mismatched HMAC. Closes #151: auto-buy skips non-max-tier adventurers once they reach 100, keeping gold flowing to the highest-unlocked tier. Closes #152: introduce computePartyCombatPower() in tick.ts mirroring the server-side formula (global upgrades, prestige, equipment, set bonuses, echo, crafted, companion). Resource bar, auto-quest gate, and boss panel all now use the same multiplier-accurate value. Closes #146: tick engine auto-unlocks adventurer-specific upgrades when their adventurer is first recruited; upgrade panel shows a recruit hint for locked entries with no boss/quest source. --- apps/web/src/components/game/bossPanel.tsx | 85 +++------------- apps/web/src/components/game/upgradePanel.tsx | 18 ++++ apps/web/src/components/ui/resourceBar.tsx | 6 +- apps/web/src/context/gameContext.tsx | 33 +++++-- apps/web/src/engine/tick.ts | 97 +++++++++++++++++++ 5 files changed, 160 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx index e676142..3167a28 100644 --- a/apps/web/src/components/game/bossPanel.tsx +++ b/apps/web/src/components/game/bossPanel.tsx @@ -11,10 +11,11 @@ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; +import { computePartyCombatPower } from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { LockToggle } from "../ui/lockToggle.js"; import { ZoneSelector } from "./zoneSelector.js"; -import type { Boss, GameState } from "@elysium/types"; +import type { Boss } from "@elysium/types"; interface BossCardProperties { readonly boss: Boss; @@ -157,72 +158,6 @@ const BossCard = ({ ); }; -/** - * Computes party DPS and HP from the current game state. - * @param state - The full game state. - * @returns The computed party DPS and HP values. - */ -const computePartyStats = ( - state: GameState, -): { - partyDps: number; - partyHp: number; -} => { - const { upgrades, adventurers, equipment, prestige } = state; - let globalMultiplier = 1; - for (const upgrade of upgrades) { - const { purchased, target, multiplier } = upgrade; - if (purchased && target === "global") { - globalMultiplier = globalMultiplier * multiplier; - } - } - const prestigeBonus = prestige.count * 0.1; - const prestigeMultiplier = 1 + prestigeBonus; - const equipmentCombatMultiplier = equipment. - filter((item) => { - return item.equipped && item.bonus.combatMultiplier !== undefined; - }). - reduce((multiplier, item) => { - return multiplier * (item.bonus.combatMultiplier ?? 1); - }, 1); - - let partyDps = 0; - let partyHp = 0; - for (const adventurer of adventurers) { - const { count, id: adventurerId, combatPower, level } = adventurer; - if (count === 0) { - continue; - } - let adventurerMultiplier = 1; - for (const upgrade of upgrades) { - const { - purchased, - target, - multiplier, - adventurerId: upgradeAdventurerId, - } = upgrade; - if ( - purchased - && target === "adventurer" - && upgradeAdventurerId === adventurerId - ) { - adventurerMultiplier = adventurerMultiplier * multiplier; - } - } - const dps - = combatPower - * count - * adventurerMultiplier - * globalMultiplier - * prestigeMultiplier; - partyDps = partyDps + dps; - const hp = level * 50 * count; - partyHp = partyHp + hp; - } - partyDps = partyDps * equipmentCombatMultiplier; - return { partyDps, partyHp }; -}; - /** * Renders the boss panel with zone selection and boss list. * @returns The JSX element. @@ -266,7 +201,14 @@ const BossPanel = (): JSX.Element => { void handleChallenge(bossId); } - const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; + const { + adventurers, + autoBoss, + bosses, + prestige: playerPrestige, + quests, + zones, + } = state; const activeZone = zones.find((zone) => { return zone.id === activeZoneId; @@ -349,7 +291,12 @@ const BossPanel = (): JSX.Element => { } const autoBossOn = autoBoss === true; - const { partyDps, partyHp } = computePartyStats(state); + const partyDps = computePartyCombatPower(state); + let partyHp = 0; + for (const { level, count } of adventurers) { + // eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear + partyHp = partyHp + level * 50 * count; + } const { count: prestigeCount } = playerPrestige; return ( diff --git a/apps/web/src/components/game/upgradePanel.tsx b/apps/web/src/components/game/upgradePanel.tsx index 19a9117..63db25c 100644 --- a/apps/web/src/components/game/upgradePanel.tsx +++ b/apps/web/src/components/game/upgradePanel.tsx @@ -7,6 +7,8 @@ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ +/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */ +/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; import { cdnImage } from "../../utils/cdn.js"; @@ -238,6 +240,22 @@ const UpgradePanel = (): JSX.Element => { } } } + for (const upgrade of locked) { + if ( + !upgradeUnlockHints.has(upgrade.id) + && upgrade.adventurerId !== undefined + ) { + const adventurerForHint = adventurers.find((a) => { + return a.id === upgrade.adventurerId; + }); + if (adventurerForHint !== undefined) { + upgradeUnlockHints.set( + upgrade.id, + `🗡️ Recruit: ${adventurerForHint.name}`, + ); + } + } + } function handleToggle(): void { setShowLocked((current) => { diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index ec4d8a2..1743a6f 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -14,6 +14,7 @@ import { RESOURCE_CAP, computeEssencePerSecond, computeGoldPerSecond, + computePartyCombatPower, } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; @@ -89,10 +90,7 @@ const ResourceBar = ({ let goldPerSecond = 0; let essencePerSecond = 0; if (state !== null) { - for (const adventurer of state.adventurers) { - const contribution = adventurer.combatPower * adventurer.count; - partyCombatPower = partyCombatPower + contribution; - } + partyCombatPower = computePartyCombatPower(state); goldPerSecond = computeGoldPerSecond(state); essencePerSecond = computeEssencePerSecond(state); } diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 24762bb..22b2ec3 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -58,6 +58,7 @@ import { RESOURCE_CAP, applyTick, calculateClickPower, + computePartyCombatPower, } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; @@ -1078,11 +1079,7 @@ export const GameProvider = ({ return q.status === "active"; }); if (!hasActiveQuest) { - // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! - const partyCombatPower = next.adventurers.reduce((total, a) => { - const power = total + a.combatPower; - return power * a.count; - }, 0); + const partyCombatPower = computePartyCombatPower(next); const zoneOrder = new Map( next.zones.map((z, index) => { return [ z.id, index ]; @@ -1120,11 +1117,28 @@ export const GameProvider = ({ next.autoAdventurer === true && next.prestige.purchasedUpgradeIds.includes("auto_adventurer") ) { + const maxAdventurerLevel = Math.max( + ...next.adventurers. + filter((a) => { + return a.unlocked; + }). + map((a) => { + return a.level; + }), + ); + const autoBuyCap = 100; const [ bestAdventurer ] = next.adventurers. filter((adventurer) => { const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count); - return adventurer.unlocked && next.resources.gold >= cost; + const isMaxTier = adventurer.level === maxAdventurerLevel; + const withinCap + = isMaxTier || adventurer.count < autoBuyCap; + return ( + adventurer.unlocked + && next.resources.gold >= cost + && withinCap + ); }). sort((adventurerA, adventurerB) => { return adventurerB.level - adventurerA.level; @@ -1346,6 +1360,13 @@ export const GameProvider = ({ } return afterBoss; }); + + /* + * Boss fight modifies server state; clear stale signature so + * the next pre-save or auto-save does not send a mismatched one. + */ + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); setAutoBossLastResult({ at: Date.now(), bossName: bossName, diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 7b77cb0..c8b42a4 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -243,6 +243,90 @@ export const computeEssencePerSecond = (state: GameState): number => { return essencePerSecond; }; +/** + * Computes the party's total combat power, applying all active multipliers + * (upgrades, prestige, equipment, set bonuses, echo, crafted, companion). + * This mirrors the server-side calculatePartyStats in boss.ts. + * @param state - The current game state. + * @returns The total party combat power. + */ +export const computePartyCombatPower = (state: GameState): number => { + let globalMultiplier = 1; + for (const upgrade of state.upgrades) { + if (upgrade.purchased && upgrade.target === "global") { + globalMultiplier = globalMultiplier * upgrade.multiplier; + } + } + + // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear + const prestigeMultiplier = 1 + state.prestige.count * 0.1; + + const equipmentCombatMultiplier = state.equipment. + filter((item) => { + return item.equipped && item.bonus.combatMultiplier !== undefined; + }). + reduce((mult, item) => { + return mult * (item.bonus.combatMultiplier ?? 1); + }, 1); + + const equippedItemIds = state.equipment. + filter((item) => { + return item.equipped; + }). + map((item) => { + return item.id; + }); + const { combatMultiplier: setCombatMultiplier } = computeSetBonuses( + equippedItemIds, + EQUIPMENT_SETS, + ); + + const echoCombatMultiplier + = state.transcendence?.echoCombatMultiplier ?? 1; + const craftedCombatMultiplier + = state.exploration?.craftedCombatMultiplier ?? 1; + + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionCombatMult + = companionBonus?.type === "bossDamage" + ? 1 + companionBonus.value + : 1; + + let partyCombatPower = 0; + for (const adventurer of state.adventurers) { + if (adventurer.count === 0) { + continue; + } + let adventurerMultiplier = 1; + for (const upgrade of state.upgrades) { + if ( + upgrade.purchased + && upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id + ) { + adventurerMultiplier = adventurerMultiplier * upgrade.multiplier; + } + } + const contribution + = adventurer.combatPower + * adventurer.count + * adventurerMultiplier + * globalMultiplier + * prestigeMultiplier; + partyCombatPower = partyCombatPower + contribution; + } + + return partyCombatPower + * equipmentCombatMultiplier + * setCombatMultiplier + * echoCombatMultiplier + * craftedCombatMultiplier + * companionCombatMult; +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. @@ -517,6 +601,19 @@ export const applyTick = ( challengeCrystals = result.crystalsAwarded; } + // Auto-unlock adventurer-specific upgrades when their adventurer is recruited + updatedUpgrades = updatedUpgrades.map((upgrade) => { + if (upgrade.unlocked || upgrade.adventurerId === undefined) { + return upgrade; + } + const adventurer = updatedAdventurers.find((a) => { + return a.id === upgrade.adventurerId; + }); + return adventurer !== undefined && adventurer.count > 0 + ? { ...upgrade, unlocked: true } + : upgrade; + }); + const goldValue = capResource(state.resources.gold + goldGained + questGold); const essenceValue = capResource( state.resources.essence + essenceGained + questEssence,