generated from nhcarrigan/template
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.
This commit is contained in:
@@ -11,10 +11,11 @@
|
|||||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { computePartyCombatPower } from "../../engine/tick.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss, GameState } from "@elysium/types";
|
import type { Boss } from "@elysium/types";
|
||||||
|
|
||||||
interface BossCardProperties {
|
interface BossCardProperties {
|
||||||
readonly boss: Boss;
|
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.
|
* Renders the boss panel with zone selection and boss list.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -266,7 +201,14 @@ const BossPanel = (): JSX.Element => {
|
|||||||
void handleChallenge(bossId);
|
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) => {
|
const activeZone = zones.find((zone) => {
|
||||||
return zone.id === activeZoneId;
|
return zone.id === activeZoneId;
|
||||||
@@ -349,7 +291,12 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoBossOn = autoBoss === true;
|
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;
|
const { count: prestigeCount } = playerPrestige;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* 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 max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
/* 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 { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { cdnImage } from "../../utils/cdn.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 {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
RESOURCE_CAP,
|
RESOURCE_CAP,
|
||||||
computeEssencePerSecond,
|
computeEssencePerSecond,
|
||||||
computeGoldPerSecond,
|
computeGoldPerSecond,
|
||||||
|
computePartyCombatPower,
|
||||||
} from "../../engine/tick.js";
|
} from "../../engine/tick.js";
|
||||||
import type { Resource } from "@elysium/types";
|
import type { Resource } from "@elysium/types";
|
||||||
|
|
||||||
@@ -89,10 +90,7 @@ const ResourceBar = ({
|
|||||||
let goldPerSecond = 0;
|
let goldPerSecond = 0;
|
||||||
let essencePerSecond = 0;
|
let essencePerSecond = 0;
|
||||||
if (state !== null) {
|
if (state !== null) {
|
||||||
for (const adventurer of state.adventurers) {
|
partyCombatPower = computePartyCombatPower(state);
|
||||||
const contribution = adventurer.combatPower * adventurer.count;
|
|
||||||
partyCombatPower = partyCombatPower + contribution;
|
|
||||||
}
|
|
||||||
goldPerSecond = computeGoldPerSecond(state);
|
goldPerSecond = computeGoldPerSecond(state);
|
||||||
essencePerSecond = computeEssencePerSecond(state);
|
essencePerSecond = computeEssencePerSecond(state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
RESOURCE_CAP,
|
RESOURCE_CAP,
|
||||||
applyTick,
|
applyTick,
|
||||||
calculateClickPower,
|
calculateClickPower,
|
||||||
|
computePartyCombatPower,
|
||||||
} from "../engine/tick.js";
|
} from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||||
@@ -1078,11 +1079,7 @@ export const GameProvider = ({
|
|||||||
return q.status === "active";
|
return q.status === "active";
|
||||||
});
|
});
|
||||||
if (!hasActiveQuest) {
|
if (!hasActiveQuest) {
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
const partyCombatPower = computePartyCombatPower(next);
|
||||||
const partyCombatPower = next.adventurers.reduce((total, a) => {
|
|
||||||
const power = total + a.combatPower;
|
|
||||||
return power * a.count;
|
|
||||||
}, 0);
|
|
||||||
const zoneOrder = new Map(
|
const zoneOrder = new Map(
|
||||||
next.zones.map((z, index) => {
|
next.zones.map((z, index) => {
|
||||||
return [ z.id, index ];
|
return [ z.id, index ];
|
||||||
@@ -1120,11 +1117,28 @@ export const GameProvider = ({
|
|||||||
next.autoAdventurer === true
|
next.autoAdventurer === true
|
||||||
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
|
&& 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.
|
const [ bestAdventurer ] = next.adventurers.
|
||||||
filter((adventurer) => {
|
filter((adventurer) => {
|
||||||
const cost
|
const cost
|
||||||
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
|
= 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) => {
|
sort((adventurerA, adventurerB) => {
|
||||||
return adventurerB.level - adventurerA.level;
|
return adventurerB.level - adventurerA.level;
|
||||||
@@ -1346,6 +1360,13 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
return afterBoss;
|
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({
|
setAutoBossLastResult({
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
bossName: bossName,
|
bossName: bossName,
|
||||||
|
|||||||
@@ -243,6 +243,90 @@ export const computeEssencePerSecond = (state: GameState): number => {
|
|||||||
return essencePerSecond;
|
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.
|
* Pure function — applies one game tick to the state.
|
||||||
* DeltaSeconds: time elapsed since last tick.
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
@@ -517,6 +601,19 @@ export const applyTick = (
|
|||||||
challengeCrystals = result.crystalsAwarded;
|
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 goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||||
const essenceValue = capResource(
|
const essenceValue = capResource(
|
||||||
state.resources.essence + essenceGained + questEssence,
|
state.resources.essence + essenceGained + questEssence,
|
||||||
|
|||||||
Reference in New Issue
Block a user