Files
elysium/apps/web/src/components/game/companionPanel.tsx
T
hikari 9bb1d01d2b
CI / Lint, Build & Test (push) Successful in 2m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m13s
fix: resolve all 8 open bug tickets (#242–#249) (#250)
## Summary

- **#242** — Crystals in the resource bar now use `formatNumber` to respect the player's notation setting (suffix/scientific/engineering)
- **#243** — Companion unlock progress includes current-run gold (`totalGoldEarned`) on both client and server, so companions unlock at the correct threshold
- **#244** — Empty green reward bubbles no longer render for quest crystal rewards with a zero amount
- **#245/#248** — Auto-save skips when `isAutoPrestigingReference.current` is true, preventing it from racing with an in-flight prestige and breaking the optimistic lock
- **#246** — Generated and uploaded CDN images for `crystal_pulse`, `crystal_surge`, and `crystal_tempest` upgrades
- **#247** — `validateAndSanitize` merges daily challenge progress by taking the max of client vs. server progress per challenge, so stale auto-saves can no longer roll back server-side completions
- **#249** — Cached save signature is cleared after `buyPrestigeUpgrade` succeeds, preventing a stale-signature mismatch on the next auto-save

## Test plan

- [ ] Lint passes (`pnpm lint`)
- [ ] Build passes (`pnpm build`)
- [ ] Tests pass with 100% coverage (`pnpm test`)
- [ ] Crystals display in resource bar respects notation setting
- [ ] No empty reward bubbles on quests that don't award crystals
- [ ] Companion progress bar shows correct value including current-run gold
- [ ] Auto-prestige no longer causes save errors
- [ ] Crafting a recipe updates daily challenge progress persistently (not rolled back by next auto-save)
- [ ] Buying a prestige upgrade does not cause a signature mismatch error on next save
- [ ] Crystal upgrade images display correctly in-game

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #250
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 09:50:20 -07:00

227 lines
7.0 KiB
TypeScript

/**
* @file Companion panel component for managing active companions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
/* eslint-disable complexity -- Companion card has many conditional render paths */
import { COMPANIONS, type Companion } from "@elysium/types";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import type { JSX } from "react";
const bonusLabels: Record<string, string> = {
bossDamage: "Boss Damage",
clickGold: "Click Gold",
essenceIncome: "Essence Income",
passiveGold: "Passive Gold",
questTime: "Quest Time",
};
const unlockLabels: Record<string, string> = {
apotheosis: "apotheosis",
lifetimeBosses: "lifetime bosses defeated",
lifetimeGold: "lifetime gold earned",
lifetimeQuests: "lifetime quests completed",
prestige: "prestige(s)",
transcendence: "transcendence(s)",
};
interface CompanionCardProperties {
readonly companion: Companion;
readonly isUnlocked: boolean;
readonly isActive: boolean;
readonly onSelect: ()=> void;
readonly formatNumber: (n: number)=> string;
readonly currentProgress: number;
}
/**
* Renders a single companion card.
* @param props - The companion card properties.
* @param props.companion - The companion data.
* @param props.isUnlocked - Whether this companion is unlocked.
* @param props.isActive - Whether this companion is currently active.
* @param props.onSelect - Callback when the companion is selected/deselected.
* @param props.formatNumber - The number formatting utility function.
* @param props.currentProgress - The player's current progress toward the unlock threshold.
* @returns The JSX element.
*/
const CompanionCard = ({
companion,
isUnlocked,
isActive,
onSelect,
formatNumber,
currentProgress,
}: CompanionCardProperties): JSX.Element => {
const bonusSign = companion.bonus.type === "questTime"
? "-"
: "+";
const bonusPercent = Math.round(companion.bonus.value * 100);
const bonusLabel = bonusLabels[companion.bonus.type] ?? companion.bonus.type;
return (
<div
className={`companion-card ${
isUnlocked
? "companion-unlocked"
: "companion-locked"
} ${isActive
? "companion-active"
: ""}`}
>
<div className="companion-header">
<img
alt={companion.name}
className="card-thumbnail"
src={cdnImage("companions", companion.id)}
/>
<div className="companion-name-block">
<span className="companion-name">{companion.name}</span>
<span className="companion-title">{companion.title}</span>
</div>
{isActive
? <span className="companion-active-badge">{"Active"}</span>
: null}
</div>
<p className="companion-description">{companion.description}</p>
<div className="companion-bonus">
<span className="companion-bonus-label">{bonusLabel}</span>
<span className="companion-bonus-value">
{bonusSign}
{bonusPercent}
{"%"}
</span>
</div>
{isUnlocked
? <button
className={`companion-select-btn ${
isActive
? "companion-select-active"
: ""
}`}
onClick={onSelect}
type="button"
>
{isActive
? "Deactivate"
: "Activate"}
</button>
: <div className="companion-unlock-requirement">
<p>
{"🔒 Unlock: "}
{companion.unlock.type === "lifetimeGold"
? formatNumber(companion.unlock.threshold)
: String(companion.unlock.threshold)}{" "}
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
</p>
<div className="companion-progress">
<progress
max={companion.unlock.threshold}
value={Math.min(currentProgress, companion.unlock.threshold)}
/>
<span className="companion-progress-label">
{companion.unlock.type === "lifetimeGold"
? formatNumber(currentProgress)
: String(currentProgress)}
{" / "}
{companion.unlock.type === "lifetimeGold"
? formatNumber(companion.unlock.threshold)
: String(companion.unlock.threshold)}
</span>
</div>
</div>
}
</div>
);
};
/**
* Renders the companion panel with all companions.
* @returns The JSX element.
*/
const CompanionPanel = (): JSX.Element => {
const { formatNumber, setActiveCompanion, state } = useGame();
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
const activeId = state.companions?.activeCompanionId ?? null;
const progressByUnlockType: Record<string, number> = {
apotheosis: state.apotheosis?.count ?? 0,
lifetimeBosses: state.player.lifetimeBossesDefeated,
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
lifetimeQuests: state.player.lifetimeQuestsCompleted,
prestige: state.prestige.count,
transcendence: state.transcendence?.count ?? 0,
};
function handleSelect(companionId: string): void {
setActiveCompanion(activeId === companionId
? null
: companionId);
}
const activeCompanion
= activeId === null
? undefined
: COMPANIONS.find((companion) => {
return companion.id === activeId;
});
return (
<div className="companion-panel">
<h2>{"👥 Companions"}</h2>
<p className="companion-intro">
{"Companions provide powerful bonuses while active."
+ " You can only have one companion active at a time."}
{activeId === null
? null
: <>
{" Currently active: "}
<strong>{activeCompanion?.name ?? activeId}</strong>
{"."}
</>
}
</p>
<div className="companion-grid">
{COMPANIONS.map((companion) => {
function handleCompanionSelect(): void {
handleSelect(companion.id);
}
return (
<CompanionCard
companion={companion}
currentProgress={
progressByUnlockType[companion.unlock.type] ?? 0
}
formatNumber={formatNumber}
isActive={activeId === companion.id}
isUnlocked={unlockedIds.includes(companion.id)}
key={companion.id}
onSelect={handleCompanionSelect}
/>
);
})}
</div>
</div>
);
};
export { CompanionPanel };