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>
375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
/**
|
|
* @file Quest panel component for managing and completing quests.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
|
|
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
|
/* eslint-disable complexity -- Many conditional render paths */
|
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
|
import { useState, type JSX } from "react";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import {
|
|
computePartyCombatPower,
|
|
zoneFailureChance,
|
|
} from "../../engine/tick.js";
|
|
import { cdnImage } from "../../utils/cdn.js";
|
|
import { LockToggle } from "../ui/lockToggle.js";
|
|
import { ZoneSelector } from "./zoneSelector.js";
|
|
import type { Quest } from "@elysium/types";
|
|
|
|
/**
|
|
* Formats a duration in seconds to a human-readable string.
|
|
* @param seconds - The total number of seconds to format.
|
|
* @returns The formatted duration string.
|
|
*/
|
|
const formatDuration = (seconds: number): string => {
|
|
const secondsPerHour = 3600;
|
|
const secondsPerMinute = 60;
|
|
if (seconds >= secondsPerHour) {
|
|
const hours = Math.floor(seconds / secondsPerHour);
|
|
const remainderSeconds = seconds % secondsPerHour;
|
|
const minutes = Math.floor(remainderSeconds / secondsPerMinute);
|
|
return `${String(hours)}h ${String(minutes)}m`;
|
|
}
|
|
if (seconds >= secondsPerMinute) {
|
|
const minutes = Math.floor(seconds / secondsPerMinute);
|
|
const secs = seconds % secondsPerMinute;
|
|
return `${String(minutes)}m ${String(secs)}s`;
|
|
}
|
|
return `${String(seconds)}s`;
|
|
};
|
|
|
|
/**
|
|
* Computes the time remaining for an active quest.
|
|
* @param quest - The quest to check.
|
|
* @returns The remaining seconds.
|
|
*/
|
|
const questTimeRemaining = (quest: Quest): number => {
|
|
if (quest.status !== "active" || quest.startedAt === undefined) {
|
|
return 0;
|
|
}
|
|
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
|
return Math.max(0, quest.durationSeconds - elapsed);
|
|
};
|
|
|
|
interface QuestCardProperties {
|
|
readonly quest: Quest;
|
|
readonly partyCombatPower: number;
|
|
readonly unlockHint: string | undefined;
|
|
readonly zoneHint: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Renders a single quest card.
|
|
* @param props - The quest card properties.
|
|
* @param props.quest - The quest to display.
|
|
* @param props.partyCombatPower - The current party's combat power.
|
|
* @param props.unlockHint - Optional hint for how to unlock this quest.
|
|
* @param props.zoneHint - Optional hint for which zone to unlock.
|
|
* @returns The JSX element.
|
|
*/
|
|
const QuestCard = ({
|
|
quest,
|
|
partyCombatPower,
|
|
unlockHint,
|
|
zoneHint,
|
|
}: QuestCardProperties): JSX.Element => {
|
|
const { startQuest, formatNumber } = useGame();
|
|
const cpRequired = quest.combatPowerRequired ?? 0;
|
|
const meetsCP = partyCombatPower >= cpRequired;
|
|
|
|
function handleStartQuest(): void {
|
|
startQuest(quest.id);
|
|
}
|
|
|
|
return (
|
|
<div className={`quest-card quest-${quest.status}`}>
|
|
<img
|
|
alt={quest.name}
|
|
className="card-thumbnail"
|
|
src={cdnImage("quests", quest.id)}
|
|
/>
|
|
<div className="quest-info">
|
|
<h3>{quest.name}</h3>
|
|
<p>{quest.description}</p>
|
|
{cpRequired > 0
|
|
&& <p
|
|
className={`quest-cp-requirement ${
|
|
meetsCP
|
|
? "cp-met"
|
|
: "cp-unmet"
|
|
}`}
|
|
>
|
|
{"⚔️ Requires "}
|
|
{formatNumber(cpRequired)}
|
|
{" Combat Power"}
|
|
{quest.status === "available"
|
|
&& (meetsCP
|
|
? " ✓"
|
|
: ` (you have ${formatNumber(partyCombatPower)})`)}
|
|
</p>
|
|
}
|
|
<div className="quest-rewards">
|
|
{quest.rewards.map((reward, rewardIndex) => {
|
|
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
|
{reward.type === "gold"
|
|
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
|
{reward.type === "essence"
|
|
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
|
{reward.type === "crystals"
|
|
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
|
{reward.type === "upgrade" && "🔓 Upgrade"}
|
|
{reward.type === "adventurer" && "👥 New Adventurer"}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="quest-action">
|
|
{quest.status === "locked"
|
|
&& <>
|
|
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
|
{zoneHint === undefined
|
|
? null
|
|
: <p className="unlock-hint">
|
|
{"🗺️ Unlock zone: "}
|
|
{zoneHint}
|
|
</p>
|
|
}
|
|
{zoneHint === undefined && unlockHint !== undefined
|
|
? <p className="unlock-hint">
|
|
{"📜 Complete: "}
|
|
{unlockHint}
|
|
</p>
|
|
: null}
|
|
</>
|
|
}
|
|
{quest.status === "available"
|
|
&& <p className="quest-failure-chance">
|
|
{"🎲 "}
|
|
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
|
{"% failure chance"}
|
|
</p>
|
|
}
|
|
{quest.status === "available" && quest.lastFailedAt !== undefined
|
|
&& <p className="quest-failed-hint">
|
|
{"⚠️ Last attempt failed — no rewards were granted."}
|
|
</p>
|
|
}
|
|
{quest.status === "available"
|
|
&& <button
|
|
className="start-quest-button"
|
|
disabled={!meetsCP}
|
|
onClick={handleStartQuest}
|
|
title={
|
|
meetsCP
|
|
? undefined
|
|
: `Need ${formatNumber(cpRequired)} combat power`
|
|
}
|
|
type="button"
|
|
>
|
|
{"Send Party ("}
|
|
{formatDuration(quest.durationSeconds)}
|
|
{")"}
|
|
</button>
|
|
}
|
|
{quest.status === "active"
|
|
&& <span className="quest-badge active">
|
|
{"⏳ "}
|
|
{formatDuration(Math.ceil(questTimeRemaining(quest)))}
|
|
{" remaining"}
|
|
</span>
|
|
}
|
|
{quest.status === "completed"
|
|
&& <span className="quest-badge completed">{"✅ Complete"}</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Renders the quest panel with zone selection and quest list.
|
|
* @returns The JSX element.
|
|
*/
|
|
const QuestPanel = (): JSX.Element => {
|
|
const { state, toggleAutoQuest } = useGame();
|
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
|
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
|
|
});
|
|
const [ showLocked, setShowLocked ] = useState(true);
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const { autoQuest, bosses, quests, zones } = state;
|
|
|
|
const activeZone = zones.find((zone) => {
|
|
return zone.id === activeZoneId;
|
|
});
|
|
const zoneIsLocked = activeZone?.status === "locked";
|
|
const unlockBoss = activeZone?.unlockBossId === null
|
|
|| activeZone?.unlockBossId === undefined
|
|
? undefined
|
|
: bosses.find((boss) => {
|
|
return boss.id === activeZone.unlockBossId;
|
|
});
|
|
const unlockQuest = activeZone?.unlockQuestId === null
|
|
|| activeZone?.unlockQuestId === undefined
|
|
? undefined
|
|
: quests.find((quest) => {
|
|
return quest.id === activeZone.unlockQuestId;
|
|
});
|
|
const partyCombatPower = computePartyCombatPower(state);
|
|
const zoneQuests = quests.filter(({ zoneId }) => {
|
|
return zoneId === activeZoneId;
|
|
});
|
|
const lockedCount = zoneQuests.filter(({ status }) => {
|
|
return status === "locked";
|
|
}).length;
|
|
const visibleQuests = showLocked
|
|
? zoneQuests
|
|
: zoneQuests.filter(({ status }) => {
|
|
return status !== "locked";
|
|
});
|
|
|
|
const questNameById = new Map(
|
|
quests.map(({ id, name }) => {
|
|
return [ id, name ];
|
|
}),
|
|
);
|
|
const zoneById = new Map(
|
|
zones.map((zone) => {
|
|
return [ zone.id, zone ];
|
|
}),
|
|
);
|
|
const questUnlockHints = new Map<string, string>();
|
|
const questZoneHints = new Map<string, string>();
|
|
for (const { id: questId, status, zoneId, prerequisiteIds } of quests) {
|
|
if (status !== "locked") {
|
|
continue;
|
|
}
|
|
const zone = zoneById.get(zoneId);
|
|
if (zone?.status === "locked") {
|
|
questZoneHints.set(questId, zone.name);
|
|
} else if (prerequisiteIds.length > 0) {
|
|
const [ prereqId ] = prerequisiteIds;
|
|
if (prereqId !== undefined) {
|
|
const prereqName = questNameById.get(prereqId);
|
|
if (prereqName !== undefined) {
|
|
questUnlockHints.set(questId, prereqName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleZoneSelect(zoneId: string): void {
|
|
setActiveZoneId(zoneId);
|
|
sessionStorage.setItem("elysium_quest_zone", zoneId);
|
|
}
|
|
|
|
function handleToggle(): void {
|
|
setShowLocked((current) => {
|
|
return !current;
|
|
});
|
|
}
|
|
|
|
function handleAutoQuest(): void {
|
|
toggleAutoQuest();
|
|
}
|
|
|
|
const autoQuestOn = autoQuest === true;
|
|
|
|
return (
|
|
<section className="panel quest-panel">
|
|
<div className="panel-header">
|
|
<h2>{"Quests"}</h2>
|
|
<div className="panel-header-controls">
|
|
<button
|
|
className={`auto-toggle-btn ${
|
|
autoQuestOn
|
|
? "auto-toggle-on"
|
|
: "auto-toggle-off"
|
|
}`}
|
|
onClick={handleAutoQuest}
|
|
title="Automatically send the party on the highest available quest"
|
|
type="button"
|
|
>
|
|
{"🤖 Auto: "}
|
|
{autoQuestOn
|
|
? "ON"
|
|
: "OFF"}
|
|
</button>
|
|
<LockToggle
|
|
lockedCount={lockedCount}
|
|
onToggle={handleToggle}
|
|
showLocked={showLocked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ZoneSelector
|
|
activeZoneId={activeZoneId}
|
|
onSelectZone={handleZoneSelect}
|
|
zones={zones}
|
|
/>
|
|
|
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
|
? <div className="exploration-zone-locked-hint">
|
|
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
|
|
{unlockBoss === undefined
|
|
? null
|
|
: <p>
|
|
{"⚔️ Defeat: "}
|
|
{unlockBoss.name}
|
|
</p>
|
|
}
|
|
{unlockQuest === undefined
|
|
? null
|
|
: <p>
|
|
{"📜 Complete: "}
|
|
{unlockQuest.name}
|
|
</p>
|
|
}
|
|
</div>
|
|
: null
|
|
}
|
|
|
|
<p className="quest-failure-note">
|
|
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
|
|
</p>
|
|
|
|
<div className="quest-list">
|
|
{visibleQuests.map((quest) => {
|
|
return (
|
|
<QuestCard
|
|
key={quest.id}
|
|
partyCombatPower={partyCombatPower}
|
|
quest={quest}
|
|
unlockHint={questUnlockHints.get(quest.id)}
|
|
zoneHint={questZoneHints.get(quest.id)}
|
|
/>
|
|
);
|
|
})}
|
|
{visibleQuests.length === 0
|
|
&& <p className="empty-zone">{"No quests to show in this zone."}</p>
|
|
}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { QuestPanel };
|