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