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