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
@@ -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 };