feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## Summary

This PR represents the full v1 prototype, implementing the core game systems for Elysium.

- Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests
- Adventurer hiring with batch size selector and progressive tier cost scaling
- Prestige, transcendence, and apotheosis systems with auto-prestige support
- Character sheet, titles, leaderboards, companion system, and daily login bonuses
- Auto-quest and auto-boss toggles
- Discord webhook notifications on prestige/transcendence/apotheosis
- Discord role awarded on apotheosis
- Responsive design and overarching story/lore system
- In-game sound effects and browser notifications for key events
- Support link button in the resource bar
- Full test coverage (100% on `apps/api` and `packages/types`)
- CI pipeline: lint → build → test

## Closes

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
Closes #12
Closes #13
Closes #14
Closes #16
Closes #19
Closes #20
Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #26
Closes #27
Closes #29

 This issue was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #30
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
/**
* @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",
},
{
body:
"Enable sound effects and browser notifications in your profile settings"
+ " (click your character name in the top bar). Sound effects play when"
+ " you defeat a boss, complete or fail a quest, unlock an achievement,"
+ " prestige, transcend, or achieve apotheosis. Browser notifications"
+ " alert you to the same events even when the game tab is in the"
+ " background. You will be prompted to grant notification permission"
+ " when you first enable them.",
title: "🔔 Sounds & Notifications",
},
];
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 };
+383
View File
@@ -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 };
+136
View File
@@ -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 };
+171
View File
@@ -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,482 @@
/**
* @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";
import {
requestNotificationPermission,
} from "../../utils/notification.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,
setEnableSounds,
setEnableNotifications,
} = 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);
setEnableSounds(profileSettings.enableSounds);
setEnableNotifications(profileSettings.enableNotifications);
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);
}
function handleSoundsToggle(): void {
toggleSetting("enableSounds");
}
async function handleNotificationsEnable(): Promise<void> {
if (profileSettings.enableNotifications) {
toggleSetting("enableNotifications");
return;
}
const granted = await requestNotificationPermission();
if (granted) {
toggleSetting("enableNotifications");
} else {
setError(
"Browser notification permission was denied."
+ " Please enable it in your browser settings.",
);
}
}
function handleNotificationsToggle(): void {
void handleNotificationsEnable();
}
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">{"Sounds & Notifications"}</p>
<p className="edit-profile-sublabel">
{"Control in-game sound effects and browser notifications."}
</p>
<button
className={`stat-toggle-btn ${
profileSettings.enableSounds
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleSoundsToggle}
type="button"
>
<span>{"🔊 Sound Effects"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enableSounds
? "✓ On"
: "Off"}
</span>
</button>
<button
className={`stat-toggle-btn ${
profileSettings.enableNotifications
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleNotificationsToggle}
type="button"
>
<span>{"🔔 Browser Notifications"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enableNotifications
? "✓ On"
: "Off"
}
</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 };
+244
View File
@@ -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 };
+105
View File
@@ -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,419 @@
/**
* @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 { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.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,
enableNotifications,
enableSounds,
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,
});
if (enableSounds) {
playSound("prestige");
}
if (enableNotifications) {
sendNotification(
"⭐ Prestige!",
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
);
}
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 };
+306
View File
@@ -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 };
+179
View File
@@ -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 };
+55
View File
@@ -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 };
+239
View File
@@ -0,0 +1,239 @@
/**
* @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>
<a
className="profile-link-button"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Get support on our forum"
>
{"🆘"} <span className="btn-label">{"Support"}</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 };