3 Commits

Author SHA1 Message Date
hikari 8a332dc9ce fix: show effective post-multiplier stats on adventurer cards (#154)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m2s
CI / Lint, Build & Test (pull_request) Failing after 1m8s
Adds computeEffectiveAdventurerStats to tick.ts to calculate per-unit
gold/s, essence/s, and combat power with all active multipliers applied
(upgrades, prestige, equipment, echo, crafted, companions). Updates
AdventurerCard to display these effective values so players can see the
true contribution of each adventurer rather than raw base stats.
2026-03-25 17:13:00 -07:00
hikari 56d963dc90 fix: clarify combat power vs boss damage distinction (#153)
Expands the JSDoc on computePartyCombatPower to explicitly document
that the companion bossDamage multiplier is intentionally included in
all combat-power calculations (boss panel, resource bar, quest gating),
matching server-side behaviour and resolving labelling ambiguity.
2026-03-25 17:07:13 -07:00
hikari 77c7ee02a6 fix: assign upgrade rewards to late-game bosses (#140)
Distributes the nine unassigned adventurer-specific upgrade rewards
across Crystalline Spire through Eternal Throne bosses that previously
had empty upgradeRewards arrays, ensuring all adventurer upgrades are
obtainable via boss drops.
2026-03-25 17:05:56 -07:00
3 changed files with 151 additions and 18 deletions
+9 -9
View File
@@ -628,7 +628,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Prism Golem", name: "The Prism Golem",
prestigeRequirement: 3, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "crystal_sage_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
@@ -664,7 +664,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Faceted", name: "The Faceted",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "void_sentinel_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
@@ -682,7 +682,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Diamond Colossus", name: "The Diamond Colossus",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "eternal_champion_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
@@ -700,7 +700,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Crystal Sovereign", name: "The Crystal Sovereign",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "cosmos_knight_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
// ── Void Sanctum ────────────────────────────────────────────────────────── // ── Void Sanctum ──────────────────────────────────────────────────────────
@@ -719,7 +719,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Herald", name: "The Void Herald",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "seraph_knight_1" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
@@ -755,7 +755,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Unmaker", name: "The Unmaker",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "abyss_diver_1" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
@@ -791,7 +791,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Emperor", name: "The Void Emperor",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "infernal_warden_1" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
// ── Eternal Throne ──────────────────────────────────────────────────────── // ── Eternal Throne ────────────────────────────────────────────────────────
@@ -810,7 +810,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Throne Warden", name: "The Throne Warden",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "infinity_ranger_1" ],
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
@@ -846,7 +846,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Undying", name: "The Undying",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "reality_warden_1" ],
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
@@ -9,6 +9,7 @@
/* eslint-disable complexity -- Complex component with many render paths */ /* eslint-disable complexity -- Complex component with many render paths */
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 { computeEffectiveAdventurerStats } 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 type { Adventurer } from "@elysium/types"; import type { Adventurer } from "@elysium/types";
@@ -76,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
return quantity; return quantity;
}; };
interface EffectiveAdventurerStats {
readonly combatPower: number;
readonly essencePerSecond: number;
readonly goldPerSecond: number;
}
interface AdventurerCardProperties { interface AdventurerCardProperties {
readonly adventurer: Adventurer; readonly adventurer: Adventurer;
readonly currentGold: number; readonly currentGold: number;
readonly batchSize: BatchSize; readonly batchSize: BatchSize;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
readonly effectiveStats: EffectiveAdventurerStats;
} }
/** /**
@@ -92,6 +100,7 @@ interface AdventurerCardProperties {
* @param props.batchSize - The selected batch size. * @param props.batchSize - The selected batch size.
* @param props.unlockHint - Optional quest name that unlocks this adventurer. * @param props.unlockHint - Optional quest name that unlocks this adventurer.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @param props.effectiveStats - The post-multiplier per-unit stats.
* @returns The JSX element. * @returns The JSX element.
*/ */
const AdventurerCard = ({ const AdventurerCard = ({
@@ -100,6 +109,7 @@ const AdventurerCard = ({
batchSize, batchSize,
unlockHint, unlockHint,
formatNumber, formatNumber,
effectiveStats,
}: AdventurerCardProperties): JSX.Element => { }: AdventurerCardProperties): JSX.Element => {
const { buyAdventurer } = useGame(); const { buyAdventurer } = useGame();
@@ -134,17 +144,17 @@ const AdventurerCard = ({
<div className="adventurer-info"> <div className="adventurer-info">
<h3>{adventurer.name}</h3> <h3>{adventurer.name}</h3>
<p> <p>
{formatNumber(adventurer.goldPerSecond)} {formatNumber(effectiveStats.goldPerSecond)}
{" gold/s each"} {" gold/s each"}
</p> </p>
{adventurer.essencePerSecond > 0 {adventurer.essencePerSecond > 0
&& <p> && <p>
{formatNumber(adventurer.essencePerSecond)} {formatNumber(effectiveStats.essencePerSecond)}
{" essence/s each"} {" essence/s each"}
</p> </p>
} }
<p> <p>
{formatNumber(adventurer.combatPower)} {formatNumber(effectiveStats.combatPower)}
{" combat power each"} {" combat power each"}
</p> </p>
</div> </div>
@@ -280,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
adventurer={adventurer} adventurer={adventurer}
batchSize={batchSize} batchSize={batchSize}
currentGold={state.resources.gold} currentGold={state.resources.gold}
effectiveStats={computeEffectiveAdventurerStats(
state,
adventurer.id,
)}
formatNumber={formatNumber} formatNumber={formatNumber}
key={adventurer.id} key={adventurer.id}
unlockHint={adventurerUnlockHints.get(adventurer.id)} unlockHint={adventurerUnlockHints.get(adventurer.id)}
+120 -1
View File
@@ -243,10 +243,129 @@ export const computeEssencePerSecond = (state: GameState): number => {
return essencePerSecond; return essencePerSecond;
}; };
/**
* Computes the effective per-unit stats for a single adventurer type,
* applying all active multipliers (upgrades, prestige, equipment, echo,
* crafted, companion). The returned values represent what a single
* adventurer of this type currently contributes per second, matching the
* per-unit contribution used by computeGoldPerSecond and
* computeEssencePerSecond.
* @param state - The current game state.
* @param adventurerId - The ID of the adventurer to compute stats for.
* @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower.
*/
export const computeEffectiveAdventurerStats = (
state: GameState,
adventurerId: string,
): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => {
const adventurer = state.adventurers.find((a) => {
return a.id === adventurerId;
});
/* V8 ignore next 3 -- @preserve */
if (adventurer === undefined) {
return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 };
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurerId;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const equippedItems = state.equipment.filter((item) => {
return item.equipped;
});
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.goldMultiplier ?? 1);
}, 1);
const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
const equippedItemIds = equippedItems.map((item) => {
return item.id;
});
const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS);
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeCombatMultiplier = 1 + state.prestige.count * 0.1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
const craftedGoldMultiplier
= state.exploration?.craftedGoldMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionGoldMult
= companionBonus?.type === "passiveGold"
? 1 + companionBonus.value
: 1;
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
const goldPerSecond
= adventurer.goldPerSecond
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesIncome
* echoIncome
* equipmentGoldMultiplier
* setBonuses.goldMultiplier
* craftedGoldMultiplier
* companionGoldMult;
const essencePerSecond
= adventurer.essencePerSecond
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
const combatPower
= adventurer.combatPower
* upgradeMultiplier
* prestigeCombatMultiplier
* equipmentCombatMultiplier
* setBonuses.combatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
return { combatPower, essencePerSecond, goldPerSecond };
};
/** /**
* Computes the party's total combat power, applying all active multipliers * Computes the party's total combat power, applying all active multipliers
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion). * (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
* This mirrors the server-side calculatePartyStats in boss.ts. * This mirrors the server-side calculatePartyStats in boss.ts and is the
* single source of truth for all combat-power checks in the client:
* - Displayed as "Combat Power" in the resource bar
* - Displayed as "Party DPS" in the boss panel
* - Used to gate quest availability
* Note: the active companion's bossDamage bonus is intentionally included
* here, as it applies to the full combat power calculation (boss fights and
* quest gating alike), matching the server-side behaviour.
* @param state - The current game state. * @param state - The current game state.
* @returns The total party combat power. * @returns The total party combat power.
*/ */