generated from nhcarrigan/template
chore: fix lint, ensure full CI pipeline passes, add verify checklist
- Fix strict-boolean-expressions in 7 route files (runtime body validation) - Fix no-unnecessary-condition in profile.ts and offlineProgress.ts (defensive null checks) - Extend v8 ignore next-N counts in game.ts to reach 100% coverage - Add CI requirements to CLAUDE.md (lint + build + test must pass before commit) - Add manual verification checklist (verify.md) - Remove progress.md
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAbout } from "../../api/client.js";
|
||||
import type { AboutResponse } from "@elysium/types";
|
||||
|
||||
const HOW_TO_PLAY = [
|
||||
{
|
||||
title: "⚔️ Adventurers",
|
||||
body: "Hire adventurers to earn gold and essence automatically. Each tier is more powerful than the last. Adventurers also contribute combat power for boss fights — the more you recruit, the stronger your party becomes.",
|
||||
},
|
||||
{
|
||||
title: "👆 Clicking",
|
||||
body: "Click the guild hall to earn gold manually. Upgrades and equipment can dramatically increase your gold per click. Clicking is especially powerful in the early game and when saving up for big purchases.",
|
||||
},
|
||||
{
|
||||
title: "🔧 Upgrades",
|
||||
body: "Purchase upgrades to multiply the gold and essence output of specific adventurer tiers, or boost your whole guild. Upgrades are permanent for the current run and compound with each other.",
|
||||
},
|
||||
{
|
||||
title: "📜 Quests",
|
||||
body: "Send your guild on quests that complete over time and reward gold, essence, crystals, equipment, and upgrades. Multiple quests can run simultaneously. Completing quests also unlocks new zones.",
|
||||
},
|
||||
{
|
||||
title: "👹 Boss Fights",
|
||||
body: "Challenge zone bosses to earn large one-time rewards and unlock new zones. Your party's combat power is based on the number and tier of adventurers you've recruited. Defeated bosses cannot be re-fought, but undefeated bosses regenerate HP over time.",
|
||||
},
|
||||
{
|
||||
title: "🗺️ Zones",
|
||||
body: "New zones unlock when you defeat the final boss AND complete the final quest of the previous zone. Each zone contains new bosses and quests with progressively greater rewards.",
|
||||
},
|
||||
{
|
||||
title: "🗡️ Equipment & Sets",
|
||||
body: "Earn equipment from boss drops and quest rewards. Each piece provides bonuses to gold income, click power, or combat. Rarer equipment provides stronger bonuses. Equip matching set pieces (2 or 3 of a named set) to unlock escalating set bonuses shown at the top of the Equipment panel.",
|
||||
},
|
||||
{
|
||||
title: "⭐ Prestige",
|
||||
body: "When you've progressed far enough, you can prestige to earn runestones — a permanent currency that persists across all runs. Prestige resets your current run but grants a production multiplier that stacks with every prestige.",
|
||||
},
|
||||
{
|
||||
title: "🔮 Runestones & Prestige Upgrades",
|
||||
body: "Spend runestones in the Prestige Shop on permanent upgrades that carry over across all future runs. These upgrades multiply income, click power, essence, and crystal gain — making each new run more powerful than the last.",
|
||||
},
|
||||
{
|
||||
title: "⚙️ Auto-Prestige",
|
||||
body: "Purchase the Autonomous Ascension upgrade in the Prestige Shop (100 runestones) to unlock the Auto-Prestige toggle. When enabled, you will automatically ascend the moment you reach the prestige threshold, using your current character name. Toggle it on and off freely from the Prestige Shop.",
|
||||
},
|
||||
{
|
||||
title: "🏆 Achievements",
|
||||
body: "Earn achievements by hitting milestones — total gold earned, bosses defeated, quests completed, and more. Achievements are purely cosmetic and track your long-term progress across all prestige runs.",
|
||||
},
|
||||
{
|
||||
title: "📅 Daily Challenges",
|
||||
body: "Complete daily challenges for bonus rewards including gold, essence, crystals, and runestones. Challenges reset each day and vary in difficulty. Completing all daily challenges gives an extra bonus reward.",
|
||||
},
|
||||
{
|
||||
title: "🗺️ Exploration",
|
||||
body: "Send scouts to explore areas within each zone. Explorations run in real-time and reward gold, essence, and crafting materials when collected. Each area has a set duration — short explorations are faster but longer ones offer rarer finds. A 📖 icon marks areas you've collected from at least once, unlocking a Codex entry.",
|
||||
},
|
||||
{
|
||||
title: "⚗️ Crafting",
|
||||
body: "Use materials gathered from exploration to craft permanent bonuses. Each recipe provides a multiplier to gold income, essence income, click power, or combat power — all of which stack and persist across prestige runs. Check the Crafting tab to see your material inventory and available recipes per zone.",
|
||||
},
|
||||
{
|
||||
title: "📖 Codex",
|
||||
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, discovering new zones, collecting from exploration areas, and crafting recipes all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 472 entries to build a complete picture of the world of Elysium.",
|
||||
},
|
||||
{
|
||||
title: "📋 Character Sheet",
|
||||
body: "Visit the Character tab to write about your character and guild. Fill in your character's name, pronouns, race, class, and backstory, then create a guild with its own name and lore. Your character sheet is visible on your public profile page.",
|
||||
},
|
||||
{
|
||||
title: "🏅 Titles",
|
||||
body: "Earn Titles by reaching milestones — defeating bosses, completing quests, prestiging, and more. Once unlocked, titles are yours forever and are never lost on prestige or transcendence resets. Set your active title from the Character tab to display it on your character sheet and public profile.",
|
||||
},
|
||||
{
|
||||
title: "🗡️ Equipment",
|
||||
body: "Defeat bosses to earn equipment drops: weapons, armour, and trinkets. Each item provides bonuses to gold income, combat power, or click power. Only one item per slot can be equipped at a time — visit the Equipment panel to manage your loadout. Your currently equipped items are displayed on your character sheet and public profile.",
|
||||
},
|
||||
{
|
||||
title: "🏆 Leaderboards",
|
||||
body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.",
|
||||
},
|
||||
{
|
||||
title: "🔥 Daily Login Bonus",
|
||||
body: "Log in every day to earn escalating rewards! Each consecutive day awards more gold, and the 7th day of your streak grants bonus crystals. Your streak resets if you miss a day. A week multiplier increases all rewards the longer your overall streak runs. Your current streak is displayed on your character sheet.",
|
||||
},
|
||||
{
|
||||
title: "🤖 Auto-Quest & Auto-Boss",
|
||||
body: "Toggle automation in the Quests and Boss Encounters panels! Auto-Quest automatically sends your party on the highest-zone available quest as soon as one completes, skipping quests whose combat power requirement isn't met. Auto-Boss automatically challenges the highest available boss as soon as one is ready. Both can be toggled on or off at any time using the 🤖 Auto button in each panel header.",
|
||||
},
|
||||
{
|
||||
title: "👥 Companions",
|
||||
body: "Unlock companions by reaching certain milestones across all your runs. Each companion provides a powerful permanent bonus: increased passive gold, click gold, boss damage, essence income, or reduced quest time. You can only have one companion active at a time — choose wisely based on your current strategy! Companions are unlocked permanently once their condition is met and will never be lost.",
|
||||
},
|
||||
{
|
||||
title: "☁️ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
},
|
||||
{
|
||||
title: "🌌 Transcendence",
|
||||
body: "Transcendence is the ultimate prestige layer, unlocked by defeating The Absolute One (requires Prestige 90). Transcending performs a nuclear reset — wiping resources, prestige, runestones, upgrades, and equipment — but grants Echoes based on your prestige count (fewer prestiges = more Echoes). Echoes are permanent and survive all future resets. Spend them in the Echo Shop on lasting multipliers: passive income, combat power, prestige quality-of-life, and Echo meta upgrades that amplify future Echo yields.",
|
||||
},
|
||||
{
|
||||
title: "✨ Apotheosis",
|
||||
body: "Apotheosis is the final act — a complete dissolution of everything you have built, including your prestige and transcendence progress. It is unlocked once you have purchased every Transcendence upgrade. In exchange for this total reset, you receive the Apotheosis badge: pure bragging rights, a mark of reaching the absolute pinnacle of the game. Apotheosis can be achieved multiple times; each cycle requires purchasing all Transcendence upgrades again. Your Codex entries and lifetime profile statistics are always preserved.",
|
||||
},
|
||||
{
|
||||
title: "📖 Story",
|
||||
body: "The Story tab contains 22 chapters that unlock as you progress. The first 18 unlock when you defeat the final boss of each zone. Chapters 19 and 20 unlock after your first and fifth prestige respectively. Chapter 21 unlocks on your first transcendence, and Chapter 22 on your first apotheosis. Each chapter presents a narrative moment and three choices — the choice you make is recorded on your Character Sheet and shapes your guild's story. Story progress is permanent and survives all resets.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateStr: string): string =>
|
||||
new Date(dateStr).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export const AboutPanel = (): React.JSX.Element => {
|
||||
const [about, setAbout] = useState<AboutResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRelease, setExpandedRelease] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAbout()
|
||||
.then(setAbout)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load about data.");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="panel about-panel">
|
||||
<h2>ℹ️ About</h2>
|
||||
|
||||
<h3 className="stats-section-header">📋 Changelog</h3>
|
||||
{error !== null && <p className="about-error">{error}</p>}
|
||||
{about === null && error === null && <p className="about-loading">Loading changelog...</p>}
|
||||
{about !== null && about.releases.length === 0 && (
|
||||
<p className="about-empty">No releases yet.</p>
|
||||
)}
|
||||
{about !== null && about.releases.length > 0 && (
|
||||
<ul className="about-releases">
|
||||
{about.releases.map((release) => (
|
||||
<li key={release.tag_name} className="about-release">
|
||||
<button
|
||||
className="about-release-header"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedRelease(
|
||||
expandedRelease === release.tag_name ? null : release.tag_name,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="about-release-tag">{release.name || release.tag_name}</span>
|
||||
<span className="about-release-date">{formatDate(release.published_at)}</span>
|
||||
<span className="about-release-chevron">
|
||||
{expandedRelease === release.tag_name ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRelease === release.tag_name && (
|
||||
<pre className="about-release-body">{release.body}</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="stats-section-header">📖 How to Play</h3>
|
||||
<ul className="about-how-to-play">
|
||||
{HOW_TO_PLAY.map((section) => (
|
||||
<li key={section.title} className="about-htp-section">
|
||||
<h4 className="about-htp-title">{section.title}</h4>
|
||||
<p className="about-htp-body">{section.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { Achievement } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const conditionDescription = (achievement: Achievement, formatNumber: (n: number) => string): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total gold`;
|
||||
case "totalClicks":
|
||||
return `Click ${formatNumber(condition.amount)} times`;
|
||||
case "bossesDefeated":
|
||||
return `Defeat ${condition.amount} boss${condition.amount > 1 ? "es" : ""}`;
|
||||
case "questsCompleted":
|
||||
return `Complete ${condition.amount} quest${condition.amount > 1 ? "s" : ""}`;
|
||||
case "adventurerTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
|
||||
case "prestigeCount":
|
||||
return `Prestige ${condition.amount} time${condition.amount > 1 ? "s" : ""}`;
|
||||
case "equipmentOwned":
|
||||
return `Own ${condition.amount} equipment item${condition.amount > 1 ? "s" : ""}`;
|
||||
}
|
||||
};
|
||||
|
||||
interface AchievementCardProps {
|
||||
achievement: Achievement;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const AchievementCard = ({ achievement, formatNumber }: AchievementCardProps): React.JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked ? "unlocked" : "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">{conditionDescription(achievement, formatNumber)}</p>
|
||||
{achievement.reward?.crystals != null && (
|
||||
<p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked ? (
|
||||
<span className="achievement-unlocked-badge">✓ Unlocked</span>
|
||||
) : (
|
||||
<span className="achievement-locked-badge">🔒</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AchievementPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const achievements = state.achievements ?? [];
|
||||
const unlocked = achievements.filter((a) => a.unlockedAt !== null);
|
||||
const locked = achievements.filter((a) => a.unlockedAt === null);
|
||||
const visible = showLocked ? achievements : unlocked;
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Achievements</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length} / {achievements.length} unlocked
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => (
|
||||
<AchievementCard key={achievement.id} achievement={achievement} formatNumber={formatNumber} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface ToastItemProps {
|
||||
achievement: Achievement;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastItem = ({ achievement, onDismiss }: ToastItemProps): React.JSX.Element => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(achievement.id);
|
||||
}, 4000);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [achievement.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div className="achievement-toast" onClick={() => { onDismiss(achievement.id); }}>
|
||||
<span className="toast-icon">{achievement.icon}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">Achievement Unlocked!</span>
|
||||
<span className="toast-name">{achievement.name}</span>
|
||||
{achievement.reward?.crystals != null && (
|
||||
<span className="toast-reward">💎 +{achievement.reward.crystals}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AchievementToast = (): React.JSX.Element | null => {
|
||||
const { newAchievements, dismissAchievement } = useGame();
|
||||
|
||||
if (newAchievements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{newAchievements.map((achievement) => (
|
||||
<ToastItem
|
||||
key={achievement.id}
|
||||
achievement={achievement}
|
||||
onDismiss={dismissAchievement}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const CLASS_ICONS: Record<string, string> = {
|
||||
warrior: "🗡️",
|
||||
mage: "🔮",
|
||||
rogue: "🗝️",
|
||||
cleric: "✝️",
|
||||
ranger: "🏹",
|
||||
paladin: "🛡️",
|
||||
};
|
||||
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
const BATCH_OPTIONS: BatchSize[] = [1, 5, 10, 25, 100, "max"];
|
||||
|
||||
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
|
||||
let total = 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
total += adventurer.baseCost * Math.pow(1.15, adventurer.count + i);
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let i = 0; i < 100_000; i++) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + i);
|
||||
if (total + cost > gold) break;
|
||||
total += cost;
|
||||
quantity++;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface AdventurerCardProps {
|
||||
adventurer: Adventurer;
|
||||
currentGold: number;
|
||||
batchSize: BatchSize;
|
||||
unlockHint?: string | undefined;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const AdventurerCard = ({ adventurer, currentGold, batchSize, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
const resolvedQuantity =
|
||||
batchSize === "max" ? computeMaxAffordable(adventurer, currentGold) : batchSize;
|
||||
const cost = computeBatchCost(adventurer, resolvedQuantity);
|
||||
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
|
||||
|
||||
const handleBuy = (): void => {
|
||||
buyAdventurer(adventurer.id, resolvedQuantity);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
|
||||
<div className="adventurer-icon">{CLASS_ICONS[adventurer.class] ?? "⚔️"}</div>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>{formatNumber(adventurer.goldPerSecond)} gold/s each</p>
|
||||
{adventurer.essencePerSecond > 0 && (
|
||||
<p>{formatNumber(adventurer.essencePerSecond)} essence/s each</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="adventurer-count">×{adventurer.count}</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{adventurer.unlocked
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${batchSize === "max" && resolvedQuantity > 0 ? ` (×${resolvedQuantity})` : ""}`
|
||||
: "🔒 Locked"}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint && (
|
||||
<p className="unlock-hint">📜 Complete: {unlockHint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdventurerPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
const [batchSize, setBatchSize] = useState<BatchSize>(1);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const locked = state.adventurers.filter((a) => !a.unlocked);
|
||||
const visible = showLocked ? state.adventurers : state.adventurers.filter((a) => a.unlocked);
|
||||
|
||||
const adventurerUnlockHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId) {
|
||||
adventurerUnlockHints.set(reward.targetId, quest.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Adventurers</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{BATCH_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
className={`batch-button ${batchSize === option ? "active" : ""}`}
|
||||
onClick={() => { setBatchSize(option); }}
|
||||
type="button"
|
||||
>
|
||||
{option === "max" ? "xMax" : `x${option}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="adventurer-list">
|
||||
{visible.map((adventurer) => (
|
||||
<AdventurerCard
|
||||
key={adventurer.id}
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { TRANSCENDENCE_UPGRADES } from "../../data/transcendenceUpgrades.js";
|
||||
|
||||
const TOTAL_ECHO_UPGRADES = TRANSCENDENCE_UPGRADES.length;
|
||||
|
||||
export const ApotheosisPanel = (): React.JSX.Element => {
|
||||
const { state, apotheosis } = useGame();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
|
||||
const purchasedCount = TRANSCENDENCE_UPGRADES.filter((u) => purchasedIds.includes(u.id)).length;
|
||||
const isEligible = purchasedCount >= TOTAL_ECHO_UPGRADES;
|
||||
const apotheosisCount = state.apotheosis?.count ?? 0;
|
||||
|
||||
const handleApotheosis = async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apotheosis();
|
||||
setResult(data.newApotheosisCount);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Apotheosis failed");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel apotheosis-panel">
|
||||
<h2>✨ Apotheosis</h2>
|
||||
|
||||
<p className="apotheosis-intro">
|
||||
Apotheosis is the final act — a complete dissolution of everything you have built.
|
||||
Prestige, Transcendence, Echoes, upgrades, equipment, resources: all of it returns
|
||||
to nothing. In exchange, you receive only one thing:
|
||||
</p>
|
||||
<p className="apotheosis-reward">
|
||||
The <strong>✨ Apotheosis</strong> badge. Proof that you have done it all.
|
||||
</p>
|
||||
<p className="apotheosis-intro">
|
||||
Apotheosis can be achieved multiple times. Each cycle requires you to purchase
|
||||
every Transcendence upgrade again before the next Apotheosis becomes available.
|
||||
There is no mechanical benefit — only the knowledge that you have reached the
|
||||
pinnacle, dissolved it, and climbed back up.
|
||||
</p>
|
||||
|
||||
{apotheosisCount > 0 && (
|
||||
<div className="apotheosis-count">
|
||||
<span>You have achieved Apotheosis <strong>{apotheosisCount}</strong> time{apotheosisCount === 1 ? "" : "s"}.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="apotheosis-status">
|
||||
<p>
|
||||
Transcendence upgrades purchased:{" "}
|
||||
<strong>{purchasedCount} / {TOTAL_ECHO_UPGRADES}</strong>
|
||||
</p>
|
||||
{!isEligible && (
|
||||
<p className="apotheosis-missing">
|
||||
🔒 Purchase all {TOTAL_ECHO_UPGRADES} Transcendence upgrades to unlock Apotheosis.
|
||||
({TOTAL_ECHO_UPGRADES - purchasedCount} remaining)
|
||||
</p>
|
||||
)}
|
||||
{isEligible && (
|
||||
<p className="apotheosis-ready">✅ All Transcendence upgrades purchased. You are ready.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEligible && (
|
||||
<div className="prestige-form">
|
||||
<p>This action is <strong>permanent and irreversible</strong>.</p>
|
||||
<button
|
||||
className="apotheosis-button"
|
||||
disabled={isPending}
|
||||
onClick={() => { void handleApotheosis(); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Ascending..." : "✨ Achieve Apotheosis"}
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result !== null && (
|
||||
<p className="success">
|
||||
Apotheosis achieved. This is cycle <strong>{result}</strong>.
|
||||
The infinite loop continues.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { BattleResult } from "../../context/GameContext.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface BattleModalProps {
|
||||
battle: BattleResult;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const BattleModal = ({
|
||||
battle,
|
||||
onDismiss,
|
||||
}: BattleModalProps): React.JSX.Element => {
|
||||
const { result, bossName } = battle;
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
const [phase, setPhase] = useState<"animating" | "result">("animating");
|
||||
|
||||
// Starting HP percentages
|
||||
const bossStartPercent = (result.bossHpBefore / result.bossMaxHp) * 100;
|
||||
const partyStartPercent = 100;
|
||||
|
||||
// Target HP percentages (after battle)
|
||||
const bossEndPercent = (result.bossHpAtBattleEnd / result.bossMaxHp) * 100;
|
||||
const partyEndPercent = result.partyMaxHp > 0
|
||||
? (result.partyHpRemaining / result.partyMaxHp) * 100
|
||||
: 0;
|
||||
|
||||
const [bossHpPercent, setBossHpPercent] = useState(bossStartPercent);
|
||||
const [partyHpPercent, setPartyHpPercent] = useState(partyStartPercent);
|
||||
|
||||
useEffect(() => {
|
||||
// Brief delay so CSS transition has a starting point to animate from
|
||||
const startAnimation = setTimeout(() => {
|
||||
setBossHpPercent(bossEndPercent);
|
||||
setPartyHpPercent(partyEndPercent);
|
||||
}, 200);
|
||||
|
||||
// Reveal result after animation completes
|
||||
const revealResult = setTimeout(() => {
|
||||
setPhase("result");
|
||||
}, 5_200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(startAnimation);
|
||||
clearTimeout(revealResult);
|
||||
};
|
||||
}, [bossEndPercent, partyEndPercent]);
|
||||
|
||||
const bossHpBarColour = bossHpPercent > 50
|
||||
? "#e74c3c"
|
||||
: bossHpPercent > 25
|
||||
? "#e67e22"
|
||||
: "#c0392b";
|
||||
|
||||
const partyHpBarColour = partyHpPercent > 50
|
||||
? "#27ae60"
|
||||
: partyHpPercent > 25
|
||||
? "#f39c12"
|
||||
: "#e74c3c";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal battle-modal">
|
||||
<h2>⚔️ Battle: {bossName}</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">Your Party DPS</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat-divider">vs</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">Boss DPS</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="battle-bars">
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">👹 {bossName}</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill"
|
||||
style={{
|
||||
width: `${bossHpPercent.toFixed(1)}%`,
|
||||
backgroundColor: bossHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.bossHpAtBattleEnd)} / {formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vs-divider">⚔️ VS ⚔️</div>
|
||||
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">🛡️ Your Party</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill party-hp"
|
||||
style={{
|
||||
width: `${partyHpPercent.toFixed(1)}%`,
|
||||
backgroundColor: partyHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.partyHpRemaining)} / {formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === "animating" && (
|
||||
<p className="battle-in-progress">Battling…</p>
|
||||
)}
|
||||
|
||||
{phase === "result" && (
|
||||
<div className={`battle-outcome ${result.won ? "victory" : "defeat"}`}>
|
||||
{result.won ? (
|
||||
<>
|
||||
<h3>🏆 Victory!</h3>
|
||||
{result.rewards && (
|
||||
<div className="battle-rewards">
|
||||
<p>Rewards:</p>
|
||||
<span>🪙 {formatNumber(result.rewards.gold)} gold</span>
|
||||
{result.rewards.essence > 0 && (
|
||||
<span>✨ {formatNumber(result.rewards.essence)} essence</span>
|
||||
)}
|
||||
{result.rewards.crystals > 0 && (
|
||||
<span>💎 {formatNumber(result.rewards.crystals)} crystals</span>
|
||||
)}
|
||||
{result.rewards.bountyRunestones > 0 && (
|
||||
<span className="battle-bounty">🔮 {formatNumber(result.rewards.bountyRunestones)} runestones (first kill!)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>💀 Defeat</h3>
|
||||
<p>Your party was defeated. The boss has reset.</p>
|
||||
{result.casualties && result.casualties.length > 0 && (
|
||||
<div className="battle-casualties">
|
||||
<p>Casualties:</p>
|
||||
{result.casualties.map((c) => (
|
||||
<span key={c.adventurerId}>
|
||||
☠️ {c.killed} {c.adventurerId} lost
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,244 +0,0 @@
|
||||
import type { Boss } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
interface BossCardProps {
|
||||
boss: Boss;
|
||||
prestigeCount: number;
|
||||
onChallenge: (bossId: string) => void;
|
||||
isChallenging: boolean;
|
||||
unlockHint?: string | undefined;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const BossCard = ({
|
||||
boss,
|
||||
prestigeCount,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: BossCardProps): React.JSX.Element => {
|
||||
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
|
||||
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
||||
const canChallenge =
|
||||
(boss.status === "available" || boss.status === "in_progress") && !isChallenging;
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{isPrestigeLocked && boss.status === "locked" && (
|
||||
<p className="prestige-lock">
|
||||
🔒 Requires Prestige {boss.prestigeRequirement}
|
||||
</p>
|
||||
)}
|
||||
{!isPrestigeLocked && boss.status === "locked" && unlockHint && (
|
||||
<p className="unlock-hint">{unlockHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated" && (
|
||||
<div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} HP
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
<span>🪙 {formatNumber(boss.goldReward)}</span>
|
||||
{boss.essenceReward > 0 && (
|
||||
<span>✨ {formatNumber(boss.essenceReward)}</span>
|
||||
)}
|
||||
{boss.crystalReward > 0 && (
|
||||
<span>💎 {formatNumber(boss.crystalReward)}</span>
|
||||
)}
|
||||
{(boss.equipmentRewards ?? []).length > 0 && (
|
||||
<span>🗡️ {boss.equipmentRewards.length} Equipment</span>
|
||||
)}
|
||||
{boss.status !== "defeated" && boss.bountyRunestones > 0 && (
|
||||
<span className="boss-bounty">🔮 {boss.bountyRunestones} (first kill)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(boss.status === "available" || boss.status === "in_progress") && (
|
||||
<button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={() => {
|
||||
onChallenge(boss.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{boss.status === "defeated" && (
|
||||
<span className="boss-badge defeated">☠️ Defeated</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BossPanel = (): React.JSX.Element => {
|
||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
||||
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
|
||||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
// Calculate party combat stats including equipment multiplier
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
const equipmentCombatMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyHP = 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 *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
partyDPS +=
|
||||
adventurer.combatPower *
|
||||
adventurer.count *
|
||||
adventurerMultiplier *
|
||||
globalMultiplier *
|
||||
prestigeMultiplier;
|
||||
partyHP += adventurer.level * 50 * adventurer.count;
|
||||
}
|
||||
partyDPS *= equipmentCombatMultiplier;
|
||||
|
||||
const handleChallenge = async (bossId: string): Promise<void> => {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const zoneBosses = state.bosses.filter((b) => b.zoneId === activeZoneId);
|
||||
const lockedCount = zoneBosses.filter((b) => b.status === "locked").length;
|
||||
const visibleBosses = showLocked
|
||||
? zoneBosses
|
||||
: zoneBosses.filter((b) => b.status !== "locked");
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const allZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
|
||||
for (let i = 0; i < allZoneBosses.length; i++) {
|
||||
const boss = allZoneBosses[i];
|
||||
if (!boss || boss.status !== "locked") continue;
|
||||
if (i === 0) {
|
||||
const parts: string[] = [];
|
||||
if (zone.unlockBossId) {
|
||||
const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
|
||||
if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
if (zone.unlockQuestId) {
|
||||
const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId);
|
||||
if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const prevBoss = allZoneBosses[i - 1];
|
||||
if (prevBoss) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Boss Encounters</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${state.autoBoss ? "auto-toggle-on" : "auto-toggle-off"}`}
|
||||
onClick={toggleAutoBoss}
|
||||
title="Automatically challenge the highest available boss"
|
||||
type="button"
|
||||
>
|
||||
🤖 Auto: {state.autoBoss ? "ON" : "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={setActiveZoneId}
|
||||
/>
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">⚔️ Party DPS</span>
|
||||
<span className="stat-value">{formatNumber(partyDPS)}</span>
|
||||
</div>
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">❤️ Party HP</span>
|
||||
<span className="stat-value">{formatNumber(partyHP)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boss-list">
|
||||
{visibleBosses.map((boss) => (
|
||||
<BossCard
|
||||
key={boss.id}
|
||||
boss={boss}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
prestigeCount={state.prestige.count}
|
||||
unlockHint={bossUnlockHints.get(boss.id)}
|
||||
onChallenge={(id) => {
|
||||
void handleChallenge(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{visibleBosses.length === 0 && (
|
||||
<p className="empty-zone">No bosses to show in this zone.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { EquipmentBonus, EquipmentType, PublicProfileResponse } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CharacterPageProps {
|
||||
discordId: string;
|
||||
}
|
||||
|
||||
export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Element => {
|
||||
const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error("Player not found");
|
||||
return res.json() as Promise<PublicProfileResponse>;
|
||||
})
|
||||
.then(setProfile)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load character sheet");
|
||||
});
|
||||
}, [discordId]);
|
||||
|
||||
const handleCopy = (): void => {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => { setCopied(false); }, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-error">
|
||||
<p>⚠️ {error}</p>
|
||||
<a className="character-page-link" href="/">← Play Elysium</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-loading">Loading character sheet…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SLOT_ICONS: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️",
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
};
|
||||
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: string[] = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const avatarUrl = profile.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
|
||||
|
||||
const subtitle = [profile.characterRace, profile.characterClass].filter(Boolean).join(" · ");
|
||||
const activeTitleName = profile.activeTitle
|
||||
? (profile.unlockedTitles.find((t) => t.id === profile.activeTitle)?.name ?? profile.activeTitle)
|
||||
: null;
|
||||
const hasBadge = profile.apotheosisCount > 0 || profile.transcendenceCount > 0 || profile.prestigeCount > 0;
|
||||
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-card">
|
||||
<div className="character-page-header">
|
||||
<img
|
||||
alt={`${profile.characterName || profile.username}'s avatar`}
|
||||
className="character-page-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="character-page-identity">
|
||||
<h1 className="character-page-name">
|
||||
{profile.characterName || profile.username}
|
||||
</h1>
|
||||
{activeTitleName && (
|
||||
<p className="character-page-title">{activeTitleName}</p>
|
||||
)}
|
||||
{profile.pronouns && (
|
||||
<p className="character-page-pronouns">{profile.pronouns}</p>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="character-page-subtitle">{subtitle}</p>
|
||||
)}
|
||||
{hasBadge && (
|
||||
<div className="character-page-badges">
|
||||
{profile.apotheosisCount > 0 && (
|
||||
<span className="character-page-badge character-page-badge--apotheosis">
|
||||
✨ Apotheosis {profile.apotheosisCount}
|
||||
</span>
|
||||
)}
|
||||
{profile.transcendenceCount > 0 && (
|
||||
<span className="character-page-badge character-page-badge--transcendence">
|
||||
🌌 Transcendence {profile.transcendenceCount}
|
||||
</span>
|
||||
)}
|
||||
{profile.prestigeCount > 0 && (
|
||||
<span className="character-page-badge character-page-badge--prestige">
|
||||
⭐ Prestige {profile.prestigeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio && (
|
||||
<div className="character-page-section">
|
||||
<h2 className="character-page-section-title">⚔️ About</h2>
|
||||
<p className="character-page-bio">{profile.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.guildName && (
|
||||
<div className="character-page-section">
|
||||
<h2 className="character-page-section-title">🏰 Guild</h2>
|
||||
<p className="character-page-guild-name">{profile.guildName}</p>
|
||||
{profile.guildDescription && (
|
||||
<p className="character-page-guild-desc">{profile.guildDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.equippedItems.length > 0 && (
|
||||
<div className="character-page-section">
|
||||
<h2 className="character-page-section-title">🗡️ Equipment</h2>
|
||||
<div className="character-page-equipment-list">
|
||||
{profile.equippedItems.map((item) => (
|
||||
<div className="character-page-equipment-item" key={item.type}>
|
||||
<div className="character-page-equipment-header">
|
||||
<span className="character-page-equipment-slot">{SLOT_ICONS[item.type]}</span>
|
||||
<span className={`character-page-equipment-name character-sheet-rarity--${item.rarity}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={`character-page-equipment-rarity character-sheet-rarity--${item.rarity}`}>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-page-equipment-bonus">{formatBonus(item.bonus)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="character-page-divider" />
|
||||
|
||||
<p className="character-page-player-line">
|
||||
Played by <span className="character-page-username">@{profile.username}</span>
|
||||
</p>
|
||||
|
||||
<div className="character-page-actions">
|
||||
<button
|
||||
className="character-page-share-btn"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? "✓ Copied!" : "🔗 Share Character"}
|
||||
</button>
|
||||
<a className="character-page-profile-link" href={`/profile/${discordId}`}>
|
||||
📊 View Stats
|
||||
</a>
|
||||
<a className="character-page-play-link" href="/">
|
||||
⚔️ Play Elysium
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,433 +0,0 @@
|
||||
import type { EquipmentBonus, EquipmentRarity, EquipmentType, ProfileSettings } from "@elysium/types";
|
||||
import { DEFAULT_PROFILE_SETTINGS, STORY_CHAPTERS } from "@elysium/types";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface EquippedItem {
|
||||
name: string;
|
||||
type: EquipmentType;
|
||||
rarity: EquipmentRarity;
|
||||
bonus: EquipmentBonus;
|
||||
}
|
||||
|
||||
interface CharacterSheetData {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: EquippedItem[];
|
||||
}
|
||||
|
||||
const EMPTY_SHEET: CharacterSheetData = {
|
||||
characterName: "",
|
||||
pronouns: "",
|
||||
characterRace: "",
|
||||
characterClass: "",
|
||||
bio: "",
|
||||
guildName: "",
|
||||
guildDescription: "",
|
||||
activeTitle: "",
|
||||
unlockedTitles: [],
|
||||
equippedItems: [],
|
||||
};
|
||||
|
||||
const SLOT_ICONS: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️",
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
};
|
||||
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: string[] = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
const { state, loginStreak } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET);
|
||||
const [draft, setDraft] = useState<CharacterSheetData>(EMPTY_SHEET);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const savedSettingsRef = useRef<ProfileSettings>({ ...DEFAULT_PROFILE_SETTINGS });
|
||||
|
||||
useEffect(() => {
|
||||
if (!player?.discordId) return;
|
||||
fetch(`/api/profile/${player.discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
profileSettings: ProfileSettings;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: EquippedItem[];
|
||||
};
|
||||
const loaded: CharacterSheetData = {
|
||||
characterName: data.characterName ?? "",
|
||||
pronouns: data.pronouns ?? "",
|
||||
characterRace: data.characterRace ?? "",
|
||||
characterClass: data.characterClass ?? "",
|
||||
bio: data.bio ?? "",
|
||||
guildName: data.guildName ?? "",
|
||||
guildDescription: data.guildDescription ?? "",
|
||||
activeTitle: data.activeTitle ?? "",
|
||||
unlockedTitles: data.unlockedTitles ?? [],
|
||||
equippedItems: data.equippedItems ?? [],
|
||||
};
|
||||
setSheet(loaded);
|
||||
setDraft(loaded);
|
||||
savedSettingsRef.current = { ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings };
|
||||
})
|
||||
.catch(() => { /* fall back to empty */ })
|
||||
.finally(() => { setLoading(false); });
|
||||
}, [player?.discordId]);
|
||||
|
||||
const handleEdit = (): void => {
|
||||
setDraft({ ...sheet });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({
|
||||
characterName: draft.characterName || (player?.characterName ?? ""),
|
||||
pronouns: draft.pronouns,
|
||||
characterRace: draft.characterRace,
|
||||
characterClass: draft.characterClass,
|
||||
bio: draft.bio,
|
||||
guildName: draft.guildName,
|
||||
guildDescription: draft.guildDescription,
|
||||
profileSettings: savedSettingsRef.current,
|
||||
activeTitle: draft.activeTitle,
|
||||
});
|
||||
setSheet({ ...draft });
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setEditing(false);
|
||||
setSaved(false);
|
||||
}, 900);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <section className="panel"><p>Loading character sheet…</p></section>;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>📋 Character Sheet</h2>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-form">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">⚔️ Character</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-name">Character Name</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-name"
|
||||
maxLength={32}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={draft.characterName}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, characterName: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.characterName.length} / 32</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-pronouns">Pronouns</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-pronouns"
|
||||
maxLength={20}
|
||||
placeholder="e.g. she/her, he/him, they/them"
|
||||
type="text"
|
||||
value={draft.pronouns}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, pronouns: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.pronouns.length} / 20</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-race">Race</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-race"
|
||||
maxLength={32}
|
||||
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
|
||||
type="text"
|
||||
value={draft.characterRace}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, characterRace: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.characterRace.length} / 32</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-class">Class</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-class"
|
||||
maxLength={32}
|
||||
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
|
||||
type="text"
|
||||
value={draft.characterClass}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, characterClass: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.characterClass.length} / 32</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-bio">About Your Character</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-bio"
|
||||
maxLength={200}
|
||||
placeholder="Describe your character's story, personality, or appearance…"
|
||||
rows={4}
|
||||
value={draft.bio}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.bio.length} / 200</span>
|
||||
|
||||
{draft.unlockedTitles.length > 0 && (
|
||||
<>
|
||||
<label className="character-sheet-label" htmlFor="cs-title">Active Title</label>
|
||||
<select
|
||||
className="character-sheet-input"
|
||||
id="cs-title"
|
||||
value={draft.activeTitle}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, activeTitle: e.target.value })); }}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{draft.unlockedTitles.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🏰 Guild</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-name">Guild Name</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-guild-name"
|
||||
maxLength={64}
|
||||
placeholder="Name your guild"
|
||||
type="text"
|
||||
value={draft.guildName}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, guildName: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.guildName.length} / 64</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-desc">Guild Description</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-guild-desc"
|
||||
maxLength={500}
|
||||
placeholder="Describe your guild's history, goals, or lore…"
|
||||
rows={6}
|
||||
value={draft.guildDescription}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, guildDescription: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.guildDescription.length} / 500</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="character-sheet-error">{error}</p>}
|
||||
|
||||
<div className="character-sheet-actions">
|
||||
<button className="character-sheet-cancel" onClick={handleCancel} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="character-sheet-save"
|
||||
disabled={saving || !draft.characterName.trim()}
|
||||
onClick={() => { void handleSave(); }}
|
||||
type="button"
|
||||
>
|
||||
{saved ? "✓ Saved!" : saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitle = [sheet.characterRace, sheet.characterClass].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>📋 Character Sheet</h2>
|
||||
<div className="character-sheet-header-actions">
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/character/${player?.discordId ?? ""}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => { setCopied(false); }, 2000);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{copied ? "✓ Copied!" : "🔗 Share"}
|
||||
</button>
|
||||
<a className="character-sheet-edit-btn" href="/leaderboards">
|
||||
🏆 Boards
|
||||
</a>
|
||||
<button className="character-sheet-edit-btn" onClick={handleEdit} type="button">
|
||||
✏️ Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-view">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">⚔️ Character</h3>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Name</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Streak</span>
|
||||
<span className="character-sheet-streak">
|
||||
🔥 {loginStreak}-day login streak
|
||||
</span>
|
||||
</div>
|
||||
{sheet.activeTitle && (
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Title</span>
|
||||
<span className="character-sheet-field-value character-sheet-title">
|
||||
{sheet.unlockedTitles.find((t) => t.id === sheet.activeTitle)?.name ?? sheet.activeTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{sheet.pronouns && (
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Pronouns</span>
|
||||
<span className="character-sheet-field-value">{sheet.pronouns}</span>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Identity</span>
|
||||
<span className="character-sheet-field-value">{subtitle}</span>
|
||||
</div>
|
||||
)}
|
||||
{sheet.bio && (
|
||||
<div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">About</span>
|
||||
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🗡️ Equipment</h3>
|
||||
{sheet.equippedItems.length > 0 ? (
|
||||
<div className="character-sheet-equipment-list">
|
||||
{sheet.equippedItems.map((item) => (
|
||||
<div className="character-sheet-equipment-item" key={item.type}>
|
||||
<div className="character-sheet-equipment-header">
|
||||
<span className="character-sheet-equipment-slot">{SLOT_ICONS[item.type]}</span>
|
||||
<span className={`character-sheet-equipment-name character-sheet-rarity--${item.rarity}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={`character-sheet-equipment-rarity character-sheet-rarity--${item.rarity}`}>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-sheet-equipment-bonus">{formatBonus(item.bonus)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="character-sheet-empty">No equipment found. Defeat bosses to earn gear!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🏰 Guild</h3>
|
||||
{sheet.guildName ? (
|
||||
<>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Name</span>
|
||||
<span className="character-sheet-field-value">{sheet.guildName}</span>
|
||||
</div>
|
||||
{sheet.guildDescription && (
|
||||
<div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">Lore</span>
|
||||
<p className="character-sheet-bio-text">{sheet.guildDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="character-sheet-empty">No guild registered yet. Click ✏️ Edit to add one!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
if (completedChapters.length === 0) return null;
|
||||
return (
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">📖 Story Choices</h3>
|
||||
{completedChapters.map((completion) => {
|
||||
const chapter = STORY_CHAPTERS.find((c) => c.id === completion.chapterId);
|
||||
if (!chapter) return null;
|
||||
const choice = chapter.choices.find((c) => c.id === completion.choiceId);
|
||||
if (!choice) return null;
|
||||
return (
|
||||
<div className="character-sheet-story-entry" key={completion.chapterId}>
|
||||
<span className="character-sheet-story-chapter">{chapter.title}</span>
|
||||
<span className="character-sheet-story-choice">{choice.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { calculateClickPower } from "../../engine/tick.js";
|
||||
|
||||
interface FloatText {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ClickArea = (): React.JSX.Element => {
|
||||
const { state, handleClick, formatNumber, saveSchemaVersion, currentSchemaVersion } = useGame();
|
||||
const [floats, setFloats] = useState<FloatText[]>([]);
|
||||
const nextIdRef = useRef(0);
|
||||
|
||||
const handleClickWithFloat = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!state) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = nextIdRef.current++;
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
setFloats((prev) => [...prev, { id, x, y, text: `+${formatNumber(clickPower)}` }]);
|
||||
handleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
setFloats((prev) => prev.filter((f) => f.id !== id));
|
||||
}, 900);
|
||||
},
|
||||
[state, handleClick],
|
||||
);
|
||||
|
||||
if (!state) return <div className="click-area-placeholder" />;
|
||||
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
return (
|
||||
<section className="click-area">
|
||||
<h1 className="game-title">Elysium</h1>
|
||||
<p className="game-version">v{__WEB_VERSION__}</p>
|
||||
{currentSchemaVersion > 0 && (
|
||||
<p className="game-schema-version">
|
||||
Save: v{saveSchemaVersion} / Latest: v{currentSchemaVersion}
|
||||
</p>
|
||||
)}
|
||||
<h2>Guild Hall</h2>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
className="click-button"
|
||||
onClick={handleClickWithFloat}
|
||||
type="button"
|
||||
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
|
||||
>
|
||||
<img
|
||||
alt="Guild Hall"
|
||||
className="click-button-image"
|
||||
src="https://cdn.nhcarrigan.com/avatars/elysium.png"
|
||||
/>
|
||||
</button>
|
||||
{floats.map((float) => (
|
||||
<span
|
||||
key={float.id}
|
||||
className="click-float"
|
||||
style={{ left: float.x, top: float.y }}
|
||||
>
|
||||
{float.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="click-power">+{formatNumber(clickPower)} gold/click</p>
|
||||
<p className="early-access-notice">
|
||||
⚠️ Early Access — this build is subject to change. <strong>All game progress WILL be reset upon v1.0.0 release.</strong>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { CodexEntry } from "@elysium/types";
|
||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const SOURCE_BADGE: Record<CodexEntry["sourceType"], string> = {
|
||||
boss: "⚔️",
|
||||
quest: "📜",
|
||||
equipment: "🛡️",
|
||||
adventurer: "👥",
|
||||
upgrade: "🔧",
|
||||
prestige: "🔮",
|
||||
zone: "🗺️",
|
||||
exploration: "🧭",
|
||||
recipe: "⚗️",
|
||||
};
|
||||
|
||||
export const CodexPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []);
|
||||
const totalEntries = CODEX_ENTRIES.length;
|
||||
const unlockedCount = CODEX_ENTRIES.filter((e) => unlockedIds.has(e.id)).length;
|
||||
const progressPercent = totalEntries > 0 ? (unlockedCount / totalEntries) * 100 : 0;
|
||||
|
||||
const entriesByZone = Object.entries(ZONE_LABELS).map(([zoneId, zoneName]) => {
|
||||
const zoneEntries = CODEX_ENTRIES.filter((e) => e.zoneId === zoneId);
|
||||
const unlockedZoneEntries = zoneEntries.filter((e) => unlockedIds.has(e.id));
|
||||
return { zoneId, zoneName, entries: zoneEntries, unlockedEntries: unlockedZoneEntries };
|
||||
}).filter(({ entries }) => entries.length > 0);
|
||||
|
||||
return (
|
||||
<section className="panel codex-panel">
|
||||
<h2>📖 Codex</h2>
|
||||
|
||||
<div className="codex-progress">
|
||||
<p className="codex-progress-text">
|
||||
Lore discovered: <strong>{unlockedCount} / {totalEntries}</strong>
|
||||
</p>
|
||||
<div className="codex-progress-bar">
|
||||
<div className="codex-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => (
|
||||
<div key={zoneId} className="codex-zone">
|
||||
<h3 className="codex-zone-header">
|
||||
{zoneName}
|
||||
<span className="codex-zone-count">
|
||||
{unlockedEntries.length}/{entries.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="codex-entries">
|
||||
{entries.map((entry) => {
|
||||
const isUnlocked = unlockedIds.has(entry.id);
|
||||
const isExpanded = expandedId === entry.id;
|
||||
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<div key={entry.id} className="codex-entry locked">
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-lock">🔒</span>
|
||||
<span className="codex-entry-title">???</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`codex-entry unlocked ${isExpanded ? "expanded" : ""}`}
|
||||
onClick={() => { setExpandedId(isExpanded ? null : entry.id); }}
|
||||
>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-source-badge">
|
||||
{SOURCE_BADGE[entry.sourceType]}
|
||||
</span>
|
||||
<span className="codex-entry-title">{entry.title}</span>
|
||||
<span className="codex-chevron">{isExpanded ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<p className="codex-entry-content">{entry.content}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { CODEX_ENTRIES } from "../../data/codex.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface CodexToastItemProps {
|
||||
entryId: string;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const CodexToastItem = ({ entryId, onDismiss }: CodexToastItemProps): React.JSX.Element | null => {
|
||||
const entry = CODEX_ENTRIES.find((e) => e.id === entryId);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(entryId);
|
||||
}, 4000);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [entryId, onDismiss]);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
return (
|
||||
<div className="codex-toast" onClick={() => { onDismiss(entryId); }}>
|
||||
<span className="toast-icon">📖</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">✨ Lore Unlocked!</span>
|
||||
<span className="toast-name">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodexToast = (): React.JSX.Element | null => {
|
||||
const { newCodexEntryIds, dismissCodexEntry } = useGame();
|
||||
|
||||
if (newCodexEntryIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{newCodexEntryIds.map((id) => (
|
||||
<CodexToastItem key={id} entryId={id} onDismiss={dismissCodexEntry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import { COMPANIONS } from "@elysium/types";
|
||||
import type { Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const BONUS_LABELS: Record<string, string> = {
|
||||
passiveGold: "Passive Gold",
|
||||
clickGold: "Click Gold",
|
||||
bossDamage: "Boss Damage",
|
||||
essenceIncome: "Essence Income",
|
||||
questTime: "Quest Time",
|
||||
};
|
||||
|
||||
const UNLOCK_LABELS: Record<string, string> = {
|
||||
lifetimeBosses: "lifetime bosses defeated",
|
||||
lifetimeQuests: "lifetime quests completed",
|
||||
lifetimeGold: "lifetime gold earned",
|
||||
prestige: "prestige(s)",
|
||||
transcendence: "transcendence(s)",
|
||||
apotheosis: "apotheosis",
|
||||
};
|
||||
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e18) return `${(threshold / 1e18).toFixed(0)}Qt`;
|
||||
if (threshold >= 1e15) return `${(threshold / 1e15).toFixed(0)}Q`;
|
||||
if (threshold >= 1e12) return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
if (threshold >= 1e9) return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
if (threshold >= 1e6) return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
if (threshold >= 1e3) return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
return threshold.toString();
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
const CompanionCard = ({
|
||||
companion,
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
companion: Companion;
|
||||
isUnlocked: boolean;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): React.JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime" ? "-" : "+";
|
||||
const bonusPercent = Math.round(companion.bonus.value * 100);
|
||||
const bonusLabel = BONUS_LABELS[companion.bonus.type] ?? companion.bonus.type;
|
||||
|
||||
return (
|
||||
<div className={`companion-card ${isUnlocked ? "companion-unlocked" : "companion-locked"} ${isActive ? "companion-active" : ""}`}>
|
||||
<div className="companion-header">
|
||||
<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>}
|
||||
</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">
|
||||
🔒 Unlock: {formatThreshold(companion.unlock.type, companion.unlock.threshold)} {UNLOCK_LABELS[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompanionPanel = (): React.JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
|
||||
if (!state) return <></>;
|
||||
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
const handleSelect = (companionId: string): void => {
|
||||
setActiveCompanion(activeId === companionId ? null : companionId);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<> Currently active: <strong>{COMPANIONS.find((c) => c.id === activeId)?.name ?? activeId}</strong>.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="companion-grid">
|
||||
{COMPANIONS.map((companion) => (
|
||||
<CompanionCard
|
||||
key={companion.id}
|
||||
companion={companion}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
isActive={activeId === companion.id}
|
||||
onSelect={() => { handleSelect(companion.id); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { MATERIALS } from "../../data/materials.js";
|
||||
import { RECIPES } from "../../data/recipes.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
const BONUS_LABEL: Record<string, string> = {
|
||||
gold_income: "🪙 Gold Income",
|
||||
essence_income: "✨ Essence Income",
|
||||
click_power: "👆 Click Power",
|
||||
combat_power: "⚔️ Combat Power",
|
||||
};
|
||||
|
||||
export const CraftingPanel = (): React.JSX.Element => {
|
||||
const { state, craftRecipe, formatNumber } = useGame();
|
||||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||||
const [pendingRecipeId, setPendingRecipeId] = useState<string | null>(null);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const explorationState = state.exploration;
|
||||
const playerMaterials = explorationState?.materials ?? [];
|
||||
const craftedIds = explorationState?.craftedRecipeIds ?? [];
|
||||
|
||||
const zoneRecipes = RECIPES.filter((r) => r.zoneId === activeZoneId);
|
||||
const zoneMaterials = MATERIALS.filter((m) => m.zoneId === activeZoneId);
|
||||
|
||||
const getQuantity = (materialId: string): number =>
|
||||
playerMaterials.find((m) => m.materialId === materialId)?.quantity ?? 0;
|
||||
|
||||
const canAfford = (recipeId: string): boolean => {
|
||||
const recipe = RECIPES.find((r) => r.id === recipeId);
|
||||
if (!recipe) return false;
|
||||
return recipe.requiredMaterials.every(
|
||||
(req) => getQuantity(req.materialId) >= req.quantity,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCraft = async (recipeId: string): Promise<void> => {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>⚗️ Crafting</h2>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={(id) => { setActiveZoneId(id); }}
|
||||
/>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>📦 Materials</h3>
|
||||
{zoneMaterials.length === 0 ? (
|
||||
<p className="empty-zone">No materials in this zone.</p>
|
||||
) : (
|
||||
<div className="materials-list">
|
||||
{zoneMaterials.map((material) => {
|
||||
const qty = getQuantity(material.id);
|
||||
return (
|
||||
<div key={material.id} className={`material-card rarity-${material.rarity} ${qty === 0 ? "material-empty" : ""}`}>
|
||||
<div className="material-info">
|
||||
<span className="material-name">{material.name}</span>
|
||||
<span className="material-rarity">{material.rarity}</span>
|
||||
</div>
|
||||
<span className="material-quantity">{formatNumber(qty)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="recipes-section">
|
||||
<h3>📜 Recipes</h3>
|
||||
{zoneRecipes.length === 0 ? (
|
||||
<p className="empty-zone">No recipes in this zone.</p>
|
||||
) : (
|
||||
<div className="recipes-list">
|
||||
{zoneRecipes.map((recipe) => {
|
||||
const crafted = craftedIds.includes(recipe.id);
|
||||
const affordable = canAfford(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
return (
|
||||
<div key={recipe.id} className={`recipe-card ${crafted ? "recipe-crafted" : ""} ${!affordable && !crafted ? "recipe-unaffordable" : ""}`}>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">{BONUS_LABEL[recipe.bonus.type] ?? recipe.bonus.type}</span>
|
||||
<span className="bonus-value">×{recipe.bonus.value.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="recipe-requirements">
|
||||
{recipe.requiredMaterials.map((req) => {
|
||||
const have = getQuantity(req.materialId);
|
||||
const enough = have >= req.quantity;
|
||||
const matName = MATERIALS.find((m) => m.id === req.materialId)?.name ?? req.materialId;
|
||||
return (
|
||||
<span key={req.materialId} className={`req-tag ${enough ? "req-met" : "req-missing"}`}>
|
||||
{matName}: {formatNumber(have)}/{formatNumber(req.quantity)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recipe-action">
|
||||
{crafted ? (
|
||||
<span className="quest-badge active">✅ Crafted</span>
|
||||
) : (
|
||||
<button
|
||||
className="craft-button"
|
||||
disabled={!affordable || isPending || pendingRecipeId !== null}
|
||||
onClick={() => { void handleCraft(recipe.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Crafting..." : "⚗️ Craft"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const formatTimeUntilReset = (): string => {
|
||||
const now = new Date();
|
||||
// Mirror the server's PST/PDT-based rollover: challenges reset at PST midnight
|
||||
const nowAsPST = new Date(now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }));
|
||||
const tomorrowMidnightPST = new Date(nowAsPST);
|
||||
tomorrowMidnightPST.setDate(tomorrowMidnightPST.getDate() + 1);
|
||||
tomorrowMidnightPST.setHours(0, 0, 0, 0);
|
||||
const pstOffset = nowAsPST.getTime() - now.getTime();
|
||||
const resetAt = new Date(tomorrowMidnightPST.getTime() - pstOffset);
|
||||
const msRemaining = resetAt.getTime() - now.getTime();
|
||||
const hoursRemaining = Math.floor(msRemaining / (1000 * 60 * 60));
|
||||
const minutesRemaining = Math.floor((msRemaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`;
|
||||
};
|
||||
|
||||
export const DailyChallengePanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { dailyChallenges } = state;
|
||||
|
||||
if (!dailyChallenges) {
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>📅 Daily Challenges</h2>
|
||||
<p className="daily-challenge-subtitle">Load the game to generate today's challenges!</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = dailyChallenges.challenges.filter((c) => c.completed).length;
|
||||
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>📅 Daily Challenges</h2>
|
||||
<div className="daily-challenge-header">
|
||||
<p className="daily-challenge-subtitle">
|
||||
Complete challenges for bonus 💎 crystals! Resets in{" "}
|
||||
<strong>{formatTimeUntilReset()}</strong> (PST midnight).
|
||||
</p>
|
||||
<p className="daily-challenge-progress">
|
||||
{completedCount} / {dailyChallenges.challenges.length} completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-list">
|
||||
{dailyChallenges.challenges.map((challenge) => {
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.floor((challenge.progress / challenge.target) * 100),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className={`daily-challenge-card ${challenge.completed ? "completed" : ""}`}
|
||||
>
|
||||
<div className="daily-challenge-info">
|
||||
<h3 className="daily-challenge-label">{challenge.label}</h3>
|
||||
<p className="daily-challenge-reward">
|
||||
Reward: <strong>💎 {formatNumber(challenge.rewardCrystals)} crystals</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-right">
|
||||
{challenge.completed ? (
|
||||
<span className="daily-challenge-done">✅ Complete!</span>
|
||||
) : (
|
||||
<>
|
||||
<p className="daily-challenge-count">
|
||||
{formatNumber(challenge.progress)} / {formatNumber(challenge.target)}
|
||||
</p>
|
||||
<div className="daily-challenge-bar-track">
|
||||
<div
|
||||
className="daily-challenge-bar-fill"
|
||||
style={{ width: `${String(progressPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,245 +0,0 @@
|
||||
import type { NumberFormat, ProfileSettings } from "@elysium/types";
|
||||
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface EditProfileModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface StatToggle {
|
||||
key: keyof ProfileSettings;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const CURRENT_RUN_TOGGLES: StatToggle[] = [
|
||||
{ key: "showCurrentGold", label: "Gold Earned This Run", icon: "🪙" },
|
||||
{ key: "showCurrentClicks", label: "Clicks This Run", icon: "👆" },
|
||||
{ key: "showApotheosis", label: "Apotheosis Badge", icon: "✨" },
|
||||
{ key: "showTranscendence", label: "Transcendence Badge", icon: "🌌" },
|
||||
{ key: "showPrestige", label: "Prestige Level", icon: "⭐" },
|
||||
{ key: "showBossesDefeated", label: "Bosses Defeated", icon: "💀" },
|
||||
{ key: "showQuestsCompleted", label: "Quests Completed", icon: "📜" },
|
||||
{ key: "showAdventurersRecruited", label: "Adventurers Recruited", icon: "⚔️" },
|
||||
{ key: "showAchievementsUnlocked", label: "Achievements Unlocked", icon: "🏆" },
|
||||
];
|
||||
|
||||
const ALL_TIME_TOGGLES: StatToggle[] = [
|
||||
{ key: "showTotalGold", label: "Total Gold Earned", icon: "🪙" },
|
||||
{ key: "showTotalClicks", label: "Total Clicks", icon: "👆" },
|
||||
{ key: "showLifetimeBossesDefeated", label: "Bosses Defeated", icon: "💀" },
|
||||
{ key: "showLifetimeQuestsCompleted", label: "Quests Completed", icon: "📜" },
|
||||
{ key: "showLifetimeAdventurersRecruited", label: "Adventurers Recruited", icon: "⚔️" },
|
||||
{ key: "showLifetimeAchievementsUnlocked", label: "Achievements Unlocked", icon: "🏆" },
|
||||
{ key: "showGuildFounded", label: "Guild Founded Date", icon: "📅" },
|
||||
];
|
||||
|
||||
export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.Element => {
|
||||
const { state, numberFormat: currentNumberFormat, setNumberFormat } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [characterName, setCharacterName] = useState(player?.characterName ?? "");
|
||||
const [bio, setBio] = useState("");
|
||||
const [settings, setSettings] = useState<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
numberFormat: currentNumberFormat,
|
||||
});
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Fetch current profile to auto-populate bio and settings
|
||||
useEffect(() => {
|
||||
if (!player?.discordId) return;
|
||||
fetch(`/api/profile/${player.discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
characterName: string;
|
||||
};
|
||||
setBio(data.bio ?? "");
|
||||
setSettings({ ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings });
|
||||
setCharacterName(data.characterName ?? player.characterName ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to local state if fetch fails — not a blocking error
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [player?.discordId, player?.characterName]);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({ characterName, bio, profileSettings: settings });
|
||||
setNumberFormat(settings.numberFormat);
|
||||
setSaved(true);
|
||||
setTimeout(onClose, 900);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSetting = (key: keyof ProfileSettings): void => {
|
||||
setSettings((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div className="modal edit-profile-modal">
|
||||
<div className="modal-header">
|
||||
<h2>Edit Profile</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingProfile ? (
|
||||
<p className="edit-profile-loading">Loading your profile…</p>
|
||||
) : (
|
||||
<div className="edit-profile-form">
|
||||
<label className="edit-profile-label" htmlFor="edit-char-name">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
className="edit-profile-input"
|
||||
id="edit-char-name"
|
||||
maxLength={32}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={characterName}
|
||||
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||
/>
|
||||
<span className="edit-profile-hint">{characterName.length} / 32</span>
|
||||
|
||||
<label className="edit-profile-label" htmlFor="edit-bio">
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
className="edit-profile-textarea"
|
||||
id="edit-bio"
|
||||
maxLength={200}
|
||||
placeholder="Tell the world about your guild… (optional)"
|
||||
rows={3}
|
||||
value={bio}
|
||||
onChange={(e) => { setBio(e.target.value); }}
|
||||
/>
|
||||
<span className="edit-profile-hint">{bio.length} / 200</span>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Visible Stats</p>
|
||||
<p className="edit-profile-sublabel">Choose which stats appear on your public profile.</p>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">Current Run</p>
|
||||
<div className="stat-toggles">
|
||||
{CURRENT_RUN_TOGGLES.map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`stat-toggle-btn ${settings[key] ? "stat-toggle-on" : "stat-toggle-off"}`}
|
||||
onClick={() => { toggleSetting(key); }}
|
||||
type="button"
|
||||
>
|
||||
<span>{icon} {label}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{settings[key] ? "✓ Shown" : "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">All Time</p>
|
||||
<div className="stat-toggles">
|
||||
{ALL_TIME_TOGGLES.map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`stat-toggle-btn ${settings[key] ? "stat-toggle-on" : "stat-toggle-off"}`}
|
||||
onClick={() => { toggleSetting(key); }}
|
||||
type="button"
|
||||
>
|
||||
<span>{icon} {label}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{settings[key] ? "✓ Shown" : "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Privacy</p>
|
||||
<p className="edit-profile-sublabel">Control your visibility on public leaderboards.</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${settings.showOnLeaderboards ? "stat-toggle-on" : "stat-toggle-off"}`}
|
||||
onClick={() => { toggleSetting("showOnLeaderboards"); }}
|
||||
type="button"
|
||||
>
|
||||
<span>🏆 Appear on Leaderboards</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{settings.showOnLeaderboards ? "✓ Shown" : "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Number Format</p>
|
||||
<p className="edit-profile-sublabel">How large numbers appear across the game.</p>
|
||||
<div className="number-format-picker">
|
||||
{(
|
||||
[
|
||||
{ value: "suffix", label: "Suffix", example: "1.23Qa" },
|
||||
{ value: "scientific", label: "Scientific", example: "1.23e15" },
|
||||
{ value: "engineering", label: "Engineering", example: "1.23E15" },
|
||||
] as { value: NumberFormat; label: string; example: string }[]
|
||||
).map(({ value, label, example }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`number-format-btn ${settings.numberFormat === value ? "number-format-active" : ""}`}
|
||||
onClick={() => { setSettings((prev) => ({ ...prev, numberFormat: value })); }}
|
||||
type="button"
|
||||
>
|
||||
<span className="number-format-label">{label}</span>
|
||||
<span className="number-format-example">{example}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="edit-profile-error">{error}</p>}
|
||||
|
||||
<div className="edit-profile-actions">
|
||||
<button
|
||||
className="edit-profile-cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="edit-profile-save"
|
||||
disabled={saving || !characterName.trim()}
|
||||
onClick={() => { void handleSave(); }}
|
||||
type="button"
|
||||
>
|
||||
{saved ? "✓ Saved!" : saving ? "Saving…" : "Save Profile"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const RARITY_LABEL: Record<string, string> = {
|
||||
common: "Common",
|
||||
rare: "Rare",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
const TYPE_ICON: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️",
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
};
|
||||
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: string[] = [];
|
||||
if (item.bonus.combatMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.combatMultiplier - 1) * 100)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.goldMultiplier - 1) * 100)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.clickMultiplier - 1) * 100)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProps {
|
||||
item: Equipment;
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
dropBossName?: string | undefined;
|
||||
setName?: string | undefined;
|
||||
}
|
||||
|
||||
const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => {
|
||||
const parts: string[] = [];
|
||||
if (cost.gold > 0) parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
if (cost.essence > 0) parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
if (cost.crystals > 0) parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const EquipmentCard = ({ item, gold, essence, crystals, dropBossName, setName }: EquipmentCardProps): React.JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford = item.cost
|
||||
? gold >= item.cost.gold && essence >= item.cost.essence && crystals >= item.cost.crystals
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}>
|
||||
<div className="equipment-icon">{TYPE_ICON[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>{RARITY_LABEL[item.rarity]}</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName && <span className="equipment-set-badge">🔗 {setName}</span>}
|
||||
{!item.owned && item.cost && (
|
||||
<p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && !item.cost && (
|
||||
<span className="equipment-locked">
|
||||
{dropBossName ? `⚔️ Drop: ${dropBossName}` : "🔒 Boss drop"}
|
||||
</span>
|
||||
)}
|
||||
{!item.owned && item.cost && (
|
||||
<button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={() => { buyEquipment(item.id); }}
|
||||
type="button"
|
||||
>
|
||||
{canAfford ? "Purchase" : "Can't afford"}
|
||||
</button>
|
||||
)}
|
||||
{item.owned && item.equipped && <span className="equipment-equipped-badge">✓ Equipped</span>}
|
||||
{item.owned && !item.equipped && (
|
||||
<button
|
||||
className="equip-button"
|
||||
onClick={() => { equipItem(item.id); }}
|
||||
type="button"
|
||||
>
|
||||
Equip
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"];
|
||||
const SLOT_LABEL: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️ Weapons",
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
};
|
||||
|
||||
export const EquipmentPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const equipment = state.equipment ?? [];
|
||||
const unownedCount = equipment.filter((e) => !e.owned).length;
|
||||
|
||||
const equipmentDropSources = new Map<string, string>();
|
||||
for (const boss of state.bosses) {
|
||||
for (const equipmentId of (boss.equipmentRewards ?? [])) {
|
||||
equipmentDropSources.set(equipmentId, boss.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build set name lookup for card badges
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((s) => [s.id, s.name]),
|
||||
);
|
||||
|
||||
// Compute active set bonuses for the summary strip
|
||||
const equippedItemIds = equipment.filter((e) => e.equipped).map((e) => e.id);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length;
|
||||
return { set, count };
|
||||
}).filter(({ count }) => count >= 2);
|
||||
|
||||
const setBonusDescription = (set: typeof EQUIPMENT_SETS[number], count: number): string => {
|
||||
const parts: string[] = [];
|
||||
for (const threshold of [2, 3] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = set.bonuses[threshold];
|
||||
if (bonus.goldMultiplier) parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold/s (${threshold}pc)`);
|
||||
if (bonus.combatMultiplier) parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat (${threshold}pc)`);
|
||||
if (bonus.clickMultiplier) parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click (${threshold}pc)`);
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Equipment</h2>
|
||||
<LockToggle
|
||||
lockedCount={unownedCount}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time. Equip matching set pieces for bonus effects!
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0 && (
|
||||
<div className="active-sets">
|
||||
<h3 className="active-sets-heading">✨ Active Set Bonuses</h3>
|
||||
{activeSets.map(({ set, count }) => (
|
||||
<div key={set.id} className="active-set-row">
|
||||
<span className="active-set-name">{set.name} ({count}/{set.pieces.length})</span>
|
||||
<span className="active-set-bonus">{setBonusDescription(set, count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SLOT_ORDER.map((slotType) => {
|
||||
const items = equipment.filter(
|
||||
(e) => e.type === slotType && (showLocked || e.owned),
|
||||
);
|
||||
return (
|
||||
<div key={slotType} className="equipment-slot-section">
|
||||
<h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => (
|
||||
<EquipmentCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
gold={state.resources.gold}
|
||||
essence={state.resources.essence}
|
||||
crystals={state.resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
setName={item.setId ? setNameById.get(item.setId) : undefined}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="empty-zone">No items to show in this slot.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { ExploreCollectResponse } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds >= 86400) {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
}
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000;
|
||||
return Math.max(0, durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface CollectResult {
|
||||
areaId: string;
|
||||
response: ExploreCollectResponse;
|
||||
}
|
||||
|
||||
export const ExplorationPanel = (): React.JSX.Element => {
|
||||
const { state, startExploration, collectExploration, formatNumber } = useGame();
|
||||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||||
const [pendingAreaId, setPendingAreaId] = useState<string | null>(null);
|
||||
const [lastResult, setLastResult] = useState<CollectResult | null>(null);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const explorationState = state.exploration;
|
||||
|
||||
const zoneAreas = EXPLORATION_AREAS.filter((a) => a.zoneId === activeZoneId);
|
||||
|
||||
const hasActiveExploration =
|
||||
explorationState?.areas.some((a) => a.status === "in_progress") ?? false;
|
||||
|
||||
const handleStart = async (areaId: string): Promise<void> => {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollect = async (areaId: string): Promise<void> => {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectExploration(areaId);
|
||||
setLastResult({ areaId, response: result });
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>🗺️ Exploration</h2>
|
||||
</div>
|
||||
|
||||
{lastResult && (
|
||||
<div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={() => { setLastResult(null); }}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{lastResult.response.foundNothing ? (
|
||||
<p className="exploration-nothing">{lastResult.response.nothingMessage}</p>
|
||||
) : (
|
||||
<>
|
||||
{lastResult.response.event && (
|
||||
<p className="exploration-event-text">{lastResult.response.event.text}</p>
|
||||
)}
|
||||
<div className="exploration-rewards">
|
||||
{(lastResult.response.event?.goldChange ?? 0) !== 0 && (
|
||||
<span className={`reward-tag ${(lastResult.response.event?.goldChange ?? 0) > 0 ? "" : "negative"}`}>
|
||||
🪙 {(lastResult.response.event?.goldChange ?? 0) > 0 ? "+" : ""}{formatNumber(lastResult.response.event?.goldChange ?? 0)} gold
|
||||
</span>
|
||||
)}
|
||||
{(lastResult.response.event?.essenceChange ?? 0) > 0 && (
|
||||
<span className="reward-tag">
|
||||
✨ +{formatNumber(lastResult.response.event?.essenceChange ?? 0)} essence
|
||||
</span>
|
||||
)}
|
||||
{lastResult.response.event?.materialGained && (
|
||||
<span className="reward-tag material-tag">
|
||||
📦 +{lastResult.response.event.materialGained.quantity} {lastResult.response.event.materialGained.materialId.replace(/_/g, " ")} (event)
|
||||
</span>
|
||||
)}
|
||||
{lastResult.response.materialsFound.map((m) => (
|
||||
<span key={m.materialId} className="reward-tag material-tag">
|
||||
📦 +{m.quantity} {m.materialId.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={(id) => { setActiveZoneId(id); setLastResult(null); }}
|
||||
/>
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find((a) => a.id === area.id);
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const isReady = status === "in_progress" && timeRemaining(startedAt, area.durationSeconds) <= 0;
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
return (
|
||||
<div key={area.id} className={`exploration-card exploration-${status}`}>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce && <span className="exploration-discovered"> 📖</span>}
|
||||
</h3>
|
||||
<p>{area.description}</p>
|
||||
<span className="exploration-duration">⏱️ {formatDuration(area.durationSeconds)}</span>
|
||||
</div>
|
||||
<div className="exploration-action">
|
||||
{status === "locked" && (
|
||||
<span className="quest-badge locked">🔒 Locked</span>
|
||||
)}
|
||||
{status === "available" && (
|
||||
<button
|
||||
className="start-quest-button"
|
||||
disabled={isPending || hasActiveExploration}
|
||||
onClick={() => { void handleStart(area.id); }}
|
||||
title={hasActiveExploration ? "An exploration is already in progress" : undefined}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Departing..." : `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
)}
|
||||
{status === "in_progress" && !isReady && (
|
||||
<span className="quest-badge active">
|
||||
⏳ {formatDuration(Math.ceil(timeRemaining(startedAt, area.durationSeconds)))} remaining
|
||||
</span>
|
||||
)}
|
||||
{(status === "in_progress" && isReady) && (
|
||||
<button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={() => { void handleCollect(area.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Collecting..." : "📦 Collect Results"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0 && (
|
||||
<p className="empty-zone">No exploration areas in this zone.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { ResourceBar } from "../ui/ResourceBar.js";
|
||||
import { AboutPanel } from "./AboutPanel.js";
|
||||
import { AchievementPanel } from "./AchievementPanel.js";
|
||||
import { AchievementToast } from "./AchievementToast.js";
|
||||
import { AdventurerPanel } from "./AdventurerPanel.js";
|
||||
import { BattleModal } from "./BattleModal.js";
|
||||
import { BossPanel } from "./BossPanel.js";
|
||||
import { ClickArea } from "./ClickArea.js";
|
||||
import { CodexPanel } from "./CodexPanel.js";
|
||||
import { CodexToast } from "./CodexToast.js";
|
||||
import { EditProfileModal } from "./EditProfileModal.js";
|
||||
import { EquipmentPanel } from "./EquipmentPanel.js";
|
||||
import { OfflineModal } from "./OfflineModal.js";
|
||||
import { OutdatedSchemaModal } from "./OutdatedSchemaModal.js";
|
||||
import { PrestigePanel } from "./PrestigePanel.js";
|
||||
import { ApotheosisPanel } from "./ApotheosisPanel.js";
|
||||
import { TranscendencePanel } from "./TranscendencePanel.js";
|
||||
import { QuestPanel } from "./QuestPanel.js";
|
||||
import { StatisticsPanel } from "./StatisticsPanel.js";
|
||||
import { UpgradePanel } from "./UpgradePanel.js";
|
||||
import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
import { ExplorationPanel } from "./ExplorationPanel.js";
|
||||
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
||||
import { CompanionPanel } from "./CompanionPanel.js";
|
||||
import { CraftingPanel } from "./CraftingPanel.js";
|
||||
import { LoginBonusModal } from "./LoginBonusModal.js";
|
||||
import { StoryPanel } from "./StoryPanel.js";
|
||||
import { StoryToast } from "./StoryToast.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions" | "story";
|
||||
|
||||
const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
{ id: "quests", label: "📜 Quests" },
|
||||
{ id: "bosses", label: "👹 Bosses" },
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "exploration", label: "🗺️ Exploration" },
|
||||
{ id: "crafting", label: "⚗️ Crafting" },
|
||||
{ id: "daily", label: "📅 Daily" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "companions", label: "👥 Companions" },
|
||||
{ id: "character", label: "📋 Character" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "story", label: "📖 Story" },
|
||||
{ id: "codex", label: "🗺️ Codex" },
|
||||
{ id: "about", label: "ℹ️ About" },
|
||||
];
|
||||
|
||||
export const GameLayout = (): React.JSX.Element => {
|
||||
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, newStoryChapterIds, loginBonus, dismissLoginBonus, schemaOutdated } = useGame();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
const [dismissedOutdatedWarning, setDismissedOutdatedWarning] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>Loading your adventure...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-screen">
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state) return <div className="loading-screen"><p>Loading...</p></div>;
|
||||
|
||||
const profileUrl = `/profile/${state.player.discordId}`;
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<ResourceBar
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
prestigeCount={state.prestige.count}
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
apotheosisCount={state.apotheosis?.count ?? 0}
|
||||
profileUrl={profileUrl}
|
||||
onEditProfile={() => { setEditingProfile(true); }}
|
||||
lastSavedAt={lastSavedAt}
|
||||
isSyncing={isSyncing}
|
||||
onForceSync={forceSync}
|
||||
/>
|
||||
<OfflineModal />
|
||||
{schemaOutdated && !dismissedOutdatedWarning && (
|
||||
<OutdatedSchemaModal onDismiss={() => { setDismissedOutdatedWarning(true); }} />
|
||||
)}
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
<StoryToast />
|
||||
{loginBonus && (
|
||||
<LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||
)}
|
||||
{battleResult && (
|
||||
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
)}
|
||||
{editingProfile && (
|
||||
<EditProfileModal onClose={() => { setEditingProfile(false); }} />
|
||||
)}
|
||||
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
<ClickArea />
|
||||
<p className="game-copyright">© NHCarrigan</p>
|
||||
</aside>
|
||||
|
||||
<main className="game-content">
|
||||
<nav className="tab-bar">
|
||||
{BASE_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab(tab.id); }}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
{tab.id === "codex" && newCodexEntryIds.length > 0 && (
|
||||
<span className="tab-badge">{newCodexEntryIds.length}</span>
|
||||
)}
|
||||
{tab.id === "story" && newStoryChapterIds.length > 0 && (
|
||||
<span className="tab-badge">{newStoryChapterIds.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "transcendence" && <TranscendencePanel />}
|
||||
{activeTab === "apotheosis" && <ApotheosisPanel />}
|
||||
{activeTab === "exploration" && <ExplorationPanel />}
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "story" && <StoryPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CategoryConfig {
|
||||
id: LeaderboardCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
formatValue: (value: number) => string;
|
||||
}
|
||||
|
||||
const CATEGORIES: CategoryConfig[] = [
|
||||
{
|
||||
id: "totalGold",
|
||||
label: "Lifetime Gold",
|
||||
icon: "🪙",
|
||||
formatValue: (v) => formatGold(v),
|
||||
},
|
||||
{
|
||||
id: "bossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
icon: "💀",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "questsCompleted",
|
||||
label: "Quests Completed",
|
||||
icon: "📜",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "achievementsUnlocked",
|
||||
label: "Achievements",
|
||||
icon: "🏆",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "prestigeCount",
|
||||
label: "Prestige",
|
||||
icon: "⭐",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "transcendenceCount",
|
||||
label: "Transcendence",
|
||||
icon: "🌌",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "apotheosisCount",
|
||||
label: "Apotheosis",
|
||||
icon: "✨",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const SUFFIXES = ["", "K", "M", "B", "T", "Qa", "Qt", "S", "Sp", "O", "N", "D"];
|
||||
|
||||
const formatGold = (value: number): string => {
|
||||
if (value === 0) return "0";
|
||||
const tier = Math.floor(Math.log10(Math.abs(value)) / 3);
|
||||
const clamped = Math.min(tier, SUFFIXES.length - 1);
|
||||
const scaled = value / Math.pow(1000, clamped);
|
||||
return `${parseFloat(scaled.toFixed(2))}${SUFFIXES[clamped] ?? ""}`;
|
||||
};
|
||||
|
||||
const RANK_BADGES: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
||||
|
||||
export const LeaderboardPage = (): React.JSX.Element => {
|
||||
const [category, setCategory] = useState<LeaderboardCategory>("totalGold");
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/leaderboards?category=${category}&limit=100`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error("Failed to load leaderboard");
|
||||
const data = (await res.json()) as { entries: LeaderboardEntry[] };
|
||||
setEntries(data.entries);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load leaderboard");
|
||||
})
|
||||
.finally(() => { setLoading(false); });
|
||||
}, [category]);
|
||||
|
||||
const currentConfig = CATEGORIES.find((c) => c.id === category) ?? CATEGORIES[0];
|
||||
|
||||
return (
|
||||
<div className="leaderboard-page">
|
||||
<div className="leaderboard-card">
|
||||
<div className="leaderboard-header">
|
||||
<h1 className="leaderboard-title">🏆 Leaderboards</h1>
|
||||
<p className="leaderboard-subtitle">The mightiest adventurers in Elysium</p>
|
||||
</div>
|
||||
|
||||
<div className="leaderboard-tabs">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`leaderboard-tab ${category === cat.id ? "leaderboard-tab--active" : ""}`}
|
||||
onClick={() => { setCategory(cat.id); }}
|
||||
type="button"
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="leaderboard-loading">Loading…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="leaderboard-error">⚠️ {error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div className="leaderboard-empty">No entries yet — be the first on the board!</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries.length > 0 && (
|
||||
<div className="leaderboard-table">
|
||||
<div className="leaderboard-table-header">
|
||||
<span className="leaderboard-col-rank">Rank</span>
|
||||
<span className="leaderboard-col-player">Player</span>
|
||||
<span className="leaderboard-col-value">{currentConfig?.icon} {currentConfig?.label}</span>
|
||||
</div>
|
||||
{entries.map((entry) => {
|
||||
const avatarUrl = entry.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(entry.discordId, 10) % 5}.png`;
|
||||
const displayName = entry.characterName || entry.username;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`leaderboard-row ${entry.rank <= 3 ? `leaderboard-row--top${entry.rank}` : ""}`}
|
||||
href={`/character/${entry.discordId}`}
|
||||
key={entry.discordId}
|
||||
>
|
||||
<span className="leaderboard-col-rank">
|
||||
{RANK_BADGES[entry.rank] ?? `#${entry.rank}`}
|
||||
</span>
|
||||
<span className="leaderboard-col-player">
|
||||
<img
|
||||
alt={displayName}
|
||||
className="leaderboard-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<span className="leaderboard-player-info">
|
||||
<span className="leaderboard-player-name">{displayName}</span>
|
||||
{entry.activeTitle && (
|
||||
<span className="leaderboard-player-title">{entry.activeTitle}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.formatValue(entry.value)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="leaderboard-footer">
|
||||
<a className="leaderboard-play-link" href="/">⚔️ Play Elysium</a>
|
||||
<p className="leaderboard-privacy-note">
|
||||
Players can opt out via their profile settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { LoginBonusResult } from "@elysium/types";
|
||||
|
||||
interface LoginBonusModalProps {
|
||||
bonus: LoginBonusResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DAY_ICONS = ["🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥"];
|
||||
|
||||
const formatGold = (value: number): string => {
|
||||
const suffixes = ["", "K", "M", "B", "T"];
|
||||
if (value < 1_000) return value.toLocaleString();
|
||||
const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1);
|
||||
const scaled = value / Math.pow(1_000, tier);
|
||||
return `${parseFloat(scaled.toFixed(1))}${suffixes[tier] ?? ""}`;
|
||||
};
|
||||
|
||||
export const LoginBonusModal = ({ bonus, onClose }: LoginBonusModalProps): React.JSX.Element => {
|
||||
const isWeeklyBonus = bonus.day === 7;
|
||||
const dayIcon = DAY_ICONS[bonus.day - 1] ?? "⭐";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div className="modal login-bonus-modal">
|
||||
<div className="login-bonus-streak">
|
||||
<span className="login-bonus-fire">🔥</span>
|
||||
<span className="login-bonus-streak-count">{bonus.streak}</span>
|
||||
<span className="login-bonus-streak-label">
|
||||
{bonus.streak === 1 ? "Day Streak" : "Day Streak"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-day-badge">
|
||||
<span className="login-bonus-day-icon">{dayIcon}</span>
|
||||
<span className="login-bonus-day-label">Day {bonus.day} Reward</span>
|
||||
{bonus.weekMultiplier > 1 && (
|
||||
<span className="login-bonus-week-tag">×{bonus.weekMultiplier} Week Bonus!</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-rewards">
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">🪙</span>
|
||||
<span className="login-bonus-reward-value">+{formatGold(bonus.goldEarned)} Gold</span>
|
||||
</div>
|
||||
{bonus.crystalsEarned > 0 && (
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">💎</span>
|
||||
<span className="login-bonus-reward-value">+{bonus.crystalsEarned} Crystals</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWeeklyBonus && (
|
||||
<p className="login-bonus-weekly-message">
|
||||
🎉 Weekly bonus — keep the streak going!
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="login-bonus-calendar">
|
||||
{DAY_ICONS.map((icon, i) => {
|
||||
const dayNum = i + 1;
|
||||
const isCompleted = dayNum < bonus.day || (bonus.day === 7 && dayNum === 7);
|
||||
const isToday = dayNum === bonus.day;
|
||||
return (
|
||||
<div
|
||||
key={dayNum}
|
||||
className={`login-bonus-cal-day ${isToday ? "login-bonus-cal-day--today" : ""} ${isCompleted ? "login-bonus-cal-day--done" : ""}`}
|
||||
>
|
||||
<span className="login-bonus-cal-icon">{icon}</span>
|
||||
<span className="login-bonus-cal-num">{dayNum}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className="login-bonus-claim-btn" onClick={onClose} type="button">
|
||||
Claim Reward
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
export const LoginPage = ({ onLogin }: LoginPageProps): React.JSX.Element => {
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle OAuth callback
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
|
||||
if (code) {
|
||||
setIsLoading(true);
|
||||
handleAuthCallback(code)
|
||||
.then(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
onLogin();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the Discord OAuth URL
|
||||
getAuthUrl()
|
||||
.then((url) => {
|
||||
setAuthUrl(url);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Failed to load authentication URL");
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [onLogin]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p className="error">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.reload(); }}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>⚔️ Elysium</h1>
|
||||
<p>An idle fantasy RPG. Hire adventurers, defeat bosses, and ascend to glory.</p>
|
||||
<a
|
||||
className="discord-login-button"
|
||||
href={authUrl ?? "#"}
|
||||
>
|
||||
Login with Discord
|
||||
</a>
|
||||
<p className="login-note">
|
||||
Your progress is saved to your Discord account and shareable with others!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
export const OfflineModal = (): React.JSX.Element | null => {
|
||||
const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber } = useGame();
|
||||
|
||||
if (offlineGold <= 0 && offlineEssence <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>Welcome back!</h2>
|
||||
<p>Your adventurers kept working whilst you were away and earned:</p>
|
||||
{offlineGold > 0 && (
|
||||
<p>
|
||||
<strong>🪙 {formatNumber(offlineGold)} gold</strong>
|
||||
</p>
|
||||
)}
|
||||
{offlineEssence > 0 && (
|
||||
<p>
|
||||
<strong>✨ {formatNumber(offlineEssence)} essence</strong>
|
||||
</p>
|
||||
)}
|
||||
<p className="modal-note">Offline progress is calculated up to 8 hours.</p>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={dismissOfflineGold}
|
||||
type="button"
|
||||
>
|
||||
Collect!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface OutdatedSchemaModalProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const OutdatedSchemaModal = ({ onDismiss }: OutdatedSchemaModalProps): React.JSX.Element => {
|
||||
const { resetProgress } = useGame();
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const handleReset = async (): Promise<void> => {
|
||||
setIsResetting(true);
|
||||
await resetProgress();
|
||||
setIsResetting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal offline-modal">
|
||||
<h2>⚠️ Outdated Save Data</h2>
|
||||
<p>
|
||||
Your save data is from an older version of Elysium and may cause bugs or unexpected
|
||||
behaviour. Cloud saves are <strong>disabled</strong> until you reset your progress.
|
||||
</p>
|
||||
<p>
|
||||
Resetting will start you fresh — all progress will be lost.
|
||||
</p>
|
||||
<div className="outdated-modal-actions">
|
||||
<button
|
||||
className="outdated-modal-reset-button"
|
||||
onClick={() => { void handleReset(); }}
|
||||
disabled={isResetting}
|
||||
type="button"
|
||||
>
|
||||
{isResetting ? "Resetting…" : "Reset Progress"}
|
||||
</button>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Proceed with Bugs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
|
||||
const BASE_THRESHOLD = 1_000_000;
|
||||
const THRESHOLD_SCALE = 5;
|
||||
const RUNESTONES_PER_LEVEL = 10;
|
||||
|
||||
const calculateThreshold = (prestigeCount: number): number =>
|
||||
BASE_THRESHOLD * Math.pow(THRESHOLD_SCALE, prestigeCount);
|
||||
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number =>
|
||||
Math.pow(1.15, prestigeCount);
|
||||
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: string[],
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base = Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_LEVEL;
|
||||
const runestoneMult = PRESTIGE_UPGRADES
|
||||
.filter((u) => u.category === "runestones" && purchasedUpgradeIds.includes(u.id))
|
||||
.reduce((mult, u) => mult * u.multiplier, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: PrestigeUpgradeCategory[] = [
|
||||
"income",
|
||||
"click",
|
||||
"essence",
|
||||
"crystals",
|
||||
"runestones",
|
||||
"utility",
|
||||
];
|
||||
|
||||
export const PrestigePanel = (): React.JSX.Element => {
|
||||
const { state, reload, formatNumber, buyPrestigeUpgrade, toggleAutoPrestige } = useGame();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [result, setResult] = useState<{ runestones: number; count: number; milestoneRunestones: number } | null>(null);
|
||||
const [prestigeError, setPrestigeError] = useState<string | null>(null);
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"prestige" | "shop">("prestige");
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
const handlePrestige = async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
setPrestigeError(null);
|
||||
try {
|
||||
const data = await prestige({});
|
||||
setResult({ runestones: data.runestones, count: data.newPrestigeCount, milestoneRunestones: data.milestoneRunestones });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setPrestigeError(err instanceof Error ? err.message : "Prestige failed");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyUpgrade = async (upgradeId: string): Promise<void> => {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyPrestigeUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upgradesByCategory = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: PRESTIGE_UPGRADE_CATEGORY_LABELS[category] ?? category,
|
||||
upgrades: PRESTIGE_UPGRADES.filter((u) => u.category === category),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="panel prestige-panel">
|
||||
<h2>⭐ Prestige</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "prestige" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("prestige"); }}
|
||||
type="button"
|
||||
>
|
||||
Ascend
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("shop"); }}
|
||||
type="button"
|
||||
>
|
||||
🔮 Runestone Shop ({formatNumber(prestigeData.runestones)} stones)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "prestige" && (
|
||||
<>
|
||||
<p>
|
||||
Prestige resets your progress but grants <strong>Runestones</strong> — permanent
|
||||
currency used for powerful upgrades. Each prestige multiplies your global production
|
||||
by ×1.15 (compounding each run).
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
Total gold this run:{" "}
|
||||
<strong>{formatNumber(player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Required to prestige: <strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Prestige count: <strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Current production multiplier:{" "}
|
||||
<strong>×{prestigeData.productionMultiplier.toFixed(2)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
After next prestige:{" "}
|
||||
<strong>×{nextMultiplier.toFixed(2)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Runestones: <strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible && (
|
||||
<p className="runestone-preview">
|
||||
Runestones on prestige: <strong>+{formatNumber(runestonePreview)}</strong>
|
||||
</p>
|
||||
)}
|
||||
{!isEligible && (
|
||||
<p className="prestige-progress">
|
||||
Progress: {formatNumber(player.totalGoldEarned)} / {formatNumber(threshold)}{" "}
|
||||
({((player.totalGoldEarned / threshold) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEligible ? (
|
||||
<div className="prestige-form">
|
||||
<p>You are ready to prestige!</p>
|
||||
<button
|
||||
className="prestige-button"
|
||||
disabled={isPending}
|
||||
onClick={() => { void handlePrestige(); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Ascending..." : `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError && <p className="error">{prestigeError}</p>}
|
||||
{result && (
|
||||
<p className="success">
|
||||
Ascended to Prestige {result.count}! Earned {formatNumber(result.runestones)} Runestones.
|
||||
{result.milestoneRunestones > 0 && (
|
||||
<> 🎉 Milestone bonus: +{formatNumber(result.milestoneRunestones)} Runestones!</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="prestige-locked">
|
||||
Earn {formatNumber(threshold - player.totalGoldEarned)} more gold to unlock prestige.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "shop" && (
|
||||
<div className="runestone-shop">
|
||||
<p className="shop-balance">
|
||||
Balance: <strong>{formatNumber(prestigeData.runestones)} Runestones</strong>
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ category, label, upgrades }) => (
|
||||
<div key={category} className="shop-category">
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = prestigeData.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = prestigeData.runestones >= upgrade.runestonesCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
const isAutoPrestigeToggle = upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled = prestigeData.autoPrestigeEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`shop-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased ? "✅ Purchased" : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoPrestigeToggle && (
|
||||
<button
|
||||
className={`auto-prestige-toggle ${autoPrestigeEnabled ? "enabled" : "disabled"}`}
|
||||
onClick={() => { toggleAutoPrestige(); }}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled ? "⚡ Auto ON" : "⏸ Auto OFF"}
|
||||
</button>
|
||||
)}
|
||||
{!purchased && (
|
||||
<button
|
||||
className="buy-upgrade-button"
|
||||
disabled={!canAfford || isLoading || buyingId !== null}
|
||||
onClick={() => { void handleBuyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "Buying..." : "Buy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,233 +0,0 @@
|
||||
import type { PublicProfileResponse } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
|
||||
interface ProfilePageProps {
|
||||
discordId: string;
|
||||
}
|
||||
|
||||
interface StatEntry {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
date: boolean;
|
||||
}
|
||||
|
||||
export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => {
|
||||
const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error("Player not found");
|
||||
return res.json() as Promise<PublicProfileResponse>;
|
||||
})
|
||||
.then(setProfile)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load profile");
|
||||
});
|
||||
}, [discordId]);
|
||||
|
||||
const handleCopy = (): void => {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => { setCopied(false); }, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-error">
|
||||
<p>⚠️ {error}</p>
|
||||
<a className="profile-play-link" href="/">← Play Elysium</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-loading">Loading profile…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = profile.profileSettings;
|
||||
const fmt = (n: number): string => formatNumber(n, s.numberFormat);
|
||||
|
||||
const avatarUrl = profile.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
|
||||
|
||||
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const currentRunStats = [
|
||||
s.showCurrentGold && {
|
||||
icon: "🪙",
|
||||
value: fmt(profile.currentRunGold),
|
||||
label: "Gold Earned",
|
||||
date: false,
|
||||
},
|
||||
s.showCurrentClicks && {
|
||||
icon: "👆",
|
||||
value: fmt(profile.currentRunClicks),
|
||||
label: "Clicks",
|
||||
date: false,
|
||||
},
|
||||
s.showBossesDefeated && {
|
||||
icon: "💀",
|
||||
value: String(profile.bossesDefeated),
|
||||
label: "Bosses Defeated",
|
||||
date: false,
|
||||
},
|
||||
s.showQuestsCompleted && {
|
||||
icon: "📜",
|
||||
value: String(profile.questsCompleted),
|
||||
label: "Quests Completed",
|
||||
date: false,
|
||||
},
|
||||
s.showAdventurersRecruited && {
|
||||
icon: "⚔️",
|
||||
value: fmt(profile.adventurersRecruited),
|
||||
label: "Adventurers Recruited",
|
||||
date: false,
|
||||
},
|
||||
s.showAchievementsUnlocked && {
|
||||
icon: "🏆",
|
||||
value: String(profile.achievementsUnlocked),
|
||||
label: "Achievements Unlocked",
|
||||
date: false,
|
||||
},
|
||||
].filter(Boolean) as StatEntry[];
|
||||
|
||||
const allTimeStats = [
|
||||
s.showTotalGold && {
|
||||
icon: "🪙",
|
||||
value: fmt(profile.totalGoldEarned),
|
||||
label: "Total Gold Earned",
|
||||
date: false,
|
||||
},
|
||||
s.showTotalClicks && {
|
||||
icon: "👆",
|
||||
value: fmt(profile.totalClicks),
|
||||
label: "Total Clicks",
|
||||
date: false,
|
||||
},
|
||||
s.showLifetimeBossesDefeated && {
|
||||
icon: "💀",
|
||||
value: String(profile.lifetimeBossesDefeated),
|
||||
label: "Bosses Defeated",
|
||||
date: false,
|
||||
},
|
||||
s.showLifetimeQuestsCompleted && {
|
||||
icon: "📜",
|
||||
value: String(profile.lifetimeQuestsCompleted),
|
||||
label: "Quests Completed",
|
||||
date: false,
|
||||
},
|
||||
s.showLifetimeAdventurersRecruited && {
|
||||
icon: "⚔️",
|
||||
value: fmt(profile.lifetimeAdventurersRecruited),
|
||||
label: "Adventurers Recruited",
|
||||
date: false,
|
||||
},
|
||||
s.showLifetimeAchievementsUnlocked && {
|
||||
icon: "🏆",
|
||||
value: String(profile.lifetimeAchievementsUnlocked),
|
||||
label: "Achievements Unlocked",
|
||||
date: false,
|
||||
},
|
||||
s.showGuildFounded && {
|
||||
icon: "📅",
|
||||
value: memberSince,
|
||||
label: "Guild Founded",
|
||||
date: true,
|
||||
},
|
||||
].filter(Boolean) as StatEntry[];
|
||||
|
||||
const renderStats = (stats: StatEntry[]): React.JSX.Element => (
|
||||
<div className="profile-stats">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="profile-stat">
|
||||
<span className="profile-stat-icon">{stat.icon}</span>
|
||||
<span className={`profile-stat-value ${stat.date ? "profile-stat-date" : ""}`}>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="profile-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-card">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
alt={`${profile.username}'s avatar`}
|
||||
className="profile-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="profile-identity">
|
||||
<h1 className="profile-character-name">{profile.characterName}</h1>
|
||||
<p className="profile-username">@{profile.username}</p>
|
||||
{s.showApotheosis && profile.apotheosisCount > 0 && (
|
||||
<span className="profile-apotheosis-badge">
|
||||
✨ Apotheosis {profile.apotheosisCount}
|
||||
</span>
|
||||
)}
|
||||
{s.showTranscendence && profile.transcendenceCount > 0 && (
|
||||
<span className="profile-transcendence-badge">
|
||||
🌌 Transcendence {profile.transcendenceCount}
|
||||
</span>
|
||||
)}
|
||||
{s.showPrestige && profile.prestigeCount > 0 && (
|
||||
<span className="profile-prestige-badge">
|
||||
⭐ Prestige {profile.prestigeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio && (
|
||||
<p className="profile-bio">{profile.bio}</p>
|
||||
)}
|
||||
|
||||
{currentRunStats.length > 0 && (
|
||||
<div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">Current Run</h3>
|
||||
{renderStats(currentRunStats)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allTimeStats.length > 0 && (
|
||||
<div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">All Time</h3>
|
||||
{renderStats(allTimeStats)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="profile-share-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? "✓ Copied!" : "🔗 Copy Profile Link"}
|
||||
</button>
|
||||
<a className="profile-play-link" href="/">
|
||||
⚔️ Play Elysium
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { Quest } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const questTimeRemaining = (quest: Quest): number => {
|
||||
if (quest.status !== "active" || quest.startedAt == null) return 0;
|
||||
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
||||
return Math.max(0, quest.durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface QuestCardProps {
|
||||
quest: Quest;
|
||||
partyCombatPower: number;
|
||||
unlockHint?: string | undefined;
|
||||
zoneHint?: string | undefined;
|
||||
}
|
||||
|
||||
const QuestCard = ({ quest, partyCombatPower, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => {
|
||||
const { startQuest, formatNumber } = useGame();
|
||||
const cpRequired = quest.combatPowerRequired ?? 0;
|
||||
const meetsCP = partyCombatPower >= cpRequired;
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<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, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key -- rewards have no unique id
|
||||
<span key={index} className="reward-tag">
|
||||
{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 && <p className="unlock-hint">🗺️ Unlock zone: {zoneHint}</p>}
|
||||
{!zoneHint && unlockHint && <p className="unlock-hint">📜 Complete: {unlockHint}</p>}
|
||||
</>
|
||||
)}
|
||||
{quest.status === "available" && quest.lastFailedAt != null && (
|
||||
<p className="quest-failed-hint">⚠️ Last attempt failed</p>
|
||||
)}
|
||||
{quest.status === "available" && (
|
||||
<button
|
||||
className="start-quest-button"
|
||||
disabled={!meetsCP}
|
||||
onClick={() => { startQuest(quest.id); }}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestPanel = (): React.JSX.Element => {
|
||||
const { state, toggleAutoQuest } = useGame();
|
||||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const partyCombatPower = state.adventurers.reduce(
|
||||
(total, a) => total + a.combatPower * a.count,
|
||||
0,
|
||||
);
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const zoneQuests = state.quests.filter((q) => q.zoneId === activeZoneId);
|
||||
const lockedCount = zoneQuests.filter((q) => q.status === "locked").length;
|
||||
const visibleQuests = showLocked
|
||||
? zoneQuests
|
||||
: zoneQuests.filter((q) => q.status !== "locked");
|
||||
|
||||
const questNameById = new Map(state.quests.map((q) => [q.id, q.name]));
|
||||
const zoneById = new Map(zones.map((z) => [z.id, z]));
|
||||
const questUnlockHints = new Map<string, string>();
|
||||
const questZoneHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
if (quest.status !== "locked") continue;
|
||||
const zone = zoneById.get(quest.zoneId);
|
||||
if (zone?.status === "locked") {
|
||||
questZoneHints.set(quest.id, zone.name);
|
||||
} else if (quest.prerequisiteIds.length > 0) {
|
||||
const prereqId = quest.prerequisiteIds[0];
|
||||
if (prereqId) {
|
||||
const prereqName = questNameById.get(prereqId);
|
||||
if (prereqName) {
|
||||
questUnlockHints.set(quest.id, prereqName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel quest-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Quests</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${state.autoQuest ? "auto-toggle-on" : "auto-toggle-off"}`}
|
||||
onClick={toggleAutoQuest}
|
||||
title="Automatically send the party on the highest available quest"
|
||||
type="button"
|
||||
>
|
||||
🤖 Auto: {state.autoQuest ? "ON" : "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={setActiveZoneId}
|
||||
/>
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js";
|
||||
|
||||
const formatDate = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
interface StatCardProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
const StatCard = ({ icon, label, value, sub }: StatCardProps): React.JSX.Element => (
|
||||
<div className="profile-stat">
|
||||
<span className="profile-stat-icon">{icon}</span>
|
||||
<span className="profile-stat-value">{value}</span>
|
||||
<span className="profile-stat-label">{label}</span>
|
||||
{sub !== undefined && <span className="profile-stat-date">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const StatisticsPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { player, resources, prestige, bosses, quests, zones, adventurers, upgrades, equipment, achievements } = state;
|
||||
|
||||
const bossesDefeated = bosses.filter((b) => b.status === "defeated").length;
|
||||
const questsCompleted = quests.filter((q) => q.status === "completed").length;
|
||||
const zonesUnlocked = zones.filter((z) => z.status === "unlocked").length;
|
||||
const adventurersRecruited = adventurers.reduce((sum, a) => sum + a.count, 0);
|
||||
const equipmentOwned = (equipment ?? []).filter((e) => e.owned).length;
|
||||
const upgradesPurchased = upgrades.filter((u) => u.purchased).length;
|
||||
const achievementsUnlocked = (achievements ?? []).filter((a) => a.unlockedAt !== null).length;
|
||||
const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length;
|
||||
|
||||
return (
|
||||
<section className="panel statistics-panel">
|
||||
<h2>📊 Statistics</h2>
|
||||
|
||||
<h3 className="stats-section-header">All-Time</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Total Gold Earned"
|
||||
value={formatNumber(player.totalGoldEarned)}
|
||||
sub="across all runs"
|
||||
/>
|
||||
<StatCard
|
||||
icon="👆"
|
||||
label="Total Clicks"
|
||||
value={formatNumber(player.totalClicks)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⭐"
|
||||
label="Prestiges"
|
||||
value={String(prestige.count)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📅"
|
||||
label="Guild Founded"
|
||||
value={formatDate(player.createdAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="☁️"
|
||||
label="Last Cloud Save"
|
||||
value={formatDate(player.lastSavedAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✖️"
|
||||
label="Production Multiplier"
|
||||
value={`×${prestige.productionMultiplier.toFixed(2)}`}
|
||||
sub="from prestige"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">Current Run</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Gold"
|
||||
value={formatNumber(resources.gold)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✨"
|
||||
label="Essence"
|
||||
value={formatNumber(resources.essence)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
sub="permanent currency"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">Progress</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="👹"
|
||||
label="Bosses Defeated"
|
||||
value={`${String(bossesDefeated)} / ${String(bosses.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📜"
|
||||
label="Quests Completed"
|
||||
value={`${String(questsCompleted)} / ${String(quests.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗺️"
|
||||
label="Zones Unlocked"
|
||||
value={`${String(zonesUnlocked)} / ${String(zones.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚔️"
|
||||
label="Adventurers Recruited"
|
||||
value={formatNumber(adventurersRecruited)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗡️"
|
||||
label="Equipment Owned"
|
||||
value={`${String(equipmentOwned)} / ${String((equipment ?? []).length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔧"
|
||||
label="Upgrades Purchased"
|
||||
value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
label="Achievements"
|
||||
value={`${String(achievementsUnlocked)} / ${String((achievements ?? []).length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Prestige Upgrades"
|
||||
value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const substituteCharacterName = (text: string, characterName: string): string =>
|
||||
text.replaceAll("{characterName}", characterName || "the guild leader");
|
||||
|
||||
export const StoryPanel = (): React.JSX.Element => {
|
||||
const { state, completeChapter } = useGame();
|
||||
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
|
||||
|
||||
if (!state) return <div className="story-panel"><p>Loading…</p></div>;
|
||||
|
||||
const unlockedIds = state.story?.unlockedChapterIds ?? [];
|
||||
const completedChapters = state.story?.completedChapters ?? [];
|
||||
const characterName = state.player.characterName ?? "";
|
||||
|
||||
const activeChapter = STORY_CHAPTERS[activeChapterIndex];
|
||||
const isUnlocked = unlockedIds.includes(activeChapter?.id ?? "");
|
||||
const completion = activeChapter
|
||||
? completedChapters.find((c) => c.chapterId === activeChapter.id)
|
||||
: null;
|
||||
const isUnread = isUnlocked && !completion;
|
||||
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<div className="story-chapter-tabs">
|
||||
{STORY_CHAPTERS.map((chapter, index) => {
|
||||
const unlocked = unlockedIds.includes(chapter.id);
|
||||
const completed = completedChapters.some((c) => c.chapterId === chapter.id);
|
||||
const unread = unlocked && !completed;
|
||||
return (
|
||||
<button
|
||||
key={chapter.id}
|
||||
className={[
|
||||
"story-tab-btn",
|
||||
activeChapterIndex === index ? "active" : "",
|
||||
!unlocked ? "locked" : "",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setActiveChapterIndex(index);
|
||||
}}
|
||||
type="button"
|
||||
aria-label={unlocked ? chapter.title : `Chapter ${index + 1} (locked)`}
|
||||
>
|
||||
{index + 1}
|
||||
{unread && <span className="story-unread-dot" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeChapter && (
|
||||
<div className="story-chapter-view">
|
||||
{isUnlocked ? (
|
||||
<>
|
||||
<h2 className="story-chapter-title">
|
||||
Chapter {activeChapterIndex + 1}: {activeChapter.title}
|
||||
</h2>
|
||||
<div className="story-chapter-content">
|
||||
{substituteCharacterName(activeChapter.content, characterName)
|
||||
.split("\n\n")
|
||||
.map((paragraph, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key -- static paragraph splits
|
||||
<p key={i}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{completion ? (
|
||||
<div className="story-choice-result">
|
||||
<p className="story-choice-label">
|
||||
<strong>Your choice:</strong>{" "}
|
||||
{activeChapter.choices.find((c) => c.id === completion.choiceId)?.label}
|
||||
</p>
|
||||
<p className="story-choice-outcome">
|
||||
{substituteCharacterName(
|
||||
activeChapter.choices.find((c) => c.id === completion.choiceId)?.outcome ?? "",
|
||||
characterName,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
isUnread && (
|
||||
<div className="story-choices">
|
||||
<p className="story-choices-prompt">What do you do?</p>
|
||||
{activeChapter.choices.map((choice) => (
|
||||
<button
|
||||
key={choice.id}
|
||||
className="story-choice-btn"
|
||||
onClick={() => {
|
||||
completeChapter(activeChapter.id, choice.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="story-locked">
|
||||
<p className="story-locked-title">Chapter {activeChapterIndex + 1}</p>
|
||||
<p className="story-locked-hint">🔒 This chapter has not yet been unlocked.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface StoryToastItemProps {
|
||||
chapterId: string;
|
||||
}
|
||||
|
||||
const StoryToastItem = ({ chapterId }: StoryToastItemProps): React.JSX.Element | null => {
|
||||
const { dismissStoryChapter } = useGame();
|
||||
const chapter = STORY_CHAPTERS.find((c) => c.id === chapterId);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
dismissStoryChapter(chapterId);
|
||||
}, 4000);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [chapterId, dismissStoryChapter]);
|
||||
|
||||
if (!chapter) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="achievement-toast"
|
||||
onClick={() => {
|
||||
dismissStoryChapter(chapterId);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="achievement-toast-icon">📖</span>
|
||||
<div className="achievement-toast-content">
|
||||
<span className="achievement-toast-label">✨ New Chapter!</span>
|
||||
<span className="achievement-toast-name">{chapter.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const StoryToast = (): React.JSX.Element | null => {
|
||||
const { newStoryChapterIds } = useGame();
|
||||
if (newStoryChapterIds.length === 0) return null;
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{newStoryChapterIds.map((id) => (
|
||||
<StoryToastItem key={id} chapterId={id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
|
||||
const ECHO_FORMULA_CONSTANT = 853;
|
||||
const FINAL_BOSS_ID = "the_absolute_one";
|
||||
|
||||
const calculateEchoPreview = (prestigeCount: number, echoMetaMultiplier: number): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
return Math.floor((ECHO_FORMULA_CONSTANT / Math.sqrt(safeCount)) * echoMetaMultiplier);
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: TranscendenceUpgradeCategory[] = [
|
||||
"income",
|
||||
"combat",
|
||||
"prestige_threshold",
|
||||
"prestige_runestones",
|
||||
"echo_meta",
|
||||
];
|
||||
|
||||
export const TranscendencePanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [result, setResult] = useState<{ echoes: number; count: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"transcend" | "shop">("transcend");
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { bosses, prestige: prestigeData, transcendence } = state;
|
||||
const hasDefeatedFinalBoss = bosses.some((b) => b.id === FINAL_BOSS_ID && b.status === "defeated");
|
||||
const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1;
|
||||
const echoPreview = calculateEchoPreview(prestigeData.count, echoMetaMultiplier);
|
||||
const currentEchoes = transcendence?.echoes ?? 0;
|
||||
const transcendenceCount = transcendence?.count ?? 0;
|
||||
|
||||
const handleTranscend = async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await transcend();
|
||||
setResult({ echoes: data.echoes, count: data.newTranscendenceCount });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Transcendence failed");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyUpgrade = async (upgradeId: string): Promise<void> => {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEchoUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upgradesByCategory = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: TRANSCENDENCE_UPGRADE_CATEGORY_LABELS[category] ?? category,
|
||||
upgrades: TRANSCENDENCE_UPGRADES.filter((u) => u.category === category),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="panel transcendence-panel">
|
||||
<h2>🌌 Transcendence</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "transcend" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("transcend"); }}
|
||||
type="button"
|
||||
>
|
||||
Transcend
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("shop"); }}
|
||||
type="button"
|
||||
>
|
||||
✨ Echo Shop ({formatNumber(currentEchoes)} echoes)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "transcend" && (
|
||||
<>
|
||||
<p className="transcendence-intro">
|
||||
Transcendence is the ultimate reset. It wipes{" "}
|
||||
<strong>everything</strong> — resources, prestige, runestones, upgrades,
|
||||
and equipment — but grants <strong>Echoes</strong>, a permanent currency
|
||||
that survives all future resets. Echoes power upgrades that permanently
|
||||
amplify every run from this point forward.
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
Fewer prestiges = more Echoes. Optimise your run for maximum yield!
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{transcendenceCount > 0 && (
|
||||
<p>Transcendence count: <strong>{transcendenceCount}</strong></p>
|
||||
)}
|
||||
<p>Current Echoes: <strong>{formatNumber(currentEchoes)}</strong></p>
|
||||
<p>Current prestige count: <strong>{prestigeData.count}</strong></p>
|
||||
{hasDefeatedFinalBoss && (
|
||||
<p className="echo-preview">
|
||||
Echoes on transcendence: <strong>+{formatNumber(echoPreview)}</strong>
|
||||
{echoMetaMultiplier > 1 && (
|
||||
<span className="echo-meta-bonus">
|
||||
{" "}(×{echoMetaMultiplier.toFixed(2)} meta bonus applied)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasDefeatedFinalBoss && (
|
||||
<div className="transcendence-locked">
|
||||
<p>🔒 <strong>Defeat The Absolute One</strong> to unlock transcendence.</p>
|
||||
<p className="transcendence-hint">
|
||||
The Absolute One is the final boss of The Absolute zone, requiring
|
||||
Prestige 90 to challenge.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDefeatedFinalBoss && (
|
||||
<div className="prestige-form">
|
||||
<p>You are ready to transcend. This action is <strong>irreversible</strong>.</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={() => { void handleTranscend(); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<p className="success">
|
||||
Transcended! Earned{" "}
|
||||
<strong>{formatNumber(result.echoes)} Echoes</strong>. This is
|
||||
Transcendence {result.count}. A new cycle begins.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "shop" && (
|
||||
<div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
Balance: <strong>{formatNumber(currentEchoes)} Echoes</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
Echo upgrades are <strong>permanent</strong> — they survive all future
|
||||
prestiges and transcendences.
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ category, label, upgrades }) => (
|
||||
<div key={category} className="shop-category">
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = (transcendence?.purchasedUpgradeIds ?? []).includes(upgrade.id);
|
||||
const canAfford = currentEchoes >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`shop-upgrade-card echo-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{!purchased && (
|
||||
<button
|
||||
className="buy-upgrade-button echo-buy-button"
|
||||
disabled={!canAfford || isLoading || buyingId !== null}
|
||||
onClick={() => { void handleBuyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "Buying..." : "Buy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
interface UpgradeCardProps {
|
||||
upgrade: Upgrade;
|
||||
currentGold: number;
|
||||
currentEssence: number;
|
||||
currentCrystals: number;
|
||||
unlockHint?: string | undefined;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals, unlockHint, formatNumber }: UpgradeCardProps): React.JSX.Element => {
|
||||
const { buyUpgrade } = useGame();
|
||||
const canAfford =
|
||||
currentGold >= upgrade.costGold &&
|
||||
currentEssence >= upgrade.costEssence &&
|
||||
currentCrystals >= (upgrade.costCrystals ?? 0);
|
||||
|
||||
if (!upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<div className="upgrade-info">
|
||||
<h3>🔒 {upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0 && <span>🪙 {formatNumber(upgrade.costGold)}</span>}
|
||||
{upgrade.costEssence > 0 && <span>✨ {formatNumber(upgrade.costEssence)}</span>}
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>💎 {formatNumber(upgrade.costCrystals ?? 0)}</span>}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">Locked</span>
|
||||
{unlockHint && <p className="unlock-hint">{unlockHint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
<span className="upgrade-name">✅ {upgrade.name}</span>
|
||||
<span className="upgrade-desc">{upgrade.description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upgrade-card">
|
||||
<div className="upgrade-info">
|
||||
<h3>{upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0 && <span>🪙 {formatNumber(upgrade.costGold)}</span>}
|
||||
{upgrade.costEssence > 0 && <span>✨ {formatNumber(upgrade.costEssence)}</span>}
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>💎 {formatNumber(upgrade.costCrystals ?? 0)}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford}
|
||||
onClick={() => { buyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpgradePanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const purchased = state.upgrades.filter((u) => u.purchased);
|
||||
const available = state.upgrades.filter((u) => u.unlocked && !u.purchased);
|
||||
const locked = state.upgrades.filter((u) => !u.unlocked);
|
||||
|
||||
const upgradeUnlockHints = new Map<string, string>();
|
||||
for (const boss of state.bosses) {
|
||||
for (const upgradeId of (boss.upgradeRewards ?? [])) {
|
||||
upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${boss.name}`);
|
||||
}
|
||||
}
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "upgrade" && reward.targetId && !upgradeUnlockHints.has(reward.targetId)) {
|
||||
upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${quest.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel upgrade-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Upgrades</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="upgrade-progress">{purchased.length} / {state.upgrades.length} purchased</p>
|
||||
{state.upgrades.length === 0 ? (
|
||||
<p className="empty-state">No upgrades available yet — keep adventuring!</p>
|
||||
) : (
|
||||
<div className="upgrade-list">
|
||||
{available.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
{purchased.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
{showLocked && locked.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
formatNumber={formatNumber}
|
||||
unlockHint={upgradeUnlockHints.get(upgrade.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Zone } from "@elysium/types";
|
||||
|
||||
interface ZoneSelectorProps {
|
||||
zones: Zone[];
|
||||
activeZoneId: string;
|
||||
onSelectZone: (zoneId: string) => void;
|
||||
}
|
||||
|
||||
export const ZoneSelector = ({
|
||||
zones,
|
||||
activeZoneId,
|
||||
onSelectZone,
|
||||
}: ZoneSelectorProps): React.JSX.Element => (
|
||||
<div className="zone-selector">
|
||||
{zones.map((zone) => (
|
||||
<button
|
||||
key={zone.id}
|
||||
className={`zone-tab ${zone.id === activeZoneId ? "zone-tab-active" : ""}`}
|
||||
onClick={() => {
|
||||
onSelectZone(zone.id);
|
||||
}}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span className="zone-emoji">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file About panel component displaying changelog and how-to-play guide.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { getAbout } from "../../api/client.js";
|
||||
import type { AboutResponse } from "@elysium/types";
|
||||
|
||||
const howToPlay = [
|
||||
{
|
||||
body:
|
||||
"Hire adventurers to earn gold and essence automatically. Each tier is"
|
||||
+ " more powerful than the last. Adventurers also contribute combat"
|
||||
+ " power for boss fights — the more you recruit, the stronger your"
|
||||
+ " party becomes.",
|
||||
title: "⚔️ Adventurers",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Click the guild hall to earn gold manually. Upgrades and equipment can"
|
||||
+ " dramatically increase your gold per click. Clicking is especially"
|
||||
+ " powerful in the early game and when saving up for big purchases.",
|
||||
title: "👆 Clicking",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Purchase upgrades to multiply the gold and essence output of specific"
|
||||
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
||||
+ " for the current run and compound with each other.",
|
||||
title: "🔧 Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send your guild on quests that complete over time and reward gold,"
|
||||
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
||||
+ " simultaneously. Completing quests also unlocks new zones.",
|
||||
title: "📜 Quests",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Challenge zone bosses to earn large one-time rewards and unlock new"
|
||||
+ " zones. Your party's combat power is based on the number and tier of"
|
||||
+ " adventurers you've recruited. Defeated bosses cannot be re-fought,"
|
||||
+ " but undefeated bosses regenerate HP over time.",
|
||||
title: "👹 Boss Fights",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"New zones unlock when you defeat the final boss AND complete the final"
|
||||
+ " quest of the previous zone. Each zone contains new bosses and"
|
||||
+ " quests with progressively greater rewards.",
|
||||
title: "🗺️ Zones",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
||||
+ " bonuses to gold income, click power, or combat. Rarer equipment"
|
||||
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
|
||||
+ " named set) to unlock escalating set bonuses shown at the top of the"
|
||||
+ " Equipment panel.",
|
||||
title: "🗡️ Equipment & Sets",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"When you've progressed far enough, you can prestige to earn runestones"
|
||||
+ " — a permanent currency that persists across all runs. Prestige"
|
||||
+ " resets your current run but grants a production multiplier that"
|
||||
+ " stacks with every prestige.",
|
||||
title: "⭐ Prestige",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Spend runestones in the Prestige Shop on permanent upgrades that carry"
|
||||
+ " over across all future runs. These upgrades multiply income, click"
|
||||
+ " power, essence, and crystal gain — making each new run more powerful"
|
||||
+ " than the last.",
|
||||
title: "🔮 Runestones & Prestige Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Purchase the Autonomous Ascension upgrade in the Prestige Shop"
|
||||
+ " (100 runestones) to unlock the Auto-Prestige toggle. When enabled,"
|
||||
+ " you will automatically ascend the moment you reach the prestige"
|
||||
+ " threshold, using your current character name. Toggle it on and off"
|
||||
+ " freely from the Prestige Shop.",
|
||||
title: "⚙️ Auto-Prestige",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn achievements by hitting milestones — total gold earned, bosses"
|
||||
+ " defeated, quests completed, and more. Achievements are purely"
|
||||
+ " cosmetic and track your long-term progress across all prestige runs.",
|
||||
title: "🏆 Achievements",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Complete daily challenges for bonus rewards including gold, essence,"
|
||||
+ " crystals, and runestones. Challenges reset each day and vary in"
|
||||
+ " difficulty. Completing all daily challenges gives an extra bonus"
|
||||
+ " reward.",
|
||||
title: "📅 Daily Challenges",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send scouts to explore areas within each zone. Explorations run in"
|
||||
+ " real-time and reward gold, essence, and crafting materials when"
|
||||
+ " collected. Each area has a set duration — short explorations are"
|
||||
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
||||
+ " you've collected from at least once, unlocking a Codex entry.",
|
||||
title: "🗺️ Exploration",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Use materials gathered from exploration to craft permanent bonuses."
|
||||
+ " Each recipe provides a multiplier to gold income, essence income,"
|
||||
+ " click power, or combat power — all of which stack and persist across"
|
||||
+ " prestige runs. Check the Crafting tab to see your material inventory"
|
||||
+ " and available recipes per zone.",
|
||||
title: "⚗️ Crafting",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Defeating bosses, completing quests, acquiring equipment, hiring"
|
||||
+ " adventurers, purchasing upgrades, unlocking prestige upgrades,"
|
||||
+ " discovering new zones, collecting from exploration areas, and"
|
||||
+ " crafting recipes all permanently unlock lore entries in the Codex."
|
||||
+ " A badge appears on the Codex tab and a toast notification pops up"
|
||||
+ " each time new lore is discovered. Collect all 472 entries to build"
|
||||
+ " a complete picture of the world of Elysium.",
|
||||
title: "📖 Codex",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Visit the Character tab to write about your character and guild. Fill"
|
||||
+ " in your character's name, pronouns, race, class, and backstory,"
|
||||
+ " then create a guild with its own name and lore. Your character sheet"
|
||||
+ " is visible on your public profile page.",
|
||||
title: "📋 Character Sheet",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn Titles by reaching milestones — defeating bosses, completing"
|
||||
+ " quests, prestiging, and more. Once unlocked, titles are yours"
|
||||
+ " forever and are never lost on prestige or transcendence resets. Set"
|
||||
+ " your active title from the Character tab to display it on your"
|
||||
+ " character sheet and public profile.",
|
||||
title: "🏅 Titles",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
||||
+ " Each item provides bonuses to gold income, combat power, or click"
|
||||
+ " power. Only one item per slot can be equipped at a time — visit the"
|
||||
+ " Equipment panel to manage your loadout. Your currently equipped"
|
||||
+ " items are displayed on your character sheet and public profile.",
|
||||
title: "🗡️ Equipment",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Compete with other adventurers on the public Leaderboards page!"
|
||||
+ " Categories include Lifetime Gold, Bosses Defeated, Quests"
|
||||
+ " Completed, Achievements, Prestige Count, Transcendence Count, and"
|
||||
+ " Apotheosis Count. Click any player's row to view their character"
|
||||
+ " sheet. You can opt out of appearing on leaderboards via the Privacy"
|
||||
+ " section in your profile settings.",
|
||||
title: "🏆 Leaderboards",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Log in every day to earn escalating rewards! Each consecutive day"
|
||||
+ " awards more gold, and the 7th day of your streak grants bonus"
|
||||
+ " crystals. Your streak resets if you miss a day. A week multiplier"
|
||||
+ " increases all rewards the longer your overall streak runs. Your"
|
||||
+ " current streak is displayed on your character sheet.",
|
||||
title: "🔥 Daily Login Bonus",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
|
||||
+ " automatically sends your party on the highest-zone available quest"
|
||||
+ " as soon as one completes, skipping quests whose combat power"
|
||||
+ " requirement isn't met. Auto-Boss automatically challenges the"
|
||||
+ " highest available boss as soon as one is ready. Both can be toggled"
|
||||
+ " on or off at any time using the 🤖 Auto button in each panel"
|
||||
+ " header.",
|
||||
title: "🤖 Auto-Quest & Auto-Boss",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Unlock companions by reaching certain milestones across all your runs."
|
||||
+ " Each companion provides a powerful permanent bonus: increased"
|
||||
+ " passive gold, click gold, boss damage, essence income, or reduced"
|
||||
+ " quest time. You can only have one companion active at a time —"
|
||||
+ " choose wisely based on your current strategy! Companions are"
|
||||
+ " unlocked permanently once their condition is met and will never be"
|
||||
+ " lost.",
|
||||
title: "👥 Companions",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Your progress is automatically saved to the cloud every 30 seconds"
|
||||
+ " whilst you play. You can also force a manual save at any time using"
|
||||
+ " the sync button in the resource bar. Your save is protected by HMAC"
|
||||
+ " validation to ensure data integrity.",
|
||||
title: "☁️ Cloud Saves",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
||||
+ " The Absolute One (requires Prestige 90). Transcending performs a"
|
||||
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
||||
+ " and equipment — but grants Echoes based on your prestige count"
|
||||
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
||||
+ " all future resets. Spend them in the Echo Shop on lasting"
|
||||
+ " multipliers: passive income, combat power, prestige"
|
||||
+ " quality-of-life, and Echo meta upgrades that amplify future Echo"
|
||||
+ " yields.",
|
||||
title: "🌌 Transcendence",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Apotheosis is the final act — a complete dissolution of everything you"
|
||||
+ " have built, including your prestige and transcendence progress. It"
|
||||
+ " is unlocked once you have purchased every Transcendence upgrade. In"
|
||||
+ " exchange for this total reset, you receive the Apotheosis badge:"
|
||||
+ " pure bragging rights, a mark of reaching the absolute pinnacle of"
|
||||
+ " the game. Apotheosis can be achieved multiple times; each cycle"
|
||||
+ " requires purchasing all Transcendence upgrades again. Your Codex"
|
||||
+ " entries and lifetime profile statistics are always preserved.",
|
||||
title: "✨ Apotheosis",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Story tab contains 22 chapters that unlock as you progress. The"
|
||||
+ " first 18 unlock when you defeat the final boss of each zone."
|
||||
+ " Chapters 19 and 20 unlock after your first and fifth prestige"
|
||||
+ " respectively. Chapter 21 unlocks on your first transcendence, and"
|
||||
+ " Chapter 22 on your first apotheosis. Each chapter presents a"
|
||||
+ " narrative moment and three choices — the choice you make is recorded"
|
||||
+ " on your Character Sheet and shapes your guild's story. Story"
|
||||
+ " progress is permanent and survives all resets.",
|
||||
title: "📖 Story",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the about panel with changelog and how-to-play sections.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const aboutPanel = (): JSX.Element => {
|
||||
const [ about, setAbout ] = useState<AboutResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ expandedRelease, setExpandedRelease ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAbout().
|
||||
then(setAbout).
|
||||
catch((caughtError: unknown) => {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to load about data.",
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="panel about-panel">
|
||||
<h2>{"ℹ️ About"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"📋 Changelog"}</h3>
|
||||
{error !== null && <p className="about-error">{error}</p>}
|
||||
{about === null && error === null
|
||||
&& <p className="about-loading">{"Loading changelog..."}</p>
|
||||
}
|
||||
{about !== null && about.releases.length === 0
|
||||
&& <p className="about-empty">{"No releases yet."}</p>
|
||||
}
|
||||
{about !== null && about.releases.length > 0
|
||||
&& <ul className="about-releases">
|
||||
{about.releases.map((release) => {
|
||||
function handleToggle(): void {
|
||||
setExpandedRelease(
|
||||
expandedRelease === release.tag_name
|
||||
? null
|
||||
: release.tag_name,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className="about-release" key={release.tag_name}>
|
||||
<button
|
||||
className="about-release-header"
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="about-release-tag">
|
||||
{release.name.length > 0
|
||||
? release.name
|
||||
: release.tag_name}
|
||||
</span>
|
||||
<span className="about-release-date">
|
||||
{formatDate(release.published_at)}
|
||||
</span>
|
||||
<span className="about-release-chevron">
|
||||
{expandedRelease === release.tag_name
|
||||
? "▲"
|
||||
: "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRelease === release.tag_name
|
||||
&& <pre className="about-release-body">{release.body}</pre>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<h3 className="stats-section-header">{"📖 How to Play"}</h3>
|
||||
<ul className="about-how-to-play">
|
||||
{howToPlay.map((section) => {
|
||||
return (
|
||||
<li className="about-htp-section" key={section.title}>
|
||||
<h4 className="about-htp-title">{section.title}</h4>
|
||||
<p className="about-htp-body">{section.body}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { aboutPanel as AboutPanel };
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file Achievement panel component displaying all game achievements.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Returns the plural form of a word based on a count.
|
||||
* @param count - The count to check.
|
||||
* @param word - The base word to pluralise.
|
||||
* @returns The pluralised word string.
|
||||
*/
|
||||
const pluralise = (count: number, word: string): string => {
|
||||
return count > 1
|
||||
? `${word}s`
|
||||
: word;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable condition description for an achievement.
|
||||
* @param achievement - The achievement to describe.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns A string describing the achievement condition.
|
||||
*/
|
||||
const conditionDescription = (
|
||||
achievement: Achievement,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total gold`;
|
||||
case "totalClicks":
|
||||
return `Click ${formatNumber(condition.amount)} times`;
|
||||
case "bossesDefeated":
|
||||
return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`;
|
||||
case "questsCompleted":
|
||||
return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`;
|
||||
case "adventurerTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
|
||||
case "prestigeCount":
|
||||
return `Prestige ${String(condition.amount)} ${pluralise(condition.amount, "time")}`;
|
||||
case "equipmentOwned":
|
||||
return `Own ${String(condition.amount)} equipment ${pluralise(condition.amount, "item")}`;
|
||||
default:
|
||||
return "Unknown condition";
|
||||
}
|
||||
};
|
||||
|
||||
interface AchievementCardProperties {
|
||||
readonly achievement: Achievement;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single achievement card.
|
||||
* @param props - The achievement card properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AchievementCard = ({
|
||||
achievement,
|
||||
formatNumber,
|
||||
}: AchievementCardProperties): JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
const crystals = achievement.reward?.crystals;
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">
|
||||
{conditionDescription(achievement, formatNumber)}
|
||||
</p>
|
||||
{crystals !== undefined
|
||||
&& <p className="achievement-reward">
|
||||
{"💎 +"}
|
||||
{crystals}
|
||||
{" Crystals"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the achievement panel with all achievements.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- Achievement panel renders many achievement states
|
||||
const AchievementPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const achievementList = state.achievements;
|
||||
const unlocked = achievementList.filter((a) => {
|
||||
return a.unlockedAt !== null;
|
||||
});
|
||||
const locked = achievementList.filter((a) => {
|
||||
return a.unlockedAt === null;
|
||||
});
|
||||
const visible = showLocked
|
||||
? achievementList
|
||||
: unlocked;
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Achievements"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length}
|
||||
{" / "}
|
||||
{achievementList.length}
|
||||
{" unlocked"}
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => {
|
||||
return (
|
||||
<AchievementCard
|
||||
achievement={achievement}
|
||||
formatNumber={formatNumber}
|
||||
key={achievement.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AchievementPanel };
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Achievement toast notification component.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
interface ToastItemProperties {
|
||||
readonly achievement: Achievement;
|
||||
readonly onDismiss: (id: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single achievement toast item.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.onDismiss - Callback to dismiss the toast.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ToastItem = ({
|
||||
achievement,
|
||||
onDismiss,
|
||||
}: ToastItemProperties): JSX.Element => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(achievement.id);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ achievement.id, onDismiss ]);
|
||||
|
||||
function handleClick(): void {
|
||||
onDismiss(achievement.id);
|
||||
}
|
||||
|
||||
const crystals = achievement.reward?.crystals;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast" onClick={handleClick}>
|
||||
<span className="toast-icon">{achievement.icon}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">{"Achievement Unlocked!"}</span>
|
||||
<span className="toast-name">{achievement.name}</span>
|
||||
{crystals !== undefined
|
||||
&& <span className="toast-reward">
|
||||
{"💎 +"}
|
||||
{crystals}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the achievement toast container with pending achievement notifications.
|
||||
* @returns The JSX element or null if there are no pending achievements.
|
||||
*/
|
||||
const AchievementToast = (): JSX.Element | null => {
|
||||
const { unlockedAchievements: pendingAchievements, dismissAchievement }
|
||||
= useGame();
|
||||
|
||||
if (pendingAchievements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingAchievements.map((achievement) => {
|
||||
return (
|
||||
<ToastItem
|
||||
achievement={achievement}
|
||||
key={achievement.id}
|
||||
onDismiss={dismissAchievement}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AchievementToast };
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file Adventurer panel component for hiring and managing adventurers.
|
||||
* @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 component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
const iconByClass: Record<string, string> = {
|
||||
cleric: "✝️",
|
||||
mage: "🔮",
|
||||
paladin: "🛡️",
|
||||
ranger: "🏹",
|
||||
rogue: "🗝️",
|
||||
warrior: "🗡️",
|
||||
};
|
||||
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||
|
||||
/**
|
||||
* Computes the total cost to buy a batch of adventurers.
|
||||
* @param adventurer - The adventurer to buy.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total gold cost.
|
||||
*/
|
||||
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of adventurers affordable with given gold.
|
||||
* @param adventurer - The adventurer type.
|
||||
* @param gold - The available gold.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
if (total + cost > gold) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface AdventurerCardProperties {
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single adventurer card with buy controls.
|
||||
* @param props - The adventurer card properties.
|
||||
* @param props.adventurer - The adventurer data.
|
||||
* @param props.currentGold - The current gold available.
|
||||
* @param props.batchSize - The selected batch size.
|
||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerCard = ({
|
||||
adventurer,
|
||||
currentGold,
|
||||
batchSize,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: AdventurerCardProperties): JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
const resolvedQuantity
|
||||
= batchSize === "max"
|
||||
? computeMaxAffordable(adventurer, currentGold)
|
||||
: batchSize;
|
||||
const cost = computeBatchCost(adventurer, resolvedQuantity);
|
||||
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyAdventurer(adventurer.id, resolvedQuantity);
|
||||
}
|
||||
|
||||
const maxSuffix
|
||||
= batchSize === "max" && resolvedQuantity > 0
|
||||
? ` (×${String(resolvedQuantity)})`
|
||||
: "";
|
||||
const buttonLabel = adventurer.unlocked
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||
: "🔒 Locked";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${adventurer.unlocked
|
||||
? ""
|
||||
: "locked"}`}>
|
||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
{formatNumber(adventurer.goldPerSecond)}
|
||||
{" gold/s each"}
|
||||
</p>
|
||||
{adventurer.essencePerSecond > 0
|
||||
&& <p>
|
||||
{formatNumber(adventurer.essencePerSecond)}
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="adventurer-count">
|
||||
{"×"}
|
||||
{adventurer.count}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the adventurer panel with all available adventurers.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const locked = state.adventurers.filter((adventurer) => {
|
||||
return !adventurer.unlocked;
|
||||
});
|
||||
const visible = showLocked
|
||||
? state.adventurers
|
||||
: state.adventurers.filter((adventurer) => {
|
||||
return adventurer.unlocked;
|
||||
});
|
||||
|
||||
const adventurerUnlockHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
||||
adventurerUnlockHints.set(reward.targetId, quest.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Adventurers"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((option) => {
|
||||
function handleBatchSelect(): void {
|
||||
setBatchSize(option);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`batch-button ${batchSize === option
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={option}
|
||||
onClick={handleBatchSelect}
|
||||
type="button"
|
||||
>
|
||||
{option === "max"
|
||||
? "xMax"
|
||||
: `x${String(option)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="adventurer-list">
|
||||
{visible.map((adventurer) => {
|
||||
return (
|
||||
<AdventurerCard
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={adventurer.id}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AdventurerPanel };
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file Apotheosis panel component for the final prestige layer.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { TRANSCENDENCE_UPGRADES } from "../../data/transcendenceUpgrades.js";
|
||||
|
||||
const totalEchoUpgrades = TRANSCENDENCE_UPGRADES.length;
|
||||
|
||||
/**
|
||||
* Renders the apotheosis panel for achieving the final game milestone.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ApotheosisPanel = (): JSX.Element => {
|
||||
const { state, apotheosis } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<number | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
|
||||
const purchasedCount = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return purchasedIds.includes(upgrade.id);
|
||||
}).length;
|
||||
const isEligible = purchasedCount >= totalEchoUpgrades;
|
||||
const apotheosisCount = state.apotheosis?.count ?? 0;
|
||||
|
||||
async function handleApotheosis(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apotheosis();
|
||||
setResult(data.newApotheosisCount);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Apotheosis failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApotheosisClick(): void {
|
||||
void handleApotheosis();
|
||||
}
|
||||
|
||||
const plural = apotheosisCount === 1
|
||||
? ""
|
||||
: "s";
|
||||
|
||||
return (
|
||||
<section className="panel apotheosis-panel">
|
||||
<h2>{"✨ Apotheosis"}</h2>
|
||||
|
||||
<p className="apotheosis-intro">
|
||||
{"Apotheosis is the final act — a complete dissolution of everything"
|
||||
+ " you have built. Prestige, Transcendence, Echoes, upgrades,"
|
||||
+ " equipment, resources: all of it returns to nothing."
|
||||
+ " In exchange, you receive only one thing:"}
|
||||
</p>
|
||||
<p className="apotheosis-reward">
|
||||
{"The "}
|
||||
<strong>{"✨ Apotheosis"}</strong>
|
||||
{" badge. Proof that you have done it all."}
|
||||
</p>
|
||||
<p className="apotheosis-intro">
|
||||
{"Apotheosis can be achieved multiple times. Each cycle requires"
|
||||
+ " you to purchase every Transcendence upgrade again before the"
|
||||
+ " next Apotheosis becomes available. There is no mechanical"
|
||||
+ " benefit — only the knowledge that you have reached the"
|
||||
+ " pinnacle, dissolved it, and climbed back up."}
|
||||
</p>
|
||||
|
||||
{apotheosisCount > 0
|
||||
&& <div className="apotheosis-count">
|
||||
<span>
|
||||
{"You have achieved Apotheosis "}
|
||||
<strong>{apotheosisCount}</strong>
|
||||
{" time"}
|
||||
{plural}
|
||||
{"."}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="apotheosis-status">
|
||||
<p>
|
||||
{"Transcendence upgrades purchased: "}
|
||||
<strong>
|
||||
{purchasedCount}
|
||||
{" / "}
|
||||
{totalEchoUpgrades}
|
||||
</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? null
|
||||
: <p className="apotheosis-missing">
|
||||
{"🔒 Purchase all "}
|
||||
{totalEchoUpgrades}
|
||||
{" Transcendence upgrades to unlock Apotheosis. ("}
|
||||
{totalEchoUpgrades - purchasedCount}
|
||||
{" remaining)"}
|
||||
</p>
|
||||
}
|
||||
{isEligible
|
||||
? <p className="apotheosis-ready">
|
||||
{"✅ All Transcendence upgrades purchased. You are ready."}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"This action is "}
|
||||
<strong>{"permanent and irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="apotheosis-button"
|
||||
disabled={isPending}
|
||||
onClick={handleApotheosisClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: "✨ Achieve Apotheosis"}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result !== null
|
||||
&& <p className="success">
|
||||
{"Apotheosis achieved. This is cycle "}
|
||||
<strong>{result}</strong>
|
||||
{". The infinite loop continues."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ApotheosisPanel };
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file Battle modal component displaying animated battle results.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex battle animation and result display */
|
||||
/* eslint-disable complexity -- Battle result display requires many conditional paths */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { type BattleResult, useGame } from "../../context/gameContext.js";
|
||||
|
||||
/**
|
||||
* Converts HP values to a percentage for display.
|
||||
* @param current - The current HP value.
|
||||
* @param maximum - The maximum HP value.
|
||||
* @returns The percentage as a number between 0 and 100.
|
||||
*/
|
||||
const toHpPercent = (current: number, maximum: number): number => {
|
||||
if (maximum === 0) {
|
||||
return 0;
|
||||
}
|
||||
const scaled = current * 100;
|
||||
return scaled / maximum;
|
||||
};
|
||||
|
||||
interface BattleModalProperties {
|
||||
readonly battle: BattleResult;
|
||||
readonly onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the battle modal with HP bars and animated battle results.
|
||||
* @param props - The battle modal properties.
|
||||
* @param props.battle - The battle result data to display.
|
||||
* @param props.onDismiss - Callback to dismiss the modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BattleModal = ({
|
||||
battle,
|
||||
onDismiss,
|
||||
}: BattleModalProperties): JSX.Element => {
|
||||
const { result, bossName } = battle;
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
||||
|
||||
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
|
||||
const partyStartPercent = 100;
|
||||
|
||||
const bossEndPercent = toHpPercent(
|
||||
result.bossHpAtBattleEnd,
|
||||
result.bossMaxHp,
|
||||
);
|
||||
const partyEndPercent = toHpPercent(
|
||||
result.partyHpRemaining,
|
||||
result.partyMaxHp,
|
||||
);
|
||||
|
||||
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
|
||||
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent);
|
||||
|
||||
useEffect(() => {
|
||||
const startAnimation = setTimeout(() => {
|
||||
setBossHpPercent(bossEndPercent);
|
||||
setPartyHpPercent(partyEndPercent);
|
||||
}, 200);
|
||||
|
||||
const revealResult = setTimeout(() => {
|
||||
setPhase("result");
|
||||
}, 5200);
|
||||
|
||||
return (): void => {
|
||||
clearTimeout(startAnimation);
|
||||
clearTimeout(revealResult);
|
||||
};
|
||||
}, [ bossEndPercent, partyEndPercent ]);
|
||||
|
||||
let bossHpBarColour = "#c0392b";
|
||||
if (bossHpPercent > 50) {
|
||||
bossHpBarColour = "#e74c3c";
|
||||
} else if (bossHpPercent > 25) {
|
||||
bossHpBarColour = "#e67e22";
|
||||
}
|
||||
|
||||
let partyHpBarColour = "#e74c3c";
|
||||
if (partyHpPercent > 50) {
|
||||
partyHpBarColour = "#27ae60";
|
||||
} else if (partyHpPercent > 25) {
|
||||
partyHpBarColour = "#f39c12";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal battle-modal">
|
||||
<h2>
|
||||
{"⚔️ Battle: "}
|
||||
{bossName}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"Your Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat-divider">{"vs"}</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="battle-bars">
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">
|
||||
{"👹 "}
|
||||
{bossName}
|
||||
</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill"
|
||||
style={{
|
||||
backgroundColor: bossHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
width: `${bossHpPercent.toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.bossHpAtBattleEnd)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vs-divider">{"⚔️ VS ⚔️"}</div>
|
||||
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">{"🛡️ Your Party"}</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill party-hp"
|
||||
style={{
|
||||
backgroundColor: partyHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
width: `${partyHpPercent.toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === "animating"
|
||||
&& <p className="battle-in-progress">{"Battling…"}</p>
|
||||
}
|
||||
|
||||
{phase === "result"
|
||||
&& <div
|
||||
className={`battle-outcome ${result.won
|
||||
? "victory"
|
||||
: "defeat"}`}
|
||||
>
|
||||
{result.won
|
||||
? <>
|
||||
<h3>{"🏆 Victory!"}</h3>
|
||||
{result.rewards === undefined
|
||||
? null
|
||||
: <div className="battle-rewards">
|
||||
<p>{"Rewards:"}</p>
|
||||
<span>
|
||||
{"🪙 "}
|
||||
{formatNumber(result.rewards.gold)}
|
||||
{" gold"}
|
||||
</span>
|
||||
{result.rewards.essence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(result.rewards.essence)}
|
||||
{" essence"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.crystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(result.rewards.crystals)}
|
||||
{" crystals"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.bountyRunestones > 0
|
||||
&& <span className="battle-bounty">
|
||||
{"🔮 "}
|
||||
{formatNumber(result.rewards.bountyRunestones)}
|
||||
{" runestones (first kill!)"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <>
|
||||
<h3>{"💀 Defeat"}</h3>
|
||||
<p>{"Your party was defeated. The boss has reset."}</p>
|
||||
{result.casualties !== undefined
|
||||
&& result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<p>{"Casualties:"}</p>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<span key={casualty.adventurerId}>
|
||||
{"☠️ "}
|
||||
{casualty.killed} {casualty.adventurerId}
|
||||
{" lost"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Continue"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BattleModal };
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file Boss panel component for viewing and challenging zone bosses.
|
||||
* @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 component with many render paths */
|
||||
/* eslint-disable complexity -- Boss card requires many conditional render paths */
|
||||
/* eslint-disable max-statements -- Boss panel requires many variable declarations */
|
||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Boss, GameState } from "@elysium/types";
|
||||
|
||||
interface BossCardProperties {
|
||||
readonly boss: Boss;
|
||||
readonly prestigeCount: number;
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single boss card.
|
||||
* @param props - The boss card properties.
|
||||
* @param props.boss - The boss data.
|
||||
* @param props.prestigeCount - The current prestige count for lock checking.
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BossCard = ({
|
||||
boss,
|
||||
prestigeCount,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: BossCardProperties): JSX.Element => {
|
||||
const scaled = boss.currentHp * 100;
|
||||
const hpPercent = scaled / boss.maxHp;
|
||||
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
||||
const canChallenge
|
||||
= (boss.status === "available" || boss.status === "in_progress")
|
||||
&& !isChallenging;
|
||||
|
||||
function handleChallenge(): void {
|
||||
onChallenge(boss.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{isPrestigeLocked && boss.status === "locked"
|
||||
? <p className="prestige-lock">
|
||||
{"🔒 Requires Prestige "}
|
||||
{boss.prestigeRequirement}
|
||||
</p>
|
||||
: null}
|
||||
{!isPrestigeLocked
|
||||
&& boss.status === "locked"
|
||||
&& unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
&& <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
<span>
|
||||
{"🪙 "}
|
||||
{formatNumber(boss.goldReward)}
|
||||
</span>
|
||||
{boss.essenceReward > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(boss.essenceReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.crystalReward > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(boss.crystalReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🗡️ "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
||||
&& <span className="boss-bounty">
|
||||
{"🔮 "}
|
||||
{boss.bountyRunestones}
|
||||
{" (first kill)"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{(boss.status === "available" || boss.status === "in_progress")
|
||||
&& <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "⚔️ Battling…"
|
||||
: "⚔️ Challenge"}
|
||||
</button>
|
||||
}
|
||||
|
||||
{boss.status === "defeated"
|
||||
&& <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BossPanel = (): JSX.Element => {
|
||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChallengeClick(bossId: string): void {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
const lockedCount = zoneBosses.filter((boss) => {
|
||||
return boss.status === "locked";
|
||||
}).length;
|
||||
const visibleBosses = showLocked
|
||||
? zoneBosses
|
||||
: zoneBosses.filter((boss) => {
|
||||
return boss.status !== "locked";
|
||||
});
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
||||
const allZoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === zoneId;
|
||||
});
|
||||
for (let index = 0; index < allZoneBosses.length; index = index + 1) {
|
||||
const boss = allZoneBosses[index];
|
||||
if (boss === undefined || boss.status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
if (index === 0) {
|
||||
const parts: Array<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = allZoneBosses[index - 1];
|
||||
if (previousBoss !== undefined) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
const autoBossOn = autoBoss === true;
|
||||
const { partyDps, partyHp } = computePartyStats(state);
|
||||
const { count: prestigeCount } = playerPrestige;
|
||||
|
||||
return (
|
||||
<section className="panel boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Boss Encounters"}</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${
|
||||
autoBossOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={toggleAutoBoss}
|
||||
title="Automatically challenge the highest available boss"
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoBossOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(partyDps)}</span>
|
||||
</div>
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"❤️ Party HP"}</span>
|
||||
<span className="stat-value">{formatNumber(partyHp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boss-list">
|
||||
{visibleBosses.map((boss) => {
|
||||
const { id: bossId } = boss;
|
||||
return (
|
||||
<BossCard
|
||||
boss={boss}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === bossId}
|
||||
key={bossId}
|
||||
onChallenge={handleChallengeClick}
|
||||
prestigeCount={prestigeCount}
|
||||
unlockHint={bossUnlockHints.get(bossId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visibleBosses.length === 0
|
||||
&& <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { BossPanel };
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @file Public character page for viewing a player's character sheet.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import type {
|
||||
EquipmentBonus,
|
||||
EquipmentType,
|
||||
PublicProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
interface CharacterPageProperties {
|
||||
readonly discordId: string;
|
||||
}
|
||||
|
||||
const slotIcons: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment bonus as a human-readable string.
|
||||
* @param bonus - The equipment bonus to format.
|
||||
* @returns The formatted bonus string.
|
||||
*/
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the public character page for a given Discord user.
|
||||
* @param props - The character page properties.
|
||||
* @param props.discordId - The Discord ID of the player to display.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
||||
const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Player not found");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response requires cast
|
||||
return await (response.json() as Promise<PublicProfileResponse>);
|
||||
}).
|
||||
then(setProfile).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load character sheet",
|
||||
);
|
||||
});
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-error">
|
||||
<p>
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</p>
|
||||
<a className="character-page-link" href="/">
|
||||
{"← Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile === null) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-loading">
|
||||
{"Loading character sheet…"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const discordIndex = Number.parseInt(discordId, 10) % 5;
|
||||
const avatarUrl
|
||||
= profile.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(discordIndex)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
|
||||
|
||||
const subtitleParts = [
|
||||
profile.characterRace,
|
||||
profile.characterClass,
|
||||
].filter((part) => {
|
||||
return part !== "";
|
||||
});
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const activeTitleEntry
|
||||
= profile.activeTitle === ""
|
||||
? undefined
|
||||
: profile.unlockedTitles.find((title) => {
|
||||
return title.id === profile.activeTitle;
|
||||
});
|
||||
const activeTitleName
|
||||
= activeTitleEntry === undefined
|
||||
? null
|
||||
: activeTitleEntry.name;
|
||||
|
||||
const hasBadge
|
||||
= profile.apotheosisCount > 0
|
||||
|| profile.transcendenceCount > 0
|
||||
|| profile.prestigeCount > 0;
|
||||
|
||||
const displayName
|
||||
= profile.characterName === ""
|
||||
? profile.username
|
||||
: profile.characterName;
|
||||
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-card">
|
||||
<div className="character-page-header">
|
||||
<img
|
||||
alt={`${displayName}'s avatar`}
|
||||
className="character-page-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="character-page-identity">
|
||||
<h1 className="character-page-name">{displayName}</h1>
|
||||
{activeTitleName === null
|
||||
? null
|
||||
: <p className="character-page-title">{activeTitleName}</p>
|
||||
}
|
||||
{profile.pronouns === ""
|
||||
? null
|
||||
: <p className="character-page-pronouns">{profile.pronouns}</p>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <p className="character-page-subtitle">{subtitle}</p>
|
||||
}
|
||||
{hasBadge
|
||||
? <div className="character-page-badges">
|
||||
{profile.apotheosisCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge character-page-badge--apotheosis"
|
||||
}
|
||||
>
|
||||
{"✨ Apotheosis "}
|
||||
{profile.apotheosisCount}
|
||||
</span>
|
||||
}
|
||||
{profile.transcendenceCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge"
|
||||
+ " character-page-badge--transcendence"
|
||||
}
|
||||
>
|
||||
{"🌌 Transcendence "}
|
||||
{profile.transcendenceCount}
|
||||
</span>
|
||||
}
|
||||
{profile.prestigeCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge character-page-badge--prestige"
|
||||
}
|
||||
>
|
||||
{"⭐ Prestige "}
|
||||
{profile.prestigeCount}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio === ""
|
||||
? null
|
||||
: <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"⚔️ About"}</h2>
|
||||
<p className="character-page-bio">{profile.bio}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{profile.guildName === ""
|
||||
? null
|
||||
: <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"🏰 Guild"}</h2>
|
||||
<p className="character-page-guild-name">{profile.guildName}</p>
|
||||
{profile.guildDescription === ""
|
||||
? null
|
||||
: <p className="character-page-guild-desc">
|
||||
{profile.guildDescription}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{profile.equippedItems.length > 0
|
||||
&& <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"🗡️ Equipment"}</h2>
|
||||
<div className="character-page-equipment-list">
|
||||
{profile.equippedItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="character-page-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-page-equipment-header">
|
||||
<span className="character-page-equipment-slot">
|
||||
{slotIcons[item.type]}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-page-equipment-name"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-page-equipment-rarity"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-page-equipment-bonus">
|
||||
{formatBonus(item.bonus)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="character-page-divider" />
|
||||
|
||||
<p className="character-page-player-line">
|
||||
{"Played by "}
|
||||
<span className="character-page-username">
|
||||
{"@"}
|
||||
{profile.username}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="character-page-actions">
|
||||
<button
|
||||
className="character-page-share-btn"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share Character"}
|
||||
</button>
|
||||
<a
|
||||
className="character-page-profile-link"
|
||||
href={`/profile/${discordId}`}
|
||||
>
|
||||
{"📊 View Stats"}
|
||||
</a>
|
||||
<a className="character-page-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterPage };
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* @file Character sheet panel for viewing and editing the player's character.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
/* eslint-disable max-statements -- Component requires many state declarations */
|
||||
/* eslint-disable max-lines -- Large component with editing and view modes */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
STORY_CHAPTERS,
|
||||
type EquipmentBonus,
|
||||
type EquipmentRarity,
|
||||
type EquipmentType,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface EquippedItem {
|
||||
name: string;
|
||||
type: EquipmentType;
|
||||
rarity: EquipmentRarity;
|
||||
bonus: EquipmentBonus;
|
||||
}
|
||||
|
||||
interface CharacterSheetData {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
}
|
||||
|
||||
const emptySheet: CharacterSheetData = {
|
||||
activeTitle: "",
|
||||
bio: "",
|
||||
characterClass: "",
|
||||
characterName: "",
|
||||
characterRace: "",
|
||||
equippedItems: [],
|
||||
guildDescription: "",
|
||||
guildName: "",
|
||||
pronouns: "",
|
||||
unlockedTitles: [],
|
||||
};
|
||||
|
||||
const slotIcons: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment bonus as a human-readable string.
|
||||
* @param bonus - The equipment bonus to format.
|
||||
* @returns The formatted bonus string.
|
||||
*/
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the character sheet panel for viewing and editing player profile.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterSheetPanel = (): JSX.Element => {
|
||||
const { state, loginStreak } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ editing, setEditing ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
const savedSettingsReference = useRef<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
profileSettings: ProfileSettings;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
};
|
||||
const loaded: CharacterSheetData = {
|
||||
activeTitle: data.activeTitle,
|
||||
bio: data.bio,
|
||||
characterClass: data.characterClass,
|
||||
characterName: data.characterName,
|
||||
characterRace: data.characterRace,
|
||||
equippedItems: data.equippedItems,
|
||||
guildDescription: data.guildDescription,
|
||||
guildName: data.guildName,
|
||||
pronouns: data.pronouns,
|
||||
unlockedTitles: data.unlockedTitles,
|
||||
};
|
||||
setSheet(loaded);
|
||||
setDraft(loaded);
|
||||
savedSettingsReference.current = {
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
};
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to empty */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ player?.discordId ]);
|
||||
|
||||
function handleEdit(): void {
|
||||
setDraft({ ...sheet });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const characterName
|
||||
= draft.characterName === ""
|
||||
? player?.characterName ?? ""
|
||||
: draft.characterName;
|
||||
await updateProfile({
|
||||
activeTitle: draft.activeTitle,
|
||||
bio: draft.bio,
|
||||
characterClass: draft.characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: draft.characterRace,
|
||||
guildDescription: draft.guildDescription,
|
||||
guildName: draft.guildName,
|
||||
profileSettings: savedSettingsReference.current,
|
||||
pronouns: draft.pronouns,
|
||||
});
|
||||
setSheet({ ...draft });
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setEditing(false);
|
||||
setSaved(false);
|
||||
}, 900);
|
||||
} catch (error_) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function handleShareClick(): void {
|
||||
const discordId = player?.discordId ?? "";
|
||||
const url = `${window.location.origin}/character/${discordId}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, pronouns: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterRace: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterClass: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, bio: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, activeTitle: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildDescChange(
|
||||
event: ChangeEvent<HTMLTextAreaElement>,
|
||||
): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildDescription: value };
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading character sheet…"}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
const isSaveDisabled = saving || draft.characterName.trim() === "";
|
||||
let saveLabel = "Save";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-form">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-name">
|
||||
{"Character Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={draft.characterName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-pronouns">
|
||||
{"Pronouns"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-pronouns"
|
||||
maxLength={20}
|
||||
onChange={handlePronounsChange}
|
||||
placeholder="e.g. she/her, he/him, they/them"
|
||||
type="text"
|
||||
value={draft.pronouns}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.pronouns.length}
|
||||
{" / 20"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-race">
|
||||
{"Race"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-race"
|
||||
maxLength={32}
|
||||
onChange={handleRaceChange}
|
||||
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
|
||||
type="text"
|
||||
value={draft.characterRace}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterRace.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-class">
|
||||
{"Class"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-class"
|
||||
maxLength={32}
|
||||
onChange={handleClassChange}
|
||||
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
|
||||
type="text"
|
||||
value={draft.characterClass}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterClass.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-bio">
|
||||
{"About Your Character"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder={
|
||||
"Describe your character's story, personality, or appearance…"
|
||||
}
|
||||
rows={4}
|
||||
value={draft.bio}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
{draft.unlockedTitles.length > 0
|
||||
&& <>
|
||||
<label className="character-sheet-label" htmlFor="cs-title">
|
||||
{"Active Title"}
|
||||
</label>
|
||||
<select
|
||||
className="character-sheet-input"
|
||||
id="cs-title"
|
||||
onChange={handleTitleChange}
|
||||
value={draft.activeTitle}
|
||||
>
|
||||
<option value="">{"— None —"}</option>
|
||||
{draft.unlockedTitles.map((title) => {
|
||||
return (
|
||||
<option key={title.id} value={title.id}>
|
||||
{title.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-name">
|
||||
{"Guild Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-guild-name"
|
||||
maxLength={64}
|
||||
onChange={handleGuildNameChange}
|
||||
placeholder="Name your guild"
|
||||
type="text"
|
||||
value={draft.guildName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildName.length}
|
||||
{" / 64"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-desc">
|
||||
{"Guild Description"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-guild-desc"
|
||||
maxLength={500}
|
||||
onChange={handleGuildDescChange}
|
||||
placeholder="Describe your guild's history, goals, or lore…"
|
||||
rows={6}
|
||||
value={draft.guildDescription}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildDescription.length}
|
||||
{" / 500"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="character-sheet-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="character-sheet-actions">
|
||||
<button
|
||||
className="character-sheet-cancel"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="character-sheet-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
|
||||
(part) => {
|
||||
return part !== "";
|
||||
},
|
||||
);
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
<div className="character-sheet-header-actions">
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleShareClick}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share"}
|
||||
</button>
|
||||
<a className="character-sheet-edit-btn" href="/leaderboards">
|
||||
{"🏆 Boards"}
|
||||
</a>
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
{"✏️ Edit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-view">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.characterName === ""
|
||||
? <em className="character-sheet-empty">{"Not set"}</em>
|
||||
: sheet.characterName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Streak"}</span>
|
||||
<span className="character-sheet-streak">
|
||||
{"🔥 "}
|
||||
{loginStreak}
|
||||
{"-day login streak"}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.activeTitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Title"}</span>
|
||||
<span
|
||||
className={"character-sheet-field-value character-sheet-title"}
|
||||
>
|
||||
{sheet.unlockedTitles.find((title) => {
|
||||
return title.id === sheet.activeTitle;
|
||||
})?.name ?? sheet.activeTitle}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.pronouns === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Pronouns"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.pronouns}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Identity"}</span>
|
||||
<span className="character-sheet-field-value">{subtitle}</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.bio === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"About"}</span>
|
||||
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
|
||||
{sheet.equippedItems.length > 0
|
||||
? <div className="character-sheet-equipment-list">
|
||||
{sheet.equippedItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-sheet-equipment-header">
|
||||
<span className="character-sheet-equipment-slot">
|
||||
{slotIcons[item.type]}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-sheet-equipment-name"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-sheet-equipment-rarity"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-sheet-equipment-bonus">
|
||||
{formatBonus(item.bonus)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: <p className="character-sheet-empty">
|
||||
{"No equipment found. Defeat bosses to earn gear!"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
{sheet.guildName === ""
|
||||
? <p className="character-sheet-empty">
|
||||
{"No guild registered yet. Click ✏️ Edit to add one!"}
|
||||
</p>
|
||||
: <>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.guildName}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.guildDescription === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"Lore"}</span>
|
||||
<p className="character-sheet-bio-text">
|
||||
{sheet.guildDescription}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
{completedChapters.length === 0
|
||||
? null
|
||||
: <div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">
|
||||
{"📖 Story Choices"}
|
||||
</h3>
|
||||
{completedChapters.map((completion) => {
|
||||
const chapter = STORY_CHAPTERS.find((candidate) => {
|
||||
return candidate.id === completion.chapterId;
|
||||
});
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
const choice = chapter.choices.find((candidate) => {
|
||||
return candidate.id === completion.choiceId;
|
||||
});
|
||||
if (choice === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-story-entry"
|
||||
key={completion.chapterId}
|
||||
>
|
||||
<span className="character-sheet-story-chapter">
|
||||
{chapter.title}
|
||||
</span>
|
||||
<span className="character-sheet-story-choice">
|
||||
{choice.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterSheetPanel };
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Click area component - the main guild hall click target.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex useCallback with float management */
|
||||
import {
|
||||
type JSX,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { calculateClickPower } from "../../engine/tick.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle -- Vite define constant
|
||||
declare const __WEB_VERSION__: string;
|
||||
|
||||
interface FloatText {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the guild hall click area with floating gold text on click.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ClickArea = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
handleClick,
|
||||
formatNumber,
|
||||
saveSchemaVersion,
|
||||
currentSchemaVersion,
|
||||
} = useGame();
|
||||
const [ floats, setFloats ] = useState<Array<FloatText>>([]);
|
||||
const nextIdReference = useRef(0);
|
||||
|
||||
const handleClickWithFloat = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (state === null) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const id = nextIdReference.current;
|
||||
nextIdReference.current = nextIdReference.current + 1;
|
||||
const clickPower = calculateClickPower(state);
|
||||
const text = `+${formatNumber(clickPower)}`;
|
||||
|
||||
setFloats((previous) => {
|
||||
return [ ...previous, { id, text, x, y } ];
|
||||
});
|
||||
handleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout
|
||||
setFloats((previous) => {
|
||||
// eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout
|
||||
return previous.filter((floatItem) => {
|
||||
return floatItem.id !== id;
|
||||
});
|
||||
});
|
||||
}, 900);
|
||||
},
|
||||
[ state, handleClick, formatNumber ],
|
||||
);
|
||||
|
||||
if (state === null) {
|
||||
return <div className="click-area-placeholder" />;
|
||||
}
|
||||
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
return (
|
||||
<section className="click-area">
|
||||
<h1 className="game-title">{"Elysium"}</h1>
|
||||
<p className="game-version">
|
||||
{"v"}
|
||||
{__WEB_VERSION__}
|
||||
</p>
|
||||
{currentSchemaVersion > 0
|
||||
&& <p className="game-schema-version">
|
||||
{"Save: v"}
|
||||
{saveSchemaVersion}
|
||||
{" / Latest: v"}
|
||||
{currentSchemaVersion}
|
||||
</p>
|
||||
}
|
||||
<h2>{"Guild Hall"}</h2>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
|
||||
className="click-button"
|
||||
onClick={handleClickWithFloat}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt="Guild Hall"
|
||||
className="click-button-image"
|
||||
src="https://cdn.nhcarrigan.com/avatars/elysium.png"
|
||||
/>
|
||||
</button>
|
||||
{floats.map((floatItem) => {
|
||||
return (
|
||||
<span
|
||||
className="click-float"
|
||||
key={floatItem.id}
|
||||
style={{ left: floatItem.x, top: floatItem.y }}
|
||||
>
|
||||
{floatItem.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="click-power">
|
||||
{"+"}
|
||||
{formatNumber(clickPower)}
|
||||
{" gold/click"}
|
||||
</p>
|
||||
<p className="early-access-notice">
|
||||
{"⚠️ Early Access — this build is subject to change. "}
|
||||
<strong>
|
||||
{"All game progress WILL be reset upon v1.0.0 release."}
|
||||
</strong>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ClickArea };
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Codex panel component displaying discovered lore entries.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with zone and entry rendering */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||
import type { CodexEntry } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Converts a fraction to a percentage value.
|
||||
* @param numerator - The numerator value.
|
||||
* @param denominator - The denominator value.
|
||||
* @returns The percentage as a number between 0 and 100.
|
||||
*/
|
||||
const toPercent = (numerator: number, denominator: number): number => {
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
const scaled = numerator * 100;
|
||||
return scaled / denominator;
|
||||
};
|
||||
|
||||
const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
||||
adventurer: "👥",
|
||||
boss: "⚔️",
|
||||
equipment: "🛡️",
|
||||
exploration: "🧭",
|
||||
prestige: "🔮",
|
||||
quest: "📜",
|
||||
recipe: "⚗️",
|
||||
upgrade: "🔧",
|
||||
zone: "🗺️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex panel with lore entries grouped by zone.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CodexPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ expandedId, setExpandedId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []);
|
||||
const totalEntries = CODEX_ENTRIES.length;
|
||||
const unlockedCount = CODEX_ENTRIES.filter((entry) => {
|
||||
return unlockedIds.has(entry.id);
|
||||
}).length;
|
||||
const progressPercent = toPercent(unlockedCount, totalEntries);
|
||||
|
||||
const entriesByZone = Object.entries(ZONE_LABELS).
|
||||
map(([ zoneId, zoneName ]) => {
|
||||
const entries = CODEX_ENTRIES.filter((entry) => {
|
||||
return entry.zoneId === zoneId;
|
||||
});
|
||||
const unlockedEntries = entries.filter((entry) => {
|
||||
return unlockedIds.has(entry.id);
|
||||
});
|
||||
return {
|
||||
entries,
|
||||
unlockedEntries,
|
||||
zoneId,
|
||||
zoneName,
|
||||
};
|
||||
}).
|
||||
filter(({ entries }) => {
|
||||
return entries.length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel codex-panel">
|
||||
<h2>{"📖 Codex"}</h2>
|
||||
|
||||
<div className="codex-progress">
|
||||
<p className="codex-progress-text">
|
||||
{"Lore discovered: "}
|
||||
<strong>
|
||||
{unlockedCount}
|
||||
{" / "}
|
||||
{totalEntries}
|
||||
</strong>
|
||||
</p>
|
||||
<div className="codex-progress-bar">
|
||||
<div
|
||||
className="codex-progress-fill"
|
||||
style={{ width: `${String(Math.round(progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => {
|
||||
return (
|
||||
<div className="codex-zone" key={zoneId}>
|
||||
<h3 className="codex-zone-header">
|
||||
{zoneName}
|
||||
<span className="codex-zone-count">
|
||||
{unlockedEntries.length}
|
||||
{"/"}
|
||||
{entries.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="codex-entries">
|
||||
{entries.map((entry) => {
|
||||
const isUnlocked = unlockedIds.has(entry.id);
|
||||
const isExpanded = expandedId === entry.id;
|
||||
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<div className="codex-entry locked" key={entry.id}>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-lock">{"🔒"}</span>
|
||||
<span className="codex-entry-title">{"???"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleExpand(): void {
|
||||
setExpandedId(isExpanded
|
||||
? null
|
||||
: entry.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`codex-entry unlocked ${
|
||||
isExpanded
|
||||
? "expanded"
|
||||
: ""
|
||||
}`}
|
||||
key={entry.id}
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-source-badge">
|
||||
{sourceBadge[entry.sourceType]}
|
||||
</span>
|
||||
<span className="codex-entry-title">{entry.title}</span>
|
||||
<span className="codex-chevron">
|
||||
{isExpanded
|
||||
? "▲"
|
||||
: "▼"}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded
|
||||
? <p className="codex-entry-content">{entry.content}</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodexPanel };
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file Codex toast notification component for new lore discoveries.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES } from "../../data/codex.js";
|
||||
|
||||
interface CodexToastItemProperties {
|
||||
readonly entryId: string;
|
||||
readonly onDismiss: (id: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single codex lore toast notification.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.entryId - The codex entry ID to display.
|
||||
* @param props.onDismiss - Callback to dismiss the toast.
|
||||
* @returns The JSX element or null if entry is not found.
|
||||
*/
|
||||
const CodexToastItem = ({
|
||||
entryId,
|
||||
onDismiss,
|
||||
}: CodexToastItemProperties): JSX.Element | null => {
|
||||
const entry = CODEX_ENTRIES.find((codexEntry) => {
|
||||
return codexEntry.id === entryId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(entryId);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ entryId, onDismiss ]);
|
||||
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(): void {
|
||||
onDismiss(entryId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="codex-toast" onClick={handleClick}>
|
||||
<span className="toast-icon">{"📖"}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">{"✨ Lore Unlocked!"}</span>
|
||||
<span className="toast-name">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex toast container with pending lore notifications.
|
||||
* @returns The JSX element or null if there are no pending entries.
|
||||
*/
|
||||
const CodexToast = (): JSX.Element | null => {
|
||||
const { unlockedCodexEntryIds: pendingEntryIds, dismissCodexEntry }
|
||||
= useGame();
|
||||
|
||||
if (pendingEntryIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingEntryIds.map((id) => {
|
||||
return (
|
||||
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodexToast };
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @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 */
|
||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/gameContext.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)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a companion unlock threshold for display.
|
||||
* @param type - The unlock condition type.
|
||||
* @param threshold - The threshold value.
|
||||
* @returns The formatted threshold string.
|
||||
*/
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e18) {
|
||||
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
||||
}
|
||||
if (threshold >= 1e15) {
|
||||
return `${(threshold / 1e15).toFixed(0)}Q`;
|
||||
}
|
||||
if (threshold >= 1e12) {
|
||||
return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
}
|
||||
if (threshold >= 1e9) {
|
||||
return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
}
|
||||
if (threshold >= 1e6) {
|
||||
return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
}
|
||||
if (threshold >= 1e3) {
|
||||
return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
}
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
interface CompanionCardProperties {
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionCard = ({
|
||||
companion,
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: 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">
|
||||
<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">
|
||||
{"🔒 Unlock: "}
|
||||
{formatThreshold(
|
||||
companion.unlock.type,
|
||||
companion.unlock.threshold,
|
||||
)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the companion panel with all companions.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionPanel = (): JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
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}
|
||||
isActive={activeId === companion.id}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
key={companion.id}
|
||||
onSelect={handleCompanionSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CompanionPanel };
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @file Crafting panel component for crafting items from materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { MATERIALS } from "../../data/materials.js";
|
||||
import { RECIPES } from "../../data/recipes.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
|
||||
const bonusLabel: Record<string, string> = {
|
||||
click_power: "👆 Click Power",
|
||||
combat_power: "⚔️ Combat Power",
|
||||
essence_income: "✨ Essence Income",
|
||||
gold_income: "🪙 Gold Income",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the crafting panel for crafting recipes from gathered materials.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CraftingPanel = (): JSX.Element => {
|
||||
const { state, craftRecipe, formatNumber } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, exploration: explorationState } = state;
|
||||
const playerMaterials = explorationState?.materials ?? [];
|
||||
const craftedIds = explorationState?.craftedRecipeIds ?? [];
|
||||
|
||||
const zoneRecipes = RECIPES.filter((recipe) => {
|
||||
return recipe.zoneId === activeZoneId;
|
||||
});
|
||||
const zoneMaterials = MATERIALS.filter((material) => {
|
||||
return material.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
function getQuantity(materialId: string): number {
|
||||
return (
|
||||
playerMaterials.find((playerMaterial) => {
|
||||
return playerMaterial.materialId === materialId;
|
||||
})?.quantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function canAffordRecipe(recipeId: string): boolean {
|
||||
const recipe = RECIPES.find((candidateRecipe) => {
|
||||
return candidateRecipe.id === recipeId;
|
||||
});
|
||||
if (recipe === undefined) {
|
||||
return false;
|
||||
}
|
||||
return recipe.requiredMaterials.every((request) => {
|
||||
return getQuantity(request.materialId) >= request.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCraft(recipeId: string): Promise<void> {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚗️ Crafting"}</h2>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>{"📦 Materials"}</h3>
|
||||
{zoneMaterials.length === 0
|
||||
? <p className="empty-zone">{"No materials in this zone."}</p>
|
||||
: <div className="materials-list">
|
||||
{zoneMaterials.map((material) => {
|
||||
const qty = getQuantity(material.id);
|
||||
return (
|
||||
<div
|
||||
className={`material-card rarity-${material.rarity} ${
|
||||
qty === 0
|
||||
? "material-empty"
|
||||
: ""
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<div className="material-info">
|
||||
<span className="material-name">{material.name}</span>
|
||||
<span className="material-rarity">{material.rarity}</span>
|
||||
</div>
|
||||
<span className="material-quantity">
|
||||
{formatNumber(qty)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="recipes-section">
|
||||
<h3>{"📜 Recipes"}</h3>
|
||||
{zoneRecipes.length === 0
|
||||
? <p className="empty-zone">{"No recipes in this zone."}</p>
|
||||
: <div className="recipes-list">
|
||||
{zoneRecipes.map((recipe) => {
|
||||
const crafted = craftedIds.includes(recipe.id);
|
||||
const affordable = canAffordRecipe(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
function handleCraftClick(): void {
|
||||
void handleCraft(recipe.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`recipe-card ${
|
||||
crafted
|
||||
? "recipe-crafted"
|
||||
: ""
|
||||
} ${!affordable && !crafted
|
||||
? "recipe-unaffordable"
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">
|
||||
{bonusLabel[recipe.bonus.type] ?? recipe.bonus.type}
|
||||
</span>
|
||||
<span className="bonus-value">
|
||||
{"×"}
|
||||
{recipe.bonus.value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="recipe-requirements">
|
||||
{recipe.requiredMaterials.map((request) => {
|
||||
const have = getQuantity(request.materialId);
|
||||
const enough = have >= request.quantity;
|
||||
const matName
|
||||
= MATERIALS.find((mat) => {
|
||||
return mat.id === request.materialId;
|
||||
})?.name ?? request.materialId;
|
||||
return (
|
||||
<span
|
||||
className={`req-tag ${
|
||||
enough
|
||||
? "req-met"
|
||||
: "req-missing"
|
||||
}`}
|
||||
key={request.materialId}
|
||||
>
|
||||
{matName}
|
||||
{": "}
|
||||
{formatNumber(have)}
|
||||
{"/"}
|
||||
{formatNumber(request.quantity)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recipe-action">
|
||||
{crafted
|
||||
? <span className="quest-badge active">
|
||||
{"✅ Crafted"}
|
||||
</span>
|
||||
: <button
|
||||
className="craft-button"
|
||||
disabled={
|
||||
!affordable || isPending || pendingRecipeId !== null
|
||||
}
|
||||
onClick={handleCraftClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Crafting..."
|
||||
: "⚗️ Craft"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CraftingPanel };
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file Daily challenge panel component showing today's challenges.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Formats the time remaining until the daily reset.
|
||||
* @returns The formatted time string.
|
||||
*/
|
||||
const formatTimeUntilReset = (): string => {
|
||||
const now = new Date();
|
||||
const nowAsPst = new Date(
|
||||
now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }),
|
||||
);
|
||||
const tomorrowMidnightPst = new Date(nowAsPst);
|
||||
tomorrowMidnightPst.setDate(tomorrowMidnightPst.getDate() + 1);
|
||||
tomorrowMidnightPst.setHours(0, 0, 0, 0);
|
||||
const pstOffset = nowAsPst.getTime() - now.getTime();
|
||||
const resetAt = new Date(tomorrowMidnightPst.getTime() - pstOffset);
|
||||
const msRemaining = resetAt.getTime() - now.getTime();
|
||||
const msPerHour = 1000 * 60 * 60;
|
||||
const msPerMinute = 1000 * 60;
|
||||
const hoursRemaining = Math.floor(msRemaining / msPerHour);
|
||||
const msAfterHours = msRemaining % msPerHour;
|
||||
const minutesRemaining = Math.floor(msAfterHours / msPerMinute);
|
||||
return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the daily challenge panel with progress tracking.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DailyChallengePanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { dailyChallenges } = state;
|
||||
|
||||
if (dailyChallenges === undefined) {
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>{"📅 Daily Challenges"}</h2>
|
||||
<p className="daily-challenge-subtitle">
|
||||
{"Load the game to generate today's challenges!"}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = dailyChallenges.challenges.filter((challenge) => {
|
||||
return challenge.completed;
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>{"📅 Daily Challenges"}</h2>
|
||||
<div className="daily-challenge-header">
|
||||
<p className="daily-challenge-subtitle">
|
||||
{"Complete challenges for bonus 💎 crystals! Resets in "}
|
||||
<strong>{formatTimeUntilReset()}</strong>
|
||||
{" (PST midnight)."}
|
||||
</p>
|
||||
<p className="daily-challenge-progress">
|
||||
{completedCount}
|
||||
{" / "}
|
||||
{dailyChallenges.challenges.length}
|
||||
{" completed"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-list">
|
||||
{dailyChallenges.challenges.map((challenge) => {
|
||||
const progressScaled = challenge.progress * 100;
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.floor(progressScaled / challenge.target),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`daily-challenge-card ${
|
||||
challenge.completed
|
||||
? "completed"
|
||||
: ""
|
||||
}`}
|
||||
key={challenge.id}
|
||||
>
|
||||
<div className="daily-challenge-info">
|
||||
<h3 className="daily-challenge-label">{challenge.label}</h3>
|
||||
<p className="daily-challenge-reward">
|
||||
{"Reward: "}
|
||||
<strong>
|
||||
{"💎 "}
|
||||
{formatNumber(challenge.rewardCrystals)}
|
||||
{" crystals"}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-right">
|
||||
{challenge.completed
|
||||
? <span className="daily-challenge-done">
|
||||
{"✅ Complete!"}
|
||||
</span>
|
||||
|
||||
: <>
|
||||
<p className="daily-challenge-count">
|
||||
{formatNumber(challenge.progress)}
|
||||
{" / "}
|
||||
{formatNumber(challenge.target)}
|
||||
</p>
|
||||
<div className="daily-challenge-bar-track">
|
||||
<div
|
||||
className="daily-challenge-bar-fill"
|
||||
style={{ width: `${String(progressPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { DailyChallengePanel };
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* @file Edit profile modal component for updating player profile settings.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex form with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for toggles */
|
||||
/* eslint-disable max-lines -- Large modal with profile and settings forms */
|
||||
/* eslint-disable max-statements -- Many state initialisations and handlers */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
type NumberFormat,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface EditProfileModalProperties {
|
||||
readonly onClose: ()=> void;
|
||||
}
|
||||
|
||||
interface StatToggle {
|
||||
key: keyof ProfileSettings;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const currentRunToggles: Array<StatToggle> = [
|
||||
{ icon: "🪙", key: "showCurrentGold", label: "Gold Earned This Run" },
|
||||
{ icon: "👆", key: "showCurrentClicks", label: "Clicks This Run" },
|
||||
{ icon: "✨", key: "showApotheosis", label: "Apotheosis Badge" },
|
||||
{ icon: "🌌", key: "showTranscendence", label: "Transcendence Badge" },
|
||||
{ icon: "⭐", key: "showPrestige", label: "Prestige Level" },
|
||||
{ icon: "💀", key: "showBossesDefeated", label: "Bosses Defeated" },
|
||||
{ icon: "📜", key: "showQuestsCompleted", label: "Quests Completed" },
|
||||
{
|
||||
icon: "⚔️",
|
||||
key: "showAdventurersRecruited",
|
||||
label: "Adventurers Recruited",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
key: "showAchievementsUnlocked",
|
||||
label: "Achievements Unlocked",
|
||||
},
|
||||
];
|
||||
|
||||
const allTimeToggles: Array<StatToggle> = [
|
||||
{ icon: "🪙", key: "showTotalGold", label: "Total Gold Earned" },
|
||||
{ icon: "👆", key: "showTotalClicks", label: "Total Clicks" },
|
||||
{
|
||||
icon: "💀",
|
||||
key: "showLifetimeBossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
},
|
||||
{
|
||||
icon: "📜",
|
||||
key: "showLifetimeQuestsCompleted",
|
||||
label: "Quests Completed",
|
||||
},
|
||||
{
|
||||
icon: "⚔️",
|
||||
key: "showLifetimeAdventurersRecruited",
|
||||
label: "Adventurers Recruited",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
key: "showLifetimeAchievementsUnlocked",
|
||||
label: "Achievements Unlocked",
|
||||
},
|
||||
{ icon: "📅", key: "showGuildFounded", label: "Guild Founded Date" },
|
||||
];
|
||||
|
||||
const numberFormatOptions: Array<{
|
||||
value: NumberFormat;
|
||||
label: string;
|
||||
example: string;
|
||||
}> = [
|
||||
{ example: "1.23Qa", label: "Suffix", value: "suffix" },
|
||||
{ example: "1.23e15", label: "Scientific", value: "scientific" },
|
||||
{ example: "1.23E15", label: "Engineering", value: "engineering" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the edit profile modal for updating player display settings.
|
||||
* @param props - The modal properties.
|
||||
* @param props.onClose - Callback to close the modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EditProfileModal = ({
|
||||
onClose,
|
||||
}: EditProfileModalProperties): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
numberFormat: currentNumberFormat,
|
||||
setNumberFormat,
|
||||
} = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ characterName, setCharacterName ] = useState(
|
||||
player?.characterName ?? "",
|
||||
);
|
||||
const [ bio, setBio ] = useState("");
|
||||
const [ profileSettings, setProfileSettings ] = useState<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
numberFormat: currentNumberFormat,
|
||||
});
|
||||
const [ loadingProfile, setLoadingProfile ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
characterName: string;
|
||||
};
|
||||
setBio(data.bio);
|
||||
setProfileSettings({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
});
|
||||
setCharacterName(
|
||||
data.characterName === ""
|
||||
? player.characterName
|
||||
: data.characterName,
|
||||
);
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to local state if fetch fails — not a blocking error */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [ player?.discordId, player?.characterName ]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({
|
||||
bio,
|
||||
characterName,
|
||||
profileSettings,
|
||||
});
|
||||
setNumberFormat(profileSettings.numberFormat);
|
||||
setSaved(true);
|
||||
setTimeout(onClose, 900);
|
||||
} catch (error_: unknown) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function toggleSetting(key: keyof ProfileSettings): void {
|
||||
setProfileSettings((previous) => {
|
||||
const current = previous[key];
|
||||
const toggled = typeof current === "boolean"
|
||||
? !current
|
||||
: current;
|
||||
return { ...previous, [key]: toggled };
|
||||
});
|
||||
}
|
||||
|
||||
function handleLeaderboardToggle(): void {
|
||||
toggleSetting("showOnLeaderboards");
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
setCharacterName(event.target.value);
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
setBio(event.target.value);
|
||||
}
|
||||
|
||||
const isSaveDisabled = saving || characterName.trim() === "";
|
||||
|
||||
let saveLabel = "Save Profile";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-modal="true" className="modal-overlay" role="dialog">
|
||||
<div className="modal edit-profile-modal">
|
||||
<div className="modal-header">
|
||||
<h2>{"Edit Profile"}</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingProfile
|
||||
? <p className="edit-profile-loading">{"Loading your profile…"}</p>
|
||||
: <div className="edit-profile-form">
|
||||
<label className="edit-profile-label" htmlFor="edit-char-name">
|
||||
{"Display Name"}
|
||||
</label>
|
||||
<input
|
||||
className="edit-profile-input"
|
||||
id="edit-char-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={characterName}
|
||||
/>
|
||||
<span className="edit-profile-hint">
|
||||
{characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="edit-profile-label" htmlFor="edit-bio">
|
||||
{"Bio"}
|
||||
</label>
|
||||
<textarea
|
||||
className="edit-profile-textarea"
|
||||
id="edit-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder="Tell the world about your guild… (optional)"
|
||||
rows={3}
|
||||
value={bio}
|
||||
/>
|
||||
<span className="edit-profile-hint">
|
||||
{bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Visible Stats"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Choose which stats appear on your public profile."}
|
||||
</p>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">{"Current Run"}</p>
|
||||
<div className="stat-toggles">
|
||||
{currentRunToggles.map(({ key, label, icon }) => {
|
||||
const isOn = profileSettings[key] === true;
|
||||
const toggleClass = isOn
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off";
|
||||
const toggleIndicator = isOn
|
||||
? "✓ Shown"
|
||||
: "Hidden";
|
||||
function handleToggle(): void {
|
||||
toggleSetting(key);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`stat-toggle-btn ${toggleClass}`}
|
||||
key={key}
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{toggleIndicator}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">{"All Time"}</p>
|
||||
<div className="stat-toggles">
|
||||
{allTimeToggles.map(({ key, label, icon }) => {
|
||||
const isOn = profileSettings[key] === true;
|
||||
const toggleClass = isOn
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off";
|
||||
const toggleIndicator = isOn
|
||||
? "✓ Shown"
|
||||
: "Hidden";
|
||||
function handleToggle(): void {
|
||||
toggleSetting(key);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`stat-toggle-btn ${toggleClass}`}
|
||||
key={key}
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{toggleIndicator}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Privacy"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Control your visibility on public leaderboards."}
|
||||
</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.showOnLeaderboards
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleLeaderboardToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🏆 Appear on Leaderboards"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.showOnLeaderboards
|
||||
? "✓ Shown"
|
||||
: "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Number Format"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"How large numbers appear across the game."}
|
||||
</p>
|
||||
<div className="number-format-picker">
|
||||
{numberFormatOptions.map(({ value, label, example }) => {
|
||||
function handleFormatSelect(): void {
|
||||
setProfileSettings((previous) => {
|
||||
return { ...previous, numberFormat: value };
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`number-format-btn ${
|
||||
profileSettings.numberFormat === value
|
||||
? "number-format-active"
|
||||
: ""
|
||||
}`}
|
||||
key={value}
|
||||
onClick={handleFormatSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="number-format-label">{label}</span>
|
||||
<span className="number-format-example">{example}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="edit-profile-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="edit-profile-actions">
|
||||
<button
|
||||
className="edit-profile-cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="edit-profile-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { EditProfileModal };
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @file Equipment panel component for managing owned and available equipment.
|
||||
* @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 component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
common: "Common",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
const typeIcon: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a piece of equipment.
|
||||
* @param item - The equipment item.
|
||||
* @returns The formatted bonus description.
|
||||
*/
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (item.bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment cost as a readable string.
|
||||
* @param cost - The cost object with gold, essence, and crystals.
|
||||
* @param cost.gold - The gold component of the cost.
|
||||
* @param cost.essence - The essence component of the cost.
|
||||
* @param cost.crystals - The crystals component of the cost.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (cost: {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
}): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (cost.gold > 0) {
|
||||
parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
}
|
||||
if (cost.essence > 0) {
|
||||
parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
}
|
||||
if (cost.crystals > 0) {
|
||||
parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProperties {
|
||||
readonly item: Equipment;
|
||||
readonly gold: number;
|
||||
readonly essence: number;
|
||||
readonly crystals: number;
|
||||
readonly dropBossName: string | undefined;
|
||||
readonly setName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single equipment card.
|
||||
* @param props - The equipment card properties.
|
||||
* @param props.item - The equipment item data.
|
||||
* @param props.gold - The current gold amount.
|
||||
* @param props.essence - The current essence amount.
|
||||
* @param props.crystals - The current crystals amount.
|
||||
* @param props.dropBossName - The name of the boss that drops this item.
|
||||
* @param props.setName - The name of the set this item belongs to.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentCard = ({
|
||||
item,
|
||||
gold,
|
||||
essence,
|
||||
crystals,
|
||||
dropBossName,
|
||||
setName,
|
||||
}: EquipmentCardProperties): JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford
|
||||
= item.cost !== undefined
|
||||
&& gold >= item.cost.gold
|
||||
&& essence >= item.cost.essence
|
||||
&& crystals >= item.cost.crystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyEquipment(item.id);
|
||||
}
|
||||
function handleEquip(): void {
|
||||
equipItem(item.id);
|
||||
}
|
||||
|
||||
const ownedClass = item.owned
|
||||
? ""
|
||||
: "not-owned";
|
||||
const equippedClass = item.equipped
|
||||
? "equipped"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||
>
|
||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName === undefined
|
||||
? null
|
||||
: <span className="equipment-set-badge">
|
||||
{"🔗 "}
|
||||
{setName}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && item.cost === undefined
|
||||
&& <span className="equipment-locked">
|
||||
{dropBossName === undefined
|
||||
? "🔒 Boss drop"
|
||||
: `⚔️ Drop: ${dropBossName}`}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{canAfford
|
||||
? "Purchase"
|
||||
: "Can't afford"}
|
||||
</button>
|
||||
}
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✓ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="equip-button"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||
const slotLabel: Record<EquipmentType, string> = {
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
weapon: "⚔️ Weapons",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the equipment panel with all owned and available equipment.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, equipment, resources } = state;
|
||||
const unownedCount = equipment.filter((item) => {
|
||||
return !item.owned;
|
||||
}).length;
|
||||
|
||||
const equipmentDropSources = new Map<string, string>();
|
||||
for (const { equipmentRewards, name: bossName } of bosses) {
|
||||
for (const equipmentId of equipmentRewards) {
|
||||
equipmentDropSources.set(equipmentId, bossName);
|
||||
}
|
||||
}
|
||||
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((equipSet) => {
|
||||
return [ equipSet.id, equipSet.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const equippedItemIds = new Set(
|
||||
equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => {
|
||||
return equippedItemIds.has(id);
|
||||
}).length;
|
||||
return { count, set };
|
||||
}).filter(({ count }) => {
|
||||
return count >= 2;
|
||||
});
|
||||
|
||||
function setBonusDescription(
|
||||
equipSet: (typeof EQUIPMENT_SETS)[number],
|
||||
count: number,
|
||||
): string {
|
||||
const parts: Array<string> = [];
|
||||
for (const threshold of [ 2, 3 ] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = equipSet.bonuses[threshold];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click (${String(threshold)}pc)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Equipment"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={unownedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
{"Equipment drops from bosses and grants passive bonuses."
|
||||
+ " Only one item per slot can be equipped at a time."
|
||||
+ " Equip matching set pieces for bonus effects!"}
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0
|
||||
&& <div className="active-sets">
|
||||
<h3 className="active-sets-heading">{"✨ Active Set Bonuses"}</h3>
|
||||
{activeSets.map(({ set, count }) => {
|
||||
return (
|
||||
<div className="active-set-row" key={set.id}>
|
||||
<span className="active-set-name">
|
||||
{set.name}
|
||||
{" ("}
|
||||
{count}
|
||||
{"/"}
|
||||
{set.pieces.length}
|
||||
{")"}
|
||||
</span>
|
||||
<span className="active-set-bonus">
|
||||
{setBonusDescription(set, count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
{slotOrder.map((slotType) => {
|
||||
const items = equipment.filter((item) => {
|
||||
return item.type === slotType && (showLocked || item.owned);
|
||||
});
|
||||
return (
|
||||
<div className="equipment-slot-section" key={slotType}>
|
||||
<h3 className="slot-heading">{slotLabel[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EquipmentCard
|
||||
crystals={resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
essence={resources.essence}
|
||||
gold={resources.gold}
|
||||
item={item}
|
||||
key={item.id}
|
||||
setName={
|
||||
item.setId === undefined
|
||||
? undefined
|
||||
: setNameById.get(item.setId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{items.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No items to show in this slot."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { EquipmentPanel };
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @file Exploration panel component for exploring areas and collecting materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { ExploreCollectResponse } 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 secondsPerDay = 86_400;
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerDay) {
|
||||
const days = Math.floor(seconds / secondsPerDay);
|
||||
const remainingAfterDays = seconds % secondsPerDay;
|
||||
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
||||
return hours > 0
|
||||
? `${String(days)}d ${String(hours)}h`
|
||||
: `${String(days)}d`;
|
||||
}
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainingAfterHours = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainingAfterHours / 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 exploration in progress.
|
||||
* @param startedAt - The timestamp when exploration started.
|
||||
* @param durationSeconds - The total duration in seconds.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000;
|
||||
return Math.max(0, durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface CollectResult {
|
||||
areaId: string;
|
||||
response: ExploreCollectResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the exploration panel for managing area explorations.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ExplorationPanel = (): JSX.Element => {
|
||||
const { state, startExploration, collectExploration, formatNumber }
|
||||
= useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, exploration: explorationState } = state;
|
||||
|
||||
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
||||
return area.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const hasActiveExploration
|
||||
= explorationState?.areas.some((area) => {
|
||||
return area.status === "in_progress";
|
||||
}) ?? false;
|
||||
|
||||
async function handleStart(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectExploration(areaId);
|
||||
setLastResult({ areaId: areaId, response: result });
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissResult(): void {
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
function handleZoneSelect(id: string): void {
|
||||
setActiveZoneId(id);
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
||||
const essenceChange = lastResult?.response.event?.essenceChange ?? 0;
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Exploration"}</h2>
|
||||
</div>
|
||||
|
||||
{lastResult === null
|
||||
? null
|
||||
: <div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={handleDismissResult}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
{lastResult.response.foundNothing
|
||||
? <p className="exploration-nothing">
|
||||
{lastResult.response.nothingMessage}
|
||||
</p>
|
||||
: <>
|
||||
{lastResult.response.event === null
|
||||
? null
|
||||
: <p className="exploration-event-text">
|
||||
{lastResult.response.event.text}
|
||||
</p>
|
||||
}
|
||||
<div className="exploration-rewards">
|
||||
{goldChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${goldChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"🪙 "}
|
||||
{goldChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(goldChange)}
|
||||
{" gold"}
|
||||
</span>
|
||||
}
|
||||
{essenceChange > 0
|
||||
&& <span className="reward-tag">
|
||||
{"✨ +"}
|
||||
{formatNumber(essenceChange)}
|
||||
{" essence"}
|
||||
</span>
|
||||
}
|
||||
{lastResult.response.event?.materialGained !== null
|
||||
&& lastResult.response.event?.materialGained !== undefined
|
||||
? <span className="reward-tag material-tag">
|
||||
{"📦 +"}
|
||||
{lastResult.response.event.materialGained.quantity}{" "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
||||
{lastResult.response.event.materialGained.materialId.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
{" (event)"}
|
||||
</span>
|
||||
: null}
|
||||
{lastResult.response.materialsFound.map((foundMaterial) => {
|
||||
return (
|
||||
<span
|
||||
className="reward-tag material-tag"
|
||||
key={foundMaterial.materialId}
|
||||
>
|
||||
{"📦 +"}
|
||||
{foundMaterial.quantity}{" "}
|
||||
{foundMaterial.materialId.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={handleZoneSelect}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const isReady
|
||||
= status === "in_progress"
|
||||
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
function handleStartClick(): void {
|
||||
void handleStart(area.id);
|
||||
}
|
||||
function handleCollectClick(): void {
|
||||
void handleCollect(area.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce === true
|
||||
? <span className="exploration-discovered">{" 📖"}</span>
|
||||
: null}
|
||||
</h3>
|
||||
<p>{area.description}</p>
|
||||
<span className="exploration-duration">
|
||||
{"⏱️ "}
|
||||
{formatDuration(area.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="exploration-action">
|
||||
{status === "locked"
|
||||
&& <span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
{status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={isPending || hasActiveExploration}
|
||||
onClick={handleStartClick}
|
||||
title={
|
||||
hasActiveExploration
|
||||
? "An exploration is already in progress"
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Departing..."
|
||||
: `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
}
|
||||
{status === "in_progress" && !isReady
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{formatDuration(
|
||||
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
||||
)}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{status === "in_progress" && isReady
|
||||
? <button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={handleCollectClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Collecting..."
|
||||
: "📦 Collect Results"}
|
||||
</button>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No exploration areas in this zone."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ExplorationPanel };
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @file Game layout component rendering the main game UI.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */
|
||||
/* eslint-disable complexity -- Many tab render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { ResourceBar } from "../ui/resourceBar.js";
|
||||
import { AboutPanel } from "./aboutPanel.js";
|
||||
import { AchievementPanel } from "./achievementPanel.js";
|
||||
import { AchievementToast } from "./achievementToast.js";
|
||||
import { AdventurerPanel } from "./adventurerPanel.js";
|
||||
import { ApotheosisPanel } from "./apotheosisPanel.js";
|
||||
import { BattleModal } from "./battleModal.js";
|
||||
import { BossPanel } from "./bossPanel.js";
|
||||
import { CharacterSheetPanel } from "./characterSheetPanel.js";
|
||||
import { ClickArea } from "./clickArea.js";
|
||||
import { CodexPanel } from "./codexPanel.js";
|
||||
import { CodexToast } from "./codexToast.js";
|
||||
import { CompanionPanel } from "./companionPanel.js";
|
||||
import { CraftingPanel } from "./craftingPanel.js";
|
||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||
import { EditProfileModal } from "./editProfileModal.js";
|
||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||
import { ExplorationPanel } from "./explorationPanel.js";
|
||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||
import { OfflineModal } from "./offlineModal.js";
|
||||
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
|
||||
import { PrestigePanel } from "./prestigePanel.js";
|
||||
import { QuestPanel } from "./questPanel.js";
|
||||
import { StatisticsPanel } from "./statisticsPanel.js";
|
||||
import { StoryPanel } from "./storyPanel.js";
|
||||
import { StoryToast } from "./storyToast.js";
|
||||
import { TranscendencePanel } from "./transcendencePanel.js";
|
||||
import { UpgradePanel } from "./upgradePanel.js";
|
||||
|
||||
type Tab =
|
||||
| "adventurers"
|
||||
| "upgrades"
|
||||
| "quests"
|
||||
| "bosses"
|
||||
| "equipment"
|
||||
| "achievements"
|
||||
| "prestige"
|
||||
| "transcendence"
|
||||
| "apotheosis"
|
||||
| "statistics"
|
||||
| "daily"
|
||||
| "codex"
|
||||
| "about"
|
||||
| "exploration"
|
||||
| "crafting"
|
||||
| "character"
|
||||
| "companions"
|
||||
| "story";
|
||||
|
||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
{ id: "quests", label: "📜 Quests" },
|
||||
{ id: "bosses", label: "👹 Bosses" },
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "exploration", label: "🗺️ Exploration" },
|
||||
{ id: "crafting", label: "⚗️ Crafting" },
|
||||
{ id: "daily", label: "📅 Daily" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "companions", label: "👥 Companions" },
|
||||
{ id: "character", label: "📋 Character" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "story", label: "📖 Story" },
|
||||
{ id: "codex", label: "🗺️ Codex" },
|
||||
{ id: "about", label: "ℹ️ About" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the main game layout with tabs and panels.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GameLayout = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
forceSync,
|
||||
unlockedCodexEntryIds: pendingCodexEntryIds,
|
||||
unlockedStoryChapterIds: pendingStoryChapterIds,
|
||||
loginBonus,
|
||||
dismissLoginBonus,
|
||||
schemaOutdated,
|
||||
} = useGame();
|
||||
const [ activeTab, setActiveTab ] = useState<Tab>("adventurers");
|
||||
const [ editingProfile, setEditingProfile ] = useState(false);
|
||||
const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ]
|
||||
= useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>{"Loading your adventure..."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null && error !== "") {
|
||||
return (
|
||||
<div className="error-screen">
|
||||
<p>
|
||||
{"Error: "}
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profileUrl = `/profile/${state.player.discordId}`;
|
||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||
|
||||
function handleOpenEditProfile(): void {
|
||||
setEditingProfile(true);
|
||||
}
|
||||
|
||||
function handleCloseEditProfile(): void {
|
||||
setEditingProfile(false);
|
||||
}
|
||||
|
||||
function handleDismissOutdated(): void {
|
||||
setDismissedOutdatedWarning(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<ResourceBar
|
||||
apotheosisCount={state.apotheosis?.count ?? 0}
|
||||
isSyncing={isSyncing}
|
||||
lastSavedAt={lastSavedAt}
|
||||
onEditProfile={handleOpenEditProfile}
|
||||
onForceSync={forceSync}
|
||||
prestigeCount={state.prestige.count}
|
||||
profileUrl={profileUrl}
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
/>
|
||||
<OfflineModal />
|
||||
{schemaOutdated && !dismissedOutdatedWarning
|
||||
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
||||
: null}
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
<StoryToast />
|
||||
{loginBonus === null
|
||||
? null
|
||||
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||
}
|
||||
{battleResult === null
|
||||
? null
|
||||
: <BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
}
|
||||
{editingProfile
|
||||
? <EditProfileModal onClose={handleCloseEditProfile} />
|
||||
: null}
|
||||
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
<ClickArea />
|
||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||
</aside>
|
||||
|
||||
<main className="game-content">
|
||||
<nav className="tab-bar">
|
||||
{baseTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tabId);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button ${
|
||||
activeTab === tabId
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
key={tabId}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
{tabId === "codex" && codexBadgeCount > 0
|
||||
&& <span className="tab-badge">{codexBadgeCount}</span>
|
||||
}
|
||||
{tabId === "story" && storyBadgeCount > 0
|
||||
&& <span className="tab-badge">{storyBadgeCount}</span>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "transcendence" && <TranscendencePanel />}
|
||||
{activeTab === "apotheosis" && <ApotheosisPanel />}
|
||||
{activeTab === "exploration" && <ExplorationPanel />}
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "story" && <StoryPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { GameLayout };
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Leaderboard page component showing top players across categories.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths for categories and entries */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
|
||||
|
||||
interface CategoryConfig {
|
||||
id: LeaderboardCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
formatValue: (value: number)=> string;
|
||||
}
|
||||
|
||||
const goldSuffixes = [
|
||||
"",
|
||||
"K",
|
||||
"M",
|
||||
"B",
|
||||
"T",
|
||||
"Qa",
|
||||
"Qt",
|
||||
"S",
|
||||
"Sp",
|
||||
"O",
|
||||
"N",
|
||||
"D",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a gold value with a short suffix.
|
||||
* @param value - The gold amount to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatGold = (value: number): string => {
|
||||
if (value === 0) {
|
||||
return "0";
|
||||
}
|
||||
const tier = Math.floor(Math.log10(Math.abs(value)) / 3);
|
||||
const clamped = Math.min(tier, goldSuffixes.length - 1);
|
||||
const scaled = value / Math.pow(1000, clamped);
|
||||
return `${String(Number.parseFloat(scaled.toFixed(2)))}${goldSuffixes[clamped] ?? ""}`;
|
||||
};
|
||||
|
||||
const categories: Array<CategoryConfig> = [
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return formatGold(v);
|
||||
},
|
||||
icon: "🪙",
|
||||
id: "totalGold",
|
||||
label: "Lifetime Gold",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "💀",
|
||||
id: "bossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "📜",
|
||||
id: "questsCompleted",
|
||||
label: "Quests Completed",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "🏆",
|
||||
id: "achievementsUnlocked",
|
||||
label: "Achievements",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "⭐",
|
||||
id: "prestigeCount",
|
||||
label: "Prestige",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "🌌",
|
||||
id: "transcendenceCount",
|
||||
label: "Transcendence",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "✨",
|
||||
id: "apotheosisCount",
|
||||
label: "Apotheosis",
|
||||
},
|
||||
];
|
||||
|
||||
const rankBadges: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
||||
|
||||
/**
|
||||
* Renders the leaderboard page with category tabs and player rankings.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LeaderboardPage = (): JSX.Element => {
|
||||
const [ category, setCategory ] = useState<LeaderboardCategory>("totalGold");
|
||||
const [ entries, setEntries ] = useState<Array<LeaderboardEntry>>([]);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/leaderboards?category=${category}&limit=100`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load leaderboard");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
entries: Array<LeaderboardEntry>;
|
||||
};
|
||||
setEntries(data.entries);
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load leaderboard",
|
||||
);
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ category ]);
|
||||
|
||||
const currentConfig
|
||||
= categories.find((cat) => {
|
||||
return cat.id === category;
|
||||
}) ?? categories[0];
|
||||
|
||||
return (
|
||||
<div className="leaderboard-page">
|
||||
<div className="leaderboard-card">
|
||||
<div className="leaderboard-header">
|
||||
<h1 className="leaderboard-title">{"🏆 Leaderboards"}</h1>
|
||||
<p className="leaderboard-subtitle">
|
||||
{"The mightiest adventurers in Elysium"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="leaderboard-tabs">
|
||||
{categories.map((cat) => {
|
||||
function handleCategoryClick(): void {
|
||||
setCategory(cat.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`leaderboard-tab ${
|
||||
category === cat.id
|
||||
? "leaderboard-tab--active"
|
||||
: ""
|
||||
}`}
|
||||
key={cat.id}
|
||||
onClick={handleCategoryClick}
|
||||
type="button"
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <div className="leaderboard-loading">{"Loading…"}</div>
|
||||
: null}
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <div className="leaderboard-error">
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && error === null && entries.length === 0
|
||||
&& <div className="leaderboard-empty">
|
||||
{"No entries yet — be the first on the board!"}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && error === null && entries.length > 0
|
||||
&& <div className="leaderboard-table">
|
||||
<div className="leaderboard-table-header">
|
||||
<span className="leaderboard-col-rank">{"Rank"}</span>
|
||||
<span className="leaderboard-col-player">{"Player"}</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.icon} {currentConfig?.label}
|
||||
</span>
|
||||
</div>
|
||||
{entries.map((entry) => {
|
||||
const avatarUrl
|
||||
= entry.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(entry.discordId, 10) % 5)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`;
|
||||
const displayName
|
||||
= entry.characterName === ""
|
||||
? entry.username
|
||||
: entry.characterName;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`leaderboard-row ${
|
||||
entry.rank <= 3
|
||||
? `leaderboard-row--top${String(entry.rank)}`
|
||||
: ""
|
||||
}`}
|
||||
href={`/character/${entry.discordId}`}
|
||||
key={entry.discordId}
|
||||
>
|
||||
<span className="leaderboard-col-rank">
|
||||
{rankBadges[entry.rank] ?? `#${String(entry.rank)}`}
|
||||
</span>
|
||||
<span className="leaderboard-col-player">
|
||||
<img
|
||||
alt={displayName}
|
||||
className="leaderboard-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<span className="leaderboard-player-info">
|
||||
<span className="leaderboard-player-name">
|
||||
{displayName}
|
||||
</span>
|
||||
{entry.activeTitle === ""
|
||||
? null
|
||||
: <span className="leaderboard-player-title">
|
||||
{entry.activeTitle}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.formatValue(entry.value)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="leaderboard-footer">
|
||||
<a className="leaderboard-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
<p className="leaderboard-privacy-note">
|
||||
{"Players can opt out via their profile settings."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LeaderboardPage };
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file Login bonus modal component displaying daily login rewards.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex modal with many render paths */
|
||||
import type { LoginBonusResult } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface LoginBonusModalProperties {
|
||||
readonly bonus: LoginBonusResult;
|
||||
readonly onClose: ()=> void;
|
||||
}
|
||||
|
||||
const dayIcons = [ "🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥" ];
|
||||
|
||||
/**
|
||||
* Formats a gold value with a short suffix.
|
||||
* @param value - The gold amount to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatGold = (value: number): string => {
|
||||
const suffixes = [ "", "K", "M", "B", "T" ];
|
||||
if (value < 1000) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1);
|
||||
const scaled = value / Math.pow(1000, tier);
|
||||
return `${String(Number.parseFloat(scaled.toFixed(1)))}${suffixes[tier] ?? ""}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the login bonus modal showing daily reward details.
|
||||
* @param props - The modal properties.
|
||||
* @param props.bonus - The login bonus result data.
|
||||
* @param props.onClose - Callback when the modal is closed.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LoginBonusModal = ({
|
||||
bonus,
|
||||
onClose,
|
||||
}: LoginBonusModalProperties): JSX.Element => {
|
||||
const isWeeklyBonus = bonus.day === 7;
|
||||
const dayIcon = dayIcons[bonus.day - 1] ?? "⭐";
|
||||
|
||||
return (
|
||||
<div aria-modal="true" className="modal-overlay" role="dialog">
|
||||
<div className="modal login-bonus-modal">
|
||||
<div className="login-bonus-streak">
|
||||
<span className="login-bonus-fire">{"🔥"}</span>
|
||||
<span className="login-bonus-streak-count">{bonus.streak}</span>
|
||||
<span className="login-bonus-streak-label">{"Day Streak"}</span>
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-day-badge">
|
||||
<span className="login-bonus-day-icon">{dayIcon}</span>
|
||||
<span className="login-bonus-day-label">
|
||||
{"Day "}
|
||||
{bonus.day}
|
||||
{" Reward"}
|
||||
</span>
|
||||
{bonus.weekMultiplier > 1
|
||||
&& <span className="login-bonus-week-tag">
|
||||
{"×"}
|
||||
{bonus.weekMultiplier}
|
||||
{" Week Bonus!"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-rewards">
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">{"🪙"}</span>
|
||||
<span className="login-bonus-reward-value">
|
||||
{"+"}
|
||||
{formatGold(bonus.goldEarned)}
|
||||
{" Gold"}
|
||||
</span>
|
||||
</div>
|
||||
{bonus.crystalsEarned > 0
|
||||
&& <div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">{"💎"}</span>
|
||||
<span className="login-bonus-reward-value">
|
||||
{"+"}
|
||||
{bonus.crystalsEarned}
|
||||
{" Crystals"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isWeeklyBonus
|
||||
? <p className="login-bonus-weekly-message">
|
||||
{"🎉 Weekly bonus — keep the streak going!"}
|
||||
</p>
|
||||
: null}
|
||||
|
||||
<div className="login-bonus-calendar">
|
||||
{dayIcons.map((icon, index) => {
|
||||
const dayNumber = index + 1;
|
||||
const isLastDayCompleted = bonus.day === 7 && dayNumber === 7;
|
||||
const isCompleted = dayNumber < bonus.day || isLastDayCompleted;
|
||||
const isToday = dayNumber === bonus.day;
|
||||
return (
|
||||
<div
|
||||
className={`login-bonus-cal-day ${
|
||||
isToday
|
||||
? "login-bonus-cal-day--today"
|
||||
: ""
|
||||
} ${isCompleted
|
||||
? "login-bonus-cal-day--done"
|
||||
: ""}`}
|
||||
key={dayNumber}
|
||||
>
|
||||
<span className="login-bonus-cal-icon">{icon}</span>
|
||||
<span className="login-bonus-cal-num">{dayNumber}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="login-bonus-claim-btn"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"Claim Reward"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginBonusModal };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file Login page component with Discord OAuth authentication.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Authentication flow requires many render paths */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
|
||||
|
||||
interface LoginPageProperties {
|
||||
readonly onLogin: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the login page with Discord OAuth authentication.
|
||||
* @param props - The login page properties.
|
||||
* @param props.onLogin - Callback when authentication completes successfully.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LoginPage = ({ onLogin }: LoginPageProperties): JSX.Element => {
|
||||
const [ authUrl, setAuthUrl ] = useState<string | null>(null);
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const code = parameters.get("code");
|
||||
|
||||
if (code !== null) {
|
||||
setIsLoading(true);
|
||||
handleAuthCallback(code).
|
||||
then(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
onLogin();
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Authentication failed",
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
getAuthUrl().
|
||||
then((url) => {
|
||||
setAuthUrl(url);
|
||||
setIsLoading(false);
|
||||
}).
|
||||
catch(() => {
|
||||
setError("Failed to load authentication URL");
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [ onLogin ]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
function handleReload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p className="error">{error}</p>
|
||||
<button onClick={handleReload} type="button">
|
||||
{"Try Again"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>{"⚔️ Elysium"}</h1>
|
||||
<p>
|
||||
{"An idle fantasy RPG. Hire adventurers, defeat bosses,"
|
||||
+ " and ascend to glory."}
|
||||
</p>
|
||||
<a className="discord-login-button" href={authUrl ?? "#"}>
|
||||
{"Login with Discord"}
|
||||
</a>
|
||||
<p className="login-note">
|
||||
{"Your progress is saved to your Discord account and shareable"
|
||||
+ " with others!"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginPage };
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file Offline modal component showing gold earned while away.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Renders the offline earnings modal if the player earned resources offline.
|
||||
* @returns The JSX element or null if no offline earnings.
|
||||
*/
|
||||
const OfflineModal = (): JSX.Element | null => {
|
||||
const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber }
|
||||
= useGame();
|
||||
|
||||
if (offlineGold <= 0 && offlineEssence <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>{"Welcome back!"}</h2>
|
||||
<p>
|
||||
{"Your adventurers kept working whilst you were away and earned:"}
|
||||
</p>
|
||||
{offlineGold > 0
|
||||
&& <p>
|
||||
<strong>
|
||||
{"🪙 "}
|
||||
{formatNumber(offlineGold)}
|
||||
{" gold"}
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
{offlineEssence > 0
|
||||
&& <p>
|
||||
<strong>
|
||||
{"✨ "}
|
||||
{formatNumber(offlineEssence)}
|
||||
{" essence"}
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
<p className="modal-note">
|
||||
{"Offline progress is calculated up to 8 hours."}
|
||||
</p>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={dismissOfflineGold}
|
||||
type="button"
|
||||
>
|
||||
{"Collect!"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { OfflineModal };
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file Outdated schema modal component warning about incompatible save data.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface OutdatedSchemaModalProperties {
|
||||
readonly onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the outdated schema modal prompting the user to reset or continue.
|
||||
* @param props - The modal properties.
|
||||
* @param props.onDismiss - Callback to dismiss the modal without resetting.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const OutdatedSchemaModal = ({
|
||||
onDismiss,
|
||||
}: OutdatedSchemaModalProperties): JSX.Element => {
|
||||
const { resetProgress } = useGame();
|
||||
const [ isResetting, setIsResetting ] = useState(false);
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
setIsResetting(true);
|
||||
await resetProgress();
|
||||
setIsResetting(false);
|
||||
}
|
||||
|
||||
function handleResetClick(): void {
|
||||
void handleReset();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal offline-modal">
|
||||
<h2>{"⚠️ Outdated Save Data"}</h2>
|
||||
<p>
|
||||
{"Your save data is from an older version of Elysium and may cause"
|
||||
+ " bugs or unexpected behaviour. Cloud saves are "}
|
||||
<strong>{"disabled"}</strong>
|
||||
{" until you reset your progress."}
|
||||
</p>
|
||||
<p>{"Resetting will start you fresh — all progress will be lost."}</p>
|
||||
<div className="outdated-modal-actions">
|
||||
<button
|
||||
className="outdated-modal-reset-button"
|
||||
disabled={isResetting}
|
||||
onClick={handleResetClick}
|
||||
type="button"
|
||||
>
|
||||
{isResetting
|
||||
? "Resetting…"
|
||||
: "Reset Progress"}
|
||||
</button>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Proceed with Bugs"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { OutdatedSchemaModal };
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* @file Prestige panel component for ascending and purchasing runestone upgrades.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-lines -- Large panel with prestige and shop tabs */
|
||||
/* eslint-disable max-statements -- Prestige panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseThreshold = 1_000_000;
|
||||
const thresholdScale = 5;
|
||||
const runestonesPerLevel = 10;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the production multiplier for a given prestige count.
|
||||
* @param prestigeCount - The number of times the player has prestiged.
|
||||
* @returns The compounding multiplier applied to all income sources.
|
||||
*/
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the runestone preview for a prestige.
|
||||
* @param totalGoldEarned - Total gold earned this run.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
||||
* @returns The predicted runestone reward.
|
||||
*/
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === "runestones"
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
"income",
|
||||
"click",
|
||||
"essence",
|
||||
"crystals",
|
||||
"runestones",
|
||||
"utility",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the prestige panel with ascension and runestone shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const PrestigePanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reload,
|
||||
formatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
toggleAutoPrestige,
|
||||
} = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
runestones: number;
|
||||
count: number;
|
||||
milestoneRunestones: number;
|
||||
} | null>(null);
|
||||
const [ prestigeError, setPrestigeError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<"prestige" | "shop">("prestige");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setPrestigeError(null);
|
||||
try {
|
||||
const data = await prestige({});
|
||||
setResult({
|
||||
count: data.newPrestigeCount,
|
||||
milestoneRunestones: data.milestoneRunestones,
|
||||
runestones: data.runestones,
|
||||
});
|
||||
await reload();
|
||||
} catch (error_: unknown) {
|
||||
setPrestigeError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Prestige failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyPrestigeUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((categoryId) => {
|
||||
const label = PRESTIGE_UPGRADE_CATEGORY_LABELS[categoryId] ?? categoryId;
|
||||
const upgrades = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === categoryId;
|
||||
});
|
||||
return { categoryId, label, upgrades };
|
||||
});
|
||||
|
||||
function handlePrestigeClick(): void {
|
||||
void handlePrestige();
|
||||
}
|
||||
|
||||
function handleAutoPrestigeToggle(): void {
|
||||
toggleAutoPrestige();
|
||||
}
|
||||
|
||||
function handlePrestigeTabClick(): void {
|
||||
setActiveTab("prestige");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
const progressRatio = player.totalGoldEarned / threshold;
|
||||
const progressPct = (progressRatio * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<section className="panel prestige-panel">
|
||||
<h2>{"⭐ Prestige"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "prestige"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handlePrestigeTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Ascend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"🔮 Runestone Shop ("}
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{" stones)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "prestige"
|
||||
&& <>
|
||||
<p>
|
||||
{"Prestige resets your progress but grants "}
|
||||
<strong>{"Runestones"}</strong>
|
||||
{"— permanent currency used for powerful upgrades."}
|
||||
{" Each prestige multiplies your global production by ×1.15"}
|
||||
{" (compounding each run)."}
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
{"Total gold this run: "}
|
||||
<strong>{formatNumber(player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Required to prestige: "}
|
||||
<strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current production multiplier: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{prestigeData.productionMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"After next prestige: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{nextMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Runestones: "}
|
||||
<strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="runestone-preview">
|
||||
{"Runestones on prestige: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(runestonePreview)}
|
||||
</strong>
|
||||
</p>
|
||||
: null}
|
||||
{isEligible
|
||||
? null
|
||||
: <p className="prestige-progress">
|
||||
{"Progress: "}
|
||||
{formatNumber(player.totalGoldEarned)}
|
||||
{" / "}
|
||||
{formatNumber(threshold)}
|
||||
{" ("}
|
||||
{progressPct}
|
||||
{"%"}
|
||||
{")"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>{"You are ready to prestige!"}</p>
|
||||
<button
|
||||
className="prestige-button"
|
||||
disabled={isPending}
|
||||
onClick={handlePrestigeClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError === null
|
||||
? null
|
||||
: <p className="error">{prestigeError}</p>
|
||||
}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Ascended to Prestige "}
|
||||
{result.count}
|
||||
{"! Earned "}
|
||||
{formatNumber(result.runestones)}
|
||||
{" Runestones."}
|
||||
{result.milestoneRunestones > 0
|
||||
&& <>
|
||||
{" 🎉 Milestone bonus: +"}
|
||||
{formatNumber(result.milestoneRunestones)}
|
||||
{" Runestones!"}
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: <p className="prestige-locked">
|
||||
{"Earn "}
|
||||
{formatNumber(threshold - player.totalGoldEarned)}
|
||||
{" more gold to unlock prestige."}
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="runestone-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{" Runestones"}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={categoryId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = prestigeData.purchasedUpgradeIds.includes(
|
||||
upgrade.id,
|
||||
);
|
||||
const canAfford
|
||||
= prestigeData.runestones >= upgrade.runestonesCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
const isAutoPrestigeToggle
|
||||
= upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled
|
||||
= prestigeData.autoPrestigeEnabled ?? false;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoPrestigeToggle
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
: null}
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { PrestigePanel };
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @file Profile page component displaying a player's public profile.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
import type { PublicProfileResponse } from "@elysium/types";
|
||||
|
||||
interface ProfilePageProperties {
|
||||
readonly discordId: string;
|
||||
}
|
||||
|
||||
interface StatEntry {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
date: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the public profile page for a given player.
|
||||
* @param props - The profile page properties.
|
||||
* @param props.discordId - The Discord ID of the player to display.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
||||
const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Player not found");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
return await (response.json() as Promise<PublicProfileResponse>);
|
||||
}).
|
||||
then(setProfile).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load profile",
|
||||
);
|
||||
});
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-error">
|
||||
<p>
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</p>
|
||||
<a className="profile-play-link" href="/">
|
||||
{"← Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile === null) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-loading">{"Loading profile…"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = profile.profileSettings;
|
||||
function fmt(value: number): string {
|
||||
return formatNumber(value, settings.numberFormat);
|
||||
}
|
||||
|
||||
const avatarUrl
|
||||
= profile.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(discordId, 10) % 5)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
|
||||
|
||||
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const currentRunStatsRaw: Array<StatEntry | false> = [
|
||||
settings.showCurrentGold && {
|
||||
date: false,
|
||||
icon: "🪙",
|
||||
label: "Gold Earned",
|
||||
value: fmt(profile.currentRunGold),
|
||||
},
|
||||
settings.showCurrentClicks && {
|
||||
date: false,
|
||||
icon: "👆",
|
||||
label: "Clicks",
|
||||
value: fmt(profile.currentRunClicks),
|
||||
},
|
||||
settings.showBossesDefeated && {
|
||||
date: false,
|
||||
icon: "💀",
|
||||
label: "Bosses Defeated",
|
||||
value: String(profile.bossesDefeated),
|
||||
},
|
||||
settings.showQuestsCompleted && {
|
||||
date: false,
|
||||
icon: "📜",
|
||||
label: "Quests Completed",
|
||||
value: String(profile.questsCompleted),
|
||||
},
|
||||
settings.showAdventurersRecruited && {
|
||||
date: false,
|
||||
icon: "⚔️",
|
||||
label: "Adventurers Recruited",
|
||||
value: fmt(profile.adventurersRecruited),
|
||||
},
|
||||
settings.showAchievementsUnlocked && {
|
||||
date: false,
|
||||
icon: "🏆",
|
||||
label: "Achievements Unlocked",
|
||||
value: String(profile.achievementsUnlocked),
|
||||
},
|
||||
];
|
||||
const currentRunStats = currentRunStatsRaw.filter(
|
||||
(entry): entry is StatEntry => {
|
||||
return entry !== false;
|
||||
},
|
||||
);
|
||||
|
||||
const allTimeStatsRaw: Array<StatEntry | false> = [
|
||||
settings.showTotalGold && {
|
||||
date: false,
|
||||
icon: "🪙",
|
||||
label: "Total Gold Earned",
|
||||
value: fmt(profile.totalGoldEarned),
|
||||
},
|
||||
settings.showTotalClicks && {
|
||||
date: false,
|
||||
icon: "👆",
|
||||
label: "Total Clicks",
|
||||
value: fmt(profile.totalClicks),
|
||||
},
|
||||
settings.showLifetimeBossesDefeated && {
|
||||
date: false,
|
||||
icon: "💀",
|
||||
label: "Bosses Defeated",
|
||||
value: String(profile.lifetimeBossesDefeated),
|
||||
},
|
||||
settings.showLifetimeQuestsCompleted && {
|
||||
date: false,
|
||||
icon: "📜",
|
||||
label: "Quests Completed",
|
||||
value: String(profile.lifetimeQuestsCompleted),
|
||||
},
|
||||
settings.showLifetimeAdventurersRecruited && {
|
||||
date: false,
|
||||
icon: "⚔️",
|
||||
label: "Adventurers Recruited",
|
||||
value: fmt(profile.lifetimeAdventurersRecruited),
|
||||
},
|
||||
settings.showLifetimeAchievementsUnlocked && {
|
||||
date: false,
|
||||
icon: "🏆",
|
||||
label: "Achievements Unlocked",
|
||||
value: String(profile.lifetimeAchievementsUnlocked),
|
||||
},
|
||||
settings.showGuildFounded && {
|
||||
date: true,
|
||||
icon: "📅",
|
||||
label: "Guild Founded",
|
||||
value: memberSince,
|
||||
},
|
||||
];
|
||||
const allTimeStats = allTimeStatsRaw.filter((entry): entry is StatEntry => {
|
||||
return entry !== false;
|
||||
});
|
||||
|
||||
function renderStats(stats: Array<StatEntry>): JSX.Element {
|
||||
return (
|
||||
<div className="profile-stats">
|
||||
{stats.map((stat) => {
|
||||
return (
|
||||
<div className="profile-stat" key={stat.label}>
|
||||
<span className="profile-stat-icon">{stat.icon}</span>
|
||||
<span
|
||||
className={`profile-stat-value ${
|
||||
stat.date
|
||||
? "profile-stat-date"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="profile-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-card">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
alt={`${profile.username}'s avatar`}
|
||||
className="profile-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="profile-identity">
|
||||
<h1 className="profile-character-name">{profile.characterName}</h1>
|
||||
<p className="profile-username">
|
||||
{"@"}
|
||||
{profile.username}
|
||||
</p>
|
||||
{settings.showApotheosis && profile.apotheosisCount > 0
|
||||
? <span className="profile-apotheosis-badge">
|
||||
{"✨ Apotheosis "}
|
||||
{profile.apotheosisCount}
|
||||
</span>
|
||||
: null}
|
||||
{settings.showTranscendence && profile.transcendenceCount > 0
|
||||
? <span className="profile-transcendence-badge">
|
||||
{"🌌 Transcendence "}
|
||||
{profile.transcendenceCount}
|
||||
</span>
|
||||
: null}
|
||||
{settings.showPrestige && profile.prestigeCount > 0
|
||||
? <span className="profile-prestige-badge">
|
||||
{"⭐ Prestige "}
|
||||
{profile.prestigeCount}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio === ""
|
||||
? null
|
||||
: <p className="profile-bio">{profile.bio}</p>
|
||||
}
|
||||
|
||||
{currentRunStats.length > 0
|
||||
&& <div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">{"Current Run"}</h3>
|
||||
{renderStats(currentRunStats)}
|
||||
</div>
|
||||
}
|
||||
|
||||
{allTimeStats.length > 0
|
||||
&& <div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">{"All Time"}</h3>
|
||||
{renderStats(allTimeStats)}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="profile-share-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Copy Profile Link"}
|
||||
</button>
|
||||
<a className="profile-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfilePage };
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @file Quest panel component for managing and completing quests.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* 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 { 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}`}>
|
||||
<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) => {
|
||||
return (
|
||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
||||
{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" && quest.lastFailedAt !== undefined
|
||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</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("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, quests, zones } = state;
|
||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
||||
const partyCombatPower = adventurers.reduce((total, adventurer) => {
|
||||
const power = total + adventurer.combatPower;
|
||||
return power * adventurer.count;
|
||||
}, 0);
|
||||
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 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={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<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 };
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file Statistics panel component showing player progress and all-time stats.
|
||||
* @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 component with many render paths */
|
||||
/* eslint-disable react/require-default-props -- TypeScript optional props with default parameters are sufficient */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
interface StatCardProperties {
|
||||
readonly icon: string;
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly sub?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single statistic card.
|
||||
* @param props - The stat card properties.
|
||||
* @param props.icon - The icon to display.
|
||||
* @param props.label - The label for the stat.
|
||||
* @param props.value - The value to display.
|
||||
* @param props.sub - Optional sub-label.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatCard = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub = undefined,
|
||||
}: StatCardProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="profile-stat">
|
||||
<span className="profile-stat-icon">{icon}</span>
|
||||
<span className="profile-stat-value">{value}</span>
|
||||
<span className="profile-stat-label">{label}</span>
|
||||
{sub === undefined
|
||||
? null
|
||||
: <span className="profile-stat-date">{sub}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the statistics panel with player progress and all-time stats.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatisticsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
player,
|
||||
resources,
|
||||
prestige,
|
||||
bosses,
|
||||
quests,
|
||||
zones,
|
||||
adventurers,
|
||||
upgrades,
|
||||
equipment,
|
||||
achievements,
|
||||
} = state;
|
||||
|
||||
const bossesDefeated = bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length;
|
||||
const questsCompleted = quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
const zonesUnlocked = zones.filter((zone) => {
|
||||
return zone.status === "unlocked";
|
||||
}).length;
|
||||
const adventurersRecruited = adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0);
|
||||
const equipmentOwned = equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length;
|
||||
const upgradesPurchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
}).length;
|
||||
const achievementsUnlocked = achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length;
|
||||
|
||||
return (
|
||||
<section className="panel statistics-panel">
|
||||
<h2>{"📊 Statistics"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"All-Time"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Total Gold Earned"
|
||||
sub="across all runs"
|
||||
value={formatNumber(player.totalGoldEarned)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="👆"
|
||||
label="Total Clicks"
|
||||
value={formatNumber(player.totalClicks)}
|
||||
/>
|
||||
<StatCard icon="⭐" label="Prestiges" value={String(prestige.count)} />
|
||||
<StatCard
|
||||
icon="📅"
|
||||
label="Guild Founded"
|
||||
value={formatDate(player.createdAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="☁️"
|
||||
label="Last Cloud Save"
|
||||
value={formatDate(player.lastSavedAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✖️"
|
||||
label="Production Multiplier"
|
||||
sub="from prestige"
|
||||
value={`×${prestige.productionMultiplier.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Current Run"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard icon="🪙" label="Gold" value={formatNumber(resources.gold)} />
|
||||
<StatCard
|
||||
icon="✨"
|
||||
label="Essence"
|
||||
value={formatNumber(resources.essence)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
sub="permanent currency"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Progress"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="👹"
|
||||
label="Bosses Defeated"
|
||||
value={`${String(bossesDefeated)} / ${String(bosses.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📜"
|
||||
label="Quests Completed"
|
||||
value={`${String(questsCompleted)} / ${String(quests.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗺️"
|
||||
label="Zones Unlocked"
|
||||
value={`${String(zonesUnlocked)} / ${String(zones.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚔️"
|
||||
label="Adventurers Recruited"
|
||||
value={formatNumber(adventurersRecruited)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗡️"
|
||||
label="Equipment Owned"
|
||||
value={`${String(equipmentOwned)} / ${String(equipment.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔧"
|
||||
label="Upgrades Purchased"
|
||||
value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
label="Achievements"
|
||||
value={`${String(achievementsUnlocked)} / ${String(achievements.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Prestige Upgrades"
|
||||
value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatisticsPanel };
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @file Story panel component displaying the main questline narrative.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
/**
|
||||
* Substitutes the character name placeholder in story text.
|
||||
* @param text - The story text with placeholders.
|
||||
* @param characterName - The player's character name.
|
||||
* @returns The text with placeholders replaced.
|
||||
*/
|
||||
const substituteCharacterName = (
|
||||
text: string,
|
||||
characterName: string,
|
||||
): string => {
|
||||
const fallback = characterName === ""
|
||||
? "the guild leader"
|
||||
: characterName;
|
||||
return text.replaceAll("{characterName}", fallback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the story panel with chapter navigation and content.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StoryPanel = (): JSX.Element => {
|
||||
const { state, completeChapter } = useGame();
|
||||
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<p>{"Loading…"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = state.story?.unlockedChapterIds ?? [];
|
||||
const completedChapters = state.story?.completedChapters ?? [];
|
||||
const { characterName } = state.player;
|
||||
|
||||
const activeChapter = STORY_CHAPTERS[activeChapterIndex];
|
||||
const isUnlocked = unlockedIds.includes(activeChapter?.id ?? "");
|
||||
const completion
|
||||
= activeChapter === undefined
|
||||
? null
|
||||
: completedChapters.find((completedChapter) => {
|
||||
return completedChapter.chapterId === activeChapter.id;
|
||||
}) ?? null;
|
||||
const isUnread = isUnlocked && completion === null;
|
||||
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<div className="story-chapter-tabs">
|
||||
{STORY_CHAPTERS.map((chapter, index) => {
|
||||
const unlocked = unlockedIds.includes(chapter.id);
|
||||
const completed = completedChapters.some((completedChapter) => {
|
||||
return completedChapter.chapterId === chapter.id;
|
||||
});
|
||||
const unread = unlocked && !completed;
|
||||
function handleChapterSelect(): void {
|
||||
setActiveChapterIndex(index);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-label={
|
||||
unlocked
|
||||
? chapter.title
|
||||
: `Chapter ${String(index + 1)} (locked)`
|
||||
}
|
||||
className={[
|
||||
"story-tab-btn",
|
||||
activeChapterIndex === index
|
||||
? "active"
|
||||
: "",
|
||||
unlocked
|
||||
? ""
|
||||
: "locked",
|
||||
].join(" ")}
|
||||
key={chapter.id}
|
||||
onClick={handleChapterSelect}
|
||||
type="button"
|
||||
>
|
||||
{index + 1}
|
||||
{unread
|
||||
? <span className="story-unread-dot" />
|
||||
: null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeChapter === undefined
|
||||
? null
|
||||
: <div className="story-chapter-view">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<h2 className="story-chapter-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
{": "}
|
||||
{activeChapter.title}
|
||||
</h2>
|
||||
<div className="story-chapter-content">
|
||||
{substituteCharacterName(activeChapter.content, characterName).
|
||||
split("\n\n").
|
||||
map((paragraph, paraIndex) => {
|
||||
// eslint-disable-next-line react/no-array-index-key -- Static content paragraphs have no stable id
|
||||
return <p key={paraIndex}>{paragraph}</p>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{completion === null && isUnread
|
||||
? <div className="story-choices">
|
||||
<p className="story-choices-prompt">{"What do you do?"}</p>
|
||||
{activeChapter.choices.map((storyChoice) => {
|
||||
const chapterForClosure = activeChapter;
|
||||
function handleChoice(): void {
|
||||
completeChapter(chapterForClosure.id, storyChoice.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="story-choice-btn"
|
||||
key={storyChoice.id}
|
||||
onClick={handleChoice}
|
||||
type="button"
|
||||
>
|
||||
{storyChoice.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
{completion === null
|
||||
? null
|
||||
: <div className="story-choice-result">
|
||||
<p className="story-choice-label">
|
||||
<strong>{"Your choice:"}</strong>{" "}
|
||||
{
|
||||
activeChapter.choices.find((storyChoice) => {
|
||||
return storyChoice.id === completion.choiceId;
|
||||
})?.label
|
||||
}
|
||||
</p>
|
||||
<p className="story-choice-outcome">
|
||||
{substituteCharacterName(
|
||||
activeChapter.choices.find((storyChoice) => {
|
||||
return storyChoice.id === completion.choiceId;
|
||||
})?.outcome ?? "",
|
||||
characterName,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <div className="story-locked">
|
||||
<p className="story-locked-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
</p>
|
||||
<p className="story-locked-hint">
|
||||
{"🔒 This chapter has not yet been unlocked."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StoryPanel };
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file Story toast notification component for new chapter unlocks.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface StoryToastItemProperties {
|
||||
readonly chapterId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single story chapter toast notification.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.chapterId - The chapter ID to display.
|
||||
* @returns The JSX element or null if chapter is not found.
|
||||
*/
|
||||
const StoryToastItem = ({
|
||||
chapterId,
|
||||
}: StoryToastItemProperties): JSX.Element | null => {
|
||||
const { dismissStoryChapter } = useGame();
|
||||
const chapter = STORY_CHAPTERS.find((storyChapter) => {
|
||||
return storyChapter.id === chapterId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
dismissStoryChapter(chapterId);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ chapterId, dismissStoryChapter ]);
|
||||
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(): void {
|
||||
dismissStoryChapter(chapterId);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="achievement-toast" onClick={handleClick} type="button">
|
||||
<span className="achievement-toast-icon">{"📖"}</span>
|
||||
<div className="achievement-toast-content">
|
||||
<span className="achievement-toast-label">{"✨ New Chapter!"}</span>
|
||||
<span className="achievement-toast-name">{chapter.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the story toast container with pending chapter notifications.
|
||||
* @returns The JSX element or null if there are no pending chapters.
|
||||
*/
|
||||
const StoryToast = (): JSX.Element | null => {
|
||||
const { unlockedStoryChapterIds: pendingChapterIds } = useGame();
|
||||
if (pendingChapterIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingChapterIds.map((id) => {
|
||||
return <StoryToastItem chapterId={id} key={id} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StoryToast };
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file Transcendence panel component for the second prestige layer.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const echoFormulaConstant = 853;
|
||||
const finalBossId = "the_absolute_one";
|
||||
|
||||
/**
|
||||
* Calculates the echo preview for a transcendence.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param echoMetaMultiplier - The echo meta multiplier from upgrades.
|
||||
* @returns The predicted echo reward.
|
||||
*/
|
||||
const calculateEchoPreview = (
|
||||
prestigeCount: number,
|
||||
echoMetaMultiplier: number,
|
||||
): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
return Math.floor(
|
||||
// eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule
|
||||
(echoFormulaConstant / Math.sqrt(safeCount)) * echoMetaMultiplier,
|
||||
);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
||||
"income",
|
||||
"combat",
|
||||
"prestige_threshold",
|
||||
"prestige_runestones",
|
||||
"echo_meta",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the transcendence panel with transcendence and echo shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const TranscendencePanel = (): JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
echoes: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
type TranscendTab = "transcend" | "shop";
|
||||
const [ activeTab, setActiveTab ] = useState<TranscendTab>("transcend");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, prestige: prestigeData, transcendence } = state;
|
||||
const hasDefeatedFinalBoss = bosses.some((boss) => {
|
||||
return boss.id === finalBossId && boss.status === "defeated";
|
||||
});
|
||||
const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1;
|
||||
const echoPreview = calculateEchoPreview(
|
||||
prestigeData.count,
|
||||
echoMetaMultiplier,
|
||||
);
|
||||
const currentEchoes = transcendence?.echoes ?? 0;
|
||||
const transcendenceCount = transcendence?.count ?? 0;
|
||||
|
||||
async function handleTranscend(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await transcend();
|
||||
setResult({ count: data.newTranscendenceCount, echoes: data.echoes });
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Transcendence failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEchoUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((catId) => {
|
||||
const categoryLabels = TRANSCENDENCE_UPGRADE_CATEGORY_LABELS;
|
||||
const label = categoryLabels[catId] ?? catId;
|
||||
const upgrades = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === catId;
|
||||
});
|
||||
return { catId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleTranscendClick(): void {
|
||||
void handleTranscend();
|
||||
}
|
||||
|
||||
function handleTranscendTabClick(): void {
|
||||
setActiveTab("transcend");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel transcendence-panel">
|
||||
<h2>{"🌌 Transcendence"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${
|
||||
activeTab === "transcend"
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleTranscendTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Transcend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"✨ Echo Shop ("}
|
||||
{formatNumber(currentEchoes)}
|
||||
{" echoes)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "transcend"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Transcendence is the ultimate reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" — resources, prestige, runestones, upgrades, and equipment"
|
||||
+ " — but grants "}
|
||||
<strong>{"Echoes"}</strong>
|
||||
{", a permanent currency that survives all future resets."}
|
||||
{" Echoes power upgrades that permanently amplify every run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"Fewer prestiges = more Echoes."}
|
||||
{" Optimise your run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{transcendenceCount > 0
|
||||
&& <p>
|
||||
{"Transcendence count: "}
|
||||
<strong>{transcendenceCount}</strong>
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{"Current Echoes: "}
|
||||
<strong>{formatNumber(currentEchoes)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Echoes on transcendence: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(echoPreview)}
|
||||
</strong>
|
||||
{echoMetaMultiplier > 1
|
||||
&& <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{echoMetaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat The Absolute One"}</strong>
|
||||
{" to unlock transcendence."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Absolute One is the final boss of The Absolute zone,"
|
||||
+ " requiring Prestige 90 to challenge."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to transcend. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleTranscendClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Transcended! Earned "}
|
||||
<strong>
|
||||
{formatNumber(result.echoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
{". This is Transcendence "}
|
||||
{result.count}
|
||||
{". A new cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(currentEchoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Echo upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future prestiges and transcendences."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = (
|
||||
transcendence?.purchasedUpgradeIds ?? []
|
||||
).includes(upgrade.id);
|
||||
const canAfford = currentEchoes >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button echo-buy-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { TranscendencePanel };
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file Upgrade panel component for purchasing game upgrades.
|
||||
* @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 component with many render paths */
|
||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
|
||||
interface UpgradeCardProperties {
|
||||
readonly upgrade: Upgrade;
|
||||
readonly currentGold: number;
|
||||
readonly currentEssence: number;
|
||||
readonly currentCrystals: number;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single upgrade card.
|
||||
* @param props - The upgrade card properties.
|
||||
* @param props.upgrade - The upgrade data.
|
||||
* @param props.currentGold - The current gold amount.
|
||||
* @param props.currentEssence - The current essence amount.
|
||||
* @param props.currentCrystals - The current crystals amount.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const UpgradeCard = ({
|
||||
upgrade,
|
||||
currentGold,
|
||||
currentEssence,
|
||||
currentCrystals,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: UpgradeCardProperties): JSX.Element => {
|
||||
const { buyUpgrade } = useGame();
|
||||
const canAfford
|
||||
= currentGold >= upgrade.costGold
|
||||
&& currentEssence >= upgrade.costEssence
|
||||
&& currentCrystals >= upgrade.costCrystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked && upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
</span>
|
||||
<span className="upgrade-desc">{upgrade.description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card">
|
||||
<div className="upgrade-info">
|
||||
<h3>{upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">
|
||||
{"×"}
|
||||
{upgrade.multiplier}
|
||||
{" multiplier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0
|
||||
&& <span>
|
||||
{"🪙 "}
|
||||
{formatNumber(upgrade.costGold)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costEssence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(upgrade.costEssence)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costCrystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(upgrade.costCrystals)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{"Buy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<div className="upgrade-info">
|
||||
<h3>
|
||||
{"🔒 "}
|
||||
{upgrade.name}
|
||||
</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">
|
||||
{"×"}
|
||||
{upgrade.multiplier}
|
||||
{" multiplier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0
|
||||
&& <span>
|
||||
{"🪙 "}
|
||||
{formatNumber(upgrade.costGold)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costEssence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(upgrade.costEssence)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costCrystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(upgrade.costCrystals)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">{"Locked"}</span>
|
||||
{unlockHint === undefined
|
||||
? null
|
||||
: <p className="unlock-hint">{unlockHint}</p>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the upgrade panel with all available, locked, and purchased upgrades.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const UpgradePanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, upgrades, resources } = state;
|
||||
const purchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
});
|
||||
const available = upgrades.filter((upgrade) => {
|
||||
return upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
const locked = upgrades.filter((upgrade) => {
|
||||
return !upgrade.unlocked;
|
||||
});
|
||||
|
||||
const upgradeUnlockHints = new Map<string, string>();
|
||||
for (const { upgradeRewards, name: bossName } of bosses) {
|
||||
for (const upgradeId of upgradeRewards) {
|
||||
upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${bossName}`);
|
||||
}
|
||||
}
|
||||
for (const { rewards, name: questName } of quests) {
|
||||
for (const reward of rewards) {
|
||||
if (
|
||||
reward.type === "upgrade"
|
||||
&& reward.targetId !== undefined
|
||||
&& !upgradeUnlockHints.has(reward.targetId)
|
||||
) {
|
||||
upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${questName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel upgrade-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Upgrades"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="upgrade-progress">
|
||||
{purchased.length}
|
||||
{" / "}
|
||||
{upgrades.length}
|
||||
{" purchased"}
|
||||
</p>
|
||||
{upgrades.length === 0
|
||||
? <p className="empty-state">
|
||||
{"No upgrades available yet — keep adventuring!"}
|
||||
</p>
|
||||
: <div className="upgrade-list">
|
||||
{available.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={undefined}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{purchased.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={undefined}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showLocked
|
||||
? locked.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={upgradeUnlockHints.get(upgrade.id)}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { UpgradePanel };
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file Zone selector component for choosing the active zone.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { Zone } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ZoneSelectorProperties {
|
||||
readonly zones: Array<Zone>;
|
||||
readonly activeZoneId: string;
|
||||
readonly onSelectZone: (zoneId: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a zone selector with buttons for each available zone.
|
||||
* @param props - The zone selector properties.
|
||||
* @param props.zones - The list of zones to display.
|
||||
* @param props.activeZoneId - The currently active zone ID.
|
||||
* @param props.onSelectZone - Callback when a zone is selected.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ZoneSelector = ({
|
||||
zones,
|
||||
activeZoneId,
|
||||
onSelectZone,
|
||||
}: ZoneSelectorProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="zone-selector">
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
onSelectZone(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
zone.id === activeZoneId
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span className="zone-emoji">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ZoneSelector };
|
||||
@@ -1,20 +0,0 @@
|
||||
interface LockToggleProps {
|
||||
showLocked: boolean;
|
||||
onToggle: () => void;
|
||||
lockedCount: number;
|
||||
}
|
||||
|
||||
export const LockToggle = ({
|
||||
showLocked,
|
||||
onToggle,
|
||||
lockedCount,
|
||||
}: LockToggleProps): React.JSX.Element => (
|
||||
<button
|
||||
className={`lock-toggle ${showLocked ? "lock-toggle-on" : "lock-toggle-off"}`}
|
||||
onClick={onToggle}
|
||||
title={showLocked ? "Hide locked items" : "Show locked items"}
|
||||
type="button"
|
||||
>
|
||||
{showLocked ? "🔓" : "🔒"} {showLocked ? "Hide" : "Show"} locked ({lockedCount})
|
||||
</button>
|
||||
);
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { Resource } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
||||
|
||||
interface ResourceBarProps {
|
||||
resources: Resource;
|
||||
runestones: number;
|
||||
prestigeCount: number;
|
||||
transcendenceCount: number;
|
||||
apotheosisCount: number;
|
||||
profileUrl: string;
|
||||
onEditProfile: () => void;
|
||||
lastSavedAt: number | null;
|
||||
isSyncing: boolean;
|
||||
onForceSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 10) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
};
|
||||
|
||||
const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning.";
|
||||
|
||||
export const ResourceBar = ({
|
||||
resources,
|
||||
runestones,
|
||||
prestigeCount,
|
||||
transcendenceCount,
|
||||
apotheosisCount,
|
||||
profileUrl,
|
||||
onEditProfile,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProps): React.JSX.Element => {
|
||||
const { formatNumber, syncError } = useGame();
|
||||
const anyFull = [resources.gold, resources.essence, resources.crystals].some((v) => v >= RESOURCE_CAP);
|
||||
return (
|
||||
<>
|
||||
<header className="resource-bar">
|
||||
<div className={`resource${resources.gold >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||||
<span className="resource-icon">🪙</span>
|
||||
<span className="resource-value">{formatNumber(resources.gold)}</span>
|
||||
<span className="resource-label">Gold</span>
|
||||
{resources.gold >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||||
</div>
|
||||
<div className={`resource${resources.essence >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||||
<span className="resource-icon">✨</span>
|
||||
<span className="resource-value">{formatNumber(resources.essence)}</span>
|
||||
<span className="resource-label">Essence</span>
|
||||
{resources.essence >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||||
</div>
|
||||
<div className={`resource${resources.crystals >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||||
<span className="resource-icon">💎</span>
|
||||
<span className="resource-value">{formatNumber(resources.crystals)}</span>
|
||||
<span className="resource-label">Crystals</span>
|
||||
{resources.crystals >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">🔮</span>
|
||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||
<span className="resource-label">Runestones</span>
|
||||
</div>
|
||||
{apotheosisCount > 0 && (
|
||||
<div className="apotheosis-badge">
|
||||
✨ Apotheosis {apotheosisCount}
|
||||
</div>
|
||||
)}
|
||||
{transcendenceCount > 0 && (
|
||||
<div className="transcendence-badge">
|
||||
🌌 Transcendence {transcendenceCount}
|
||||
</div>
|
||||
)}
|
||||
{prestigeCount > 0 && (
|
||||
<div className="prestige-badge">
|
||||
⭐ Prestige {prestigeCount}
|
||||
</div>
|
||||
)}
|
||||
<div className="profile-buttons">
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://donate.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Support the developer"
|
||||
>
|
||||
💜 <span className="btn-label">Donate</span>
|
||||
</a>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://chat.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Join our Discord"
|
||||
>
|
||||
💬 <span className="btn-label">Discord</span>
|
||||
</a>
|
||||
{syncError !== null ? (
|
||||
<span className="save-status save-error" title={syncError}>
|
||||
❌ Save failed
|
||||
</span>
|
||||
) : lastSavedAt !== null ? (
|
||||
<span className="save-status" title={new Date(lastSavedAt).toLocaleString()}>
|
||||
☁️ {formatRelativeTime(lastSavedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
className="force-save-button"
|
||||
disabled={isSyncing}
|
||||
onClick={onForceSync}
|
||||
title="Force cloud save"
|
||||
type="button"
|
||||
>
|
||||
{isSyncing ? "⏳" : "💾"}
|
||||
</button>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href={profileUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View your public profile"
|
||||
>
|
||||
👤 <span className="btn-label">Profile</span>
|
||||
</a>
|
||||
<button
|
||||
className="profile-edit-button"
|
||||
onClick={onEditProfile}
|
||||
title="Edit your profile"
|
||||
type="button"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{anyFull && (
|
||||
<div className="resource-cap-notice">
|
||||
⚠️ One or more resources are full! Consider spending some or prestiging to keep earning.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @file Lock toggle component for showing/hiding locked items.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface LockToggleProperties {
|
||||
readonly showLocked: boolean;
|
||||
readonly onToggle: ()=> void;
|
||||
readonly lockedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a toggle button for showing or hiding locked items.
|
||||
* @param props - The lock toggle properties.
|
||||
* @param props.showLocked - Whether locked items are currently shown.
|
||||
* @param props.onToggle - Callback when the toggle is clicked.
|
||||
* @param props.lockedCount - The number of locked items.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LockToggle = ({
|
||||
showLocked,
|
||||
onToggle,
|
||||
lockedCount,
|
||||
}: LockToggleProperties): JSX.Element => {
|
||||
const toggleIcon = showLocked
|
||||
? "🔓"
|
||||
: "🔒";
|
||||
const toggleLabel = showLocked
|
||||
? "Hide"
|
||||
: "Show";
|
||||
return (
|
||||
<button
|
||||
className={`lock-toggle ${
|
||||
showLocked
|
||||
? "lock-toggle-on"
|
||||
: "lock-toggle-off"
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
title={showLocked
|
||||
? "Hide locked items"
|
||||
: "Show locked items"}
|
||||
type="button"
|
||||
>
|
||||
{toggleIcon} {toggleLabel}
|
||||
{" locked ("}
|
||||
{lockedCount}
|
||||
{")"}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export { LockToggle };
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* @file Resource bar component displaying player resources and profile actions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ResourceBarProperties {
|
||||
readonly resources: Resource;
|
||||
readonly runestones: number;
|
||||
readonly prestigeCount: number;
|
||||
readonly transcendenceCount: number;
|
||||
readonly apotheosisCount: number;
|
||||
readonly profileUrl: string;
|
||||
readonly onEditProfile: ()=> void;
|
||||
readonly lastSavedAt: number | null;
|
||||
readonly isSyncing: boolean;
|
||||
readonly onForceSync: ()=> Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp as a human-readable relative time string.
|
||||
* @param timestamp - The Unix timestamp in milliseconds.
|
||||
* @returns The relative time string.
|
||||
*/
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 10) {
|
||||
return "just now";
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${String(seconds)}s ago`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${String(minutes)}m ago`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${String(hours)}h ago`;
|
||||
};
|
||||
|
||||
const resourceFullTooltip = [
|
||||
"This resource is full!",
|
||||
" Consider spending some or prestiging to keep earning.",
|
||||
].join("");
|
||||
|
||||
/**
|
||||
* Renders the resource bar with player resources and profile actions.
|
||||
* @param props - The resource bar properties.
|
||||
* @param props.resources - The current player resources.
|
||||
* @param props.runestones - The current runestone count.
|
||||
* @param props.prestigeCount - The number of prestiges completed.
|
||||
* @param props.transcendenceCount - The number of transcendences completed.
|
||||
* @param props.apotheosisCount - The number of apotheoses completed.
|
||||
* @param props.profileUrl - The URL of the player's public profile.
|
||||
* @param props.onEditProfile - Callback to open the edit profile modal.
|
||||
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||
* @param props.onForceSync - Callback to trigger a forced cloud sync.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ResourceBar = ({
|
||||
resources,
|
||||
runestones,
|
||||
prestigeCount,
|
||||
transcendenceCount,
|
||||
apotheosisCount,
|
||||
profileUrl,
|
||||
onEditProfile,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProperties): JSX.Element => {
|
||||
const { formatNumber, syncError } = useGame();
|
||||
const { gold, essence, crystals } = resources;
|
||||
const resourceValues = [ gold, essence, crystals ];
|
||||
const anyFull = resourceValues.some((v) => {
|
||||
return v >= RESOURCE_CAP;
|
||||
});
|
||||
const goldFull = gold >= RESOURCE_CAP;
|
||||
const essenceFull = essence >= RESOURCE_CAP;
|
||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||
|
||||
function handleForceSync(): void {
|
||||
void onForceSync();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="resource-bar">
|
||||
<div className={`resource${goldFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"🪙"}</span>
|
||||
<span className="resource-value">{formatNumber(gold)}</span>
|
||||
<span className="resource-label">{"Gold"}</span>
|
||||
{goldFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${essenceFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"✨"}</span>
|
||||
<span className="resource-value">{formatNumber(essence)}</span>
|
||||
<span className="resource-label">{"Essence"}</span>
|
||||
{essenceFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${crystalsFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"💎"}</span>
|
||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
||||
<span className="resource-label">{"Crystals"}</span>
|
||||
{crystalsFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"🔮"}</span>
|
||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
{apotheosisCount > 0
|
||||
&& <div className="apotheosis-badge">
|
||||
{"✨ Apotheosis "}
|
||||
{apotheosisCount}
|
||||
</div>
|
||||
}
|
||||
{transcendenceCount > 0
|
||||
&& <div className="transcendence-badge">
|
||||
{"🌌 Transcendence "}
|
||||
{transcendenceCount}
|
||||
</div>
|
||||
}
|
||||
{prestigeCount > 0
|
||||
&& <div className="prestige-badge">
|
||||
{"⭐ Prestige "}
|
||||
{prestigeCount}
|
||||
</div>
|
||||
}
|
||||
<div className="profile-buttons">
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://donate.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Support the developer"
|
||||
>
|
||||
{"💜"} <span className="btn-label">{"Donate"}</span>
|
||||
</a>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://chat.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Join our Discord"
|
||||
>
|
||||
{"💬"} <span className="btn-label">{"Discord"}</span>
|
||||
</a>
|
||||
{syncError === null
|
||||
? null
|
||||
: <span className="save-status save-error" title={syncError}>
|
||||
{"❌ Save failed"}
|
||||
</span>
|
||||
}
|
||||
{syncError === null && lastSavedAt !== null
|
||||
? <span
|
||||
className="save-status"
|
||||
title={new Date(lastSavedAt).toLocaleString()}
|
||||
>
|
||||
{"☁️ "}
|
||||
{formatRelativeTime(lastSavedAt)}
|
||||
</span>
|
||||
: null}
|
||||
<button
|
||||
className="force-save-button"
|
||||
disabled={isSyncing}
|
||||
onClick={handleForceSync}
|
||||
title="Force cloud save"
|
||||
type="button"
|
||||
>
|
||||
{isSyncing
|
||||
? "⏳"
|
||||
: "💾"}
|
||||
</button>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href={profileUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View your public profile"
|
||||
>
|
||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
||||
</a>
|
||||
<button
|
||||
className="profile-edit-button"
|
||||
onClick={onEditProfile}
|
||||
title="Edit your profile"
|
||||
type="button"
|
||||
>
|
||||
{"✏️"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{anyFull
|
||||
? <div className="resource-cap-notice">
|
||||
{"⚠️ One or more resources are full! Consider spending some or"
|
||||
+ " prestiging to keep earning."}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ResourceBar };
|
||||
Reference in New Issue
Block a user