generated from nhcarrigan/template
9bb1d01d2b
## 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>
227 lines
7.0 KiB
TypeScript
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 };
|