generated from nhcarrigan/template
feat: add companion system with quest-time reduction server validation
Introduces 10 unlockable companions (Lyra, Finn, Wren, Aldric, Sera, Kael, Zuri, Mira, Vex, Pria), each providing a unique bonus: passive gold, click gold, boss damage, essence income, or quest-time reduction. Quest-time reduction is validated server-side: computeQuestRewards applies the active companion's reduction to the effective duration check, and the income validation budget accounts for passive gold and essence bonuses. Server recomputes unlockedCompanionIds on every save using DB-authoritative lifetime stats and validates the active companion ID. Companion bonuses are also applied in the client tick engine and boss.ts calculatePartyStats.
This commit is contained in:
@@ -87,6 +87,10 @@ const HOW_TO_PLAY = [
|
||||
title: "🤖 Auto-Quest & Auto-Boss",
|
||||
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: "👥 Companions",
|
||||
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: "☁️ Cloud Saves",
|
||||
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.",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { COMPANIONS } from "@elysium/types";
|
||||
import type { Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const BONUS_LABELS: Record<string, string> = {
|
||||
passiveGold: "Passive Gold",
|
||||
clickGold: "Click Gold",
|
||||
bossDamage: "Boss Damage",
|
||||
essenceIncome: "Essence Income",
|
||||
questTime: "Quest Time",
|
||||
};
|
||||
|
||||
const UNLOCK_LABELS: Record<string, string> = {
|
||||
lifetimeBosses: "lifetime bosses defeated",
|
||||
lifetimeQuests: "lifetime quests completed",
|
||||
lifetimeGold: "lifetime gold earned",
|
||||
prestige: "prestige(s)",
|
||||
transcendence: "transcendence(s)",
|
||||
apotheosis: "apotheosis",
|
||||
};
|
||||
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
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();
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
const CompanionCard = ({
|
||||
companion,
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
companion: Companion;
|
||||
isUnlocked: boolean;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): React.JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime" ? "-" : "+";
|
||||
const bonusPercent = Math.round(companion.bonus.value * 100);
|
||||
const bonusLabel = BONUS_LABELS[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>}
|
||||
</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)} {UNLOCK_LABELS[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompanionPanel = (): React.JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
|
||||
if (!state) return <></>;
|
||||
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
const handleSelect = (companionId: string): void => {
|
||||
setActiveCompanion(activeId === companionId ? null : companionId);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<> Currently active: <strong>{COMPANIONS.find((c) => c.id === activeId)?.name ?? activeId}</strong>.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="companion-grid">
|
||||
{COMPANIONS.map((companion) => (
|
||||
<CompanionCard
|
||||
key={companion.id}
|
||||
companion={companion}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
isActive={activeId === companion.id}
|
||||
onSelect={() => { handleSelect(companion.id); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,10 +22,11 @@ import { UpgradePanel } from "./UpgradePanel.js";
|
||||
import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
import { ExplorationPanel } from "./ExplorationPanel.js";
|
||||
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
||||
import { CompanionPanel } from "./CompanionPanel.js";
|
||||
import { CraftingPanel } from "./CraftingPanel.js";
|
||||
import { LoginBonusModal } from "./LoginBonusModal.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions";
|
||||
|
||||
const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
@@ -40,6 +41,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ 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: "codex", label: "📖 Codex" },
|
||||
@@ -135,6 +137,7 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
|
||||
@@ -208,6 +208,8 @@ interface GameContextValue {
|
||||
loginStreak: number;
|
||||
/** Dismiss the login bonus modal */
|
||||
dismissLoginBonus: () => void;
|
||||
/** Set the active companion (null to deactivate) */
|
||||
setActiveCompanion: (companionId: string | null) => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -909,6 +911,21 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActiveCompanion = useCallback((companionId: string | null) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const unlockedIds = prev.companions?.unlockedCompanionIds ?? [];
|
||||
const validatedId = companionId !== null && unlockedIds.includes(companionId) ? companionId : null;
|
||||
return {
|
||||
...prev,
|
||||
companions: {
|
||||
unlockedCompanionIds: unlockedIds,
|
||||
activeCompanionId: validatedId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
@@ -995,6 +1012,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
dismissLoginBonus,
|
||||
setActiveCompanion,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
import { computeSetBonuses, getActiveCompanionBonus } from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
@@ -94,6 +94,14 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1;
|
||||
const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1;
|
||||
const companionQuestTimeReduction = companionBonus?.type === "questTime" ? companionBonus.value : 0;
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 0;
|
||||
|
||||
@@ -123,6 +131,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
equipmentGoldMultiplier *
|
||||
setGoldMultiplier *
|
||||
craftedGoldMultiplier *
|
||||
companionGoldMult *
|
||||
deltaSeconds;
|
||||
|
||||
essenceGained +=
|
||||
@@ -132,6 +141,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
prestige *
|
||||
runestonesEssence *
|
||||
craftedEssenceMultiplier *
|
||||
companionEssenceMult *
|
||||
deltaSeconds;
|
||||
}
|
||||
|
||||
@@ -146,10 +156,11 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
let updatedEquipment = state.equipment ?? [];
|
||||
|
||||
const updatedQuests = state.quests.map((quest) => {
|
||||
const effectiveQuestMs = quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000;
|
||||
if (
|
||||
quest.status !== "active" ||
|
||||
quest.startedAt == null ||
|
||||
now < quest.startedAt + quest.durationSeconds * 1000
|
||||
now < quest.startedAt + effectiveQuestMs
|
||||
) {
|
||||
return quest;
|
||||
}
|
||||
@@ -323,6 +334,12 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
|
||||
|
||||
const companionClickBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionClickMult = companionClickBonus?.type === "clickGold" ? 1 + companionClickBonus.value : 1;
|
||||
|
||||
return (
|
||||
state.baseClickPower *
|
||||
clickMultiplier *
|
||||
@@ -331,6 +348,7 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
echoIncome *
|
||||
equipmentClickMultiplier *
|
||||
setClickMultiplier *
|
||||
craftedClickMultiplier
|
||||
craftedClickMultiplier *
|
||||
companionClickMult
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3966,3 +3966,139 @@ body {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Companions ──────────────────────────────────────────────────────── */
|
||||
|
||||
.companion-panel h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.companion-intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--colour-muted);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.companion-intro strong {
|
||||
color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.companion-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.companion-card {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.companion-unlocked {
|
||||
border-color: var(--colour-border);
|
||||
}
|
||||
|
||||
.companion-locked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.companion-active {
|
||||
border-color: var(--colour-accent);
|
||||
box-shadow: 0 0 0 1px var(--colour-accent);
|
||||
}
|
||||
|
||||
.companion-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.companion-name-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.companion-name {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--colour-text);
|
||||
}
|
||||
|
||||
.companion-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--colour-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.companion-active-badge {
|
||||
background: var(--colour-accent);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.companion-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--colour-muted);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.companion-bonus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(120, 80, 200, 0.12);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.companion-bonus-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--colour-muted);
|
||||
}
|
||||
|
||||
.companion-bonus-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.companion-select-btn {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--colour-accent);
|
||||
background: transparent;
|
||||
color: var(--colour-accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.companion-select-btn:hover {
|
||||
background: var(--colour-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.companion-select-active {
|
||||
background: var(--colour-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.companion-unlock-requirement {
|
||||
font-size: 0.8rem;
|
||||
color: var(--colour-muted);
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user