generated from nhcarrigan/template
feat: add equipment set bonuses and boss bounty runestones
- Define EquipmentSet type + computeSetBonuses utility in packages/types - Add setId field to Equipment type and assign sets to 27 equipment items - Create 9 named equipment sets (Iron Vanguard → Eternal Throne) with 2pc/3pc bonuses - Apply set combat multiplier in boss route - Apply set gold/click multipliers in tick engine and click handler - Include set bonuses in anti-cheat delta validation - Show active set bonus strip + set badge per card in EquipmentPanel - Add boss first-kill bounty runestones (scaling 1–10 per boss tier) - Update AboutPanel and IDEAS.md
This commit is contained in:
@@ -28,8 +28,8 @@ const HOW_TO_PLAY = [
|
||||
body: "New zones unlock when you defeat the final boss AND complete the final quest of the previous zone. Each zone contains new bosses and quests with progressively greater rewards.",
|
||||
},
|
||||
{
|
||||
title: "🗡️ Equipment",
|
||||
body: "Earn equipment from boss drops and quest rewards. Each piece of equipment provides bonuses to gold income, click power, or adventurer output. Rarer equipment provides stronger bonuses.",
|
||||
title: "🗡️ Equipment & Sets",
|
||||
body: "Earn equipment from boss drops and quest rewards. Each piece provides bonuses to gold income, click power, or combat. Rarer equipment provides stronger bonuses. Equip matching set pieces (2 or 3 of a named set) to unlock escalating set bonuses shown at the top of the Equipment panel.",
|
||||
},
|
||||
{
|
||||
title: "⭐ Prestige",
|
||||
|
||||
@@ -133,6 +133,9 @@ export const BattleModal = ({
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -70,6 +70,9 @@ const BossCard = ({
|
||||
{(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") && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const RARITY_LABEL: Record<string, string> = {
|
||||
@@ -36,6 +37,7 @@ interface EquipmentCardProps {
|
||||
essence: number;
|
||||
crystals: number;
|
||||
dropBossName?: string | undefined;
|
||||
setName?: string | undefined;
|
||||
}
|
||||
|
||||
const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => {
|
||||
@@ -46,7 +48,7 @@ const costLabel = (cost: { gold: number; essence: number; crystals: number }): s
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const EquipmentCard = ({ item, gold, essence, crystals, dropBossName }: EquipmentCardProps): React.JSX.Element => {
|
||||
const EquipmentCard = ({ item, gold, essence, crystals, dropBossName, setName }: EquipmentCardProps): React.JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford = item.cost
|
||||
@@ -63,6 +65,7 @@ const EquipmentCard = ({ item, gold, essence, crystals, dropBossName }: Equipmen
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName && <span className="equipment-set-badge">🔗 {setName}</span>}
|
||||
{!item.owned && item.cost && (
|
||||
<p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
)}
|
||||
@@ -121,6 +124,31 @@ export const EquipmentPanel = (): React.JSX.Element => {
|
||||
}
|
||||
}
|
||||
|
||||
// Build set name lookup for card badges
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((s) => [s.id, s.name]),
|
||||
);
|
||||
|
||||
// Compute active set bonuses for the summary strip
|
||||
const equippedItemIds = equipment.filter((e) => e.equipped).map((e) => e.id);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length;
|
||||
return { set, count };
|
||||
}).filter(({ count }) => count >= 2);
|
||||
|
||||
const setBonusDescription = (set: typeof EQUIPMENT_SETS[number], count: number): string => {
|
||||
const parts: string[] = [];
|
||||
for (const threshold of [2, 3] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = set.bonuses[threshold];
|
||||
if (bonus.goldMultiplier) parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold/s (${threshold}pc)`);
|
||||
if (bonus.combatMultiplier) parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat (${threshold}pc)`);
|
||||
if (bonus.clickMultiplier) parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click (${threshold}pc)`);
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
@@ -132,9 +160,21 @@ export const EquipmentPanel = (): React.JSX.Element => {
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time.
|
||||
Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time. Equip matching set pieces for bonus effects!
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0 && (
|
||||
<div className="active-sets">
|
||||
<h3 className="active-sets-heading">✨ Active Set Bonuses</h3>
|
||||
{activeSets.map(({ set, count }) => (
|
||||
<div key={set.id} className="active-set-row">
|
||||
<span className="active-set-name">{set.name} ({count}/{set.pieces.length})</span>
|
||||
<span className="active-set-bonus">{setBonusDescription(set, count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SLOT_ORDER.map((slotType) => {
|
||||
const items = equipment.filter(
|
||||
(e) => e.type === slotType && (showLocked || e.owned),
|
||||
@@ -151,6 +191,7 @@ export const EquipmentPanel = (): React.JSX.Element => {
|
||||
essence={state.resources.essence}
|
||||
crystals={state.resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
setName={item.setId ? setNameById.get(item.setId) : undefined}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { EquipmentSet } from "@elysium/types";
|
||||
|
||||
export const EQUIPMENT_SETS: EquipmentSet[] = [
|
||||
{
|
||||
id: "iron_vanguard",
|
||||
name: "Iron Vanguard",
|
||||
description: "The armaments of a seasoned guild soldier — proven steel, reliable gold.",
|
||||
pieces: ["iron_sword", "chainmail", "mages_focus"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.1 },
|
||||
3: { combatMultiplier: 1.1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shadow_infiltrator",
|
||||
name: "Shadow Infiltrator",
|
||||
description: "Gear forged from the Shadow Marshes themselves — unseen, unstoppable.",
|
||||
pieces: ["shadow_dagger", "void_shroud", "void_compass"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.15 },
|
||||
3: { clickMultiplier: 1.2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "volcanic_forger",
|
||||
name: "Volcanic Forger",
|
||||
description: "Weapons and armour tempered in the depths of the Volcanic Reaches.",
|
||||
pieces: ["flame_lance", "volcanic_plate", "crystal_shard"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.15 },
|
||||
3: { goldMultiplier: 1.15 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "celestial_guardian",
|
||||
name: "Celestial Guardian",
|
||||
description: "Relics of the Celestial Reaches — divine power made manifest.",
|
||||
pieces: ["seraph_wing", "celestial_armour", "angels_halo"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.2 },
|
||||
3: { goldMultiplier: 1.2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "abyssal_predator",
|
||||
name: "Abyssal Predator",
|
||||
description: "Trophies reclaimed from the deepest trenches of the Abyssal Reaches.",
|
||||
pieces: ["depth_blade", "pressure_plate", "leviathan_eye"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.2 },
|
||||
3: { clickMultiplier: 1.25 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "infernal_conqueror",
|
||||
name: "Infernal Conqueror",
|
||||
description: "Forged in the heart of the Infernal Court from the essence of the defeated.",
|
||||
pieces: ["hellfire_edge", "demon_hide", "soul_gem"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.25 },
|
||||
3: { goldMultiplier: 1.25 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "crystal_domain",
|
||||
name: "Crystal Domain",
|
||||
description: "Instruments of the Crystalline Spire — reality refracted into absolute efficiency.",
|
||||
pieces: ["prism_blade", "faceted_armour", "prism_eye"],
|
||||
bonuses: {
|
||||
2: { clickMultiplier: 1.25 },
|
||||
3: { goldMultiplier: 1.25 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "void_emperor",
|
||||
name: "Void Emperor",
|
||||
description: "The regalia of the Void Sanctum's lord — power carved from absolute nothingness.",
|
||||
pieces: ["void_annihilator", "eternal_shroud", "void_heart_gem"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.3 },
|
||||
3: { combatMultiplier: 1.3 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "eternal_throne",
|
||||
name: "Eternal Throne",
|
||||
description: "The armaments of the Eternal Throne — weapons and armour that have endured all of time.",
|
||||
pieces: ["throne_blade", "eternal_armour", "eternity_stone"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.35, goldMultiplier: 1.25 },
|
||||
3: { clickMultiplier: 1.35 },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
@@ -57,6 +59,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
(mult, e) => mult * (e.bonus.goldMultiplier ?? 1),
|
||||
1,
|
||||
);
|
||||
const setGoldMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).goldMultiplier;
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
@@ -88,6 +91,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
prestige *
|
||||
runestonesIncome *
|
||||
equipmentGoldMultiplier *
|
||||
setGoldMultiplier *
|
||||
deltaSeconds;
|
||||
|
||||
essenceGained +=
|
||||
@@ -228,7 +232,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
essence: newEssence,
|
||||
crystals: capResource(state.resources.crystals + questCrystals + challengeCrystals),
|
||||
},
|
||||
dailyChallenges: updatedDailyChallenges,
|
||||
...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}),
|
||||
player: {
|
||||
...state.player,
|
||||
totalGoldEarned: newTotalGoldEarned,
|
||||
@@ -271,9 +275,11 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
.filter((u) => u.purchased && u.target === "click")
|
||||
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
|
||||
|
||||
const equipmentClickMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.clickMultiplier != null)
|
||||
const equippedItems = (state.equipment ?? []).filter((e) => e.equipped);
|
||||
const equipmentClickMultiplier = equippedItems
|
||||
.filter((e) => e.bonus.clickMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
|
||||
const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).clickMultiplier;
|
||||
|
||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||
|
||||
@@ -282,6 +288,7 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
clickMultiplier *
|
||||
state.prestige.productionMultiplier *
|
||||
runestonesClick *
|
||||
equipmentClickMultiplier
|
||||
equipmentClickMultiplier *
|
||||
setClickMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
@@ -498,6 +498,11 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.boss-bounty {
|
||||
color: #a78bfa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.attack-button {
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
@@ -862,6 +867,11 @@ body {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.battle-bounty {
|
||||
color: #a78bfa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
background: var(--colour-accent);
|
||||
border: none;
|
||||
@@ -945,6 +955,46 @@ body {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.active-sets {
|
||||
background: rgba(138, 43, 226, 0.08);
|
||||
border: 1px solid rgba(138, 43, 226, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.active-sets-heading {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #bf7fff;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.active-set-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.active-set-name {
|
||||
font-weight: 600;
|
||||
color: #d4a0ff;
|
||||
}
|
||||
|
||||
.active-set-bonus {
|
||||
color: var(--colour-text-muted);
|
||||
}
|
||||
|
||||
.equipment-set-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: #bf7fff;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.equipment-slot-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user