generated from nhcarrigan/template
feat: v1 prototype — core game systems #30
@@ -69,4 +69,5 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS
|
|||||||
transcendence: { ...INITIAL_TRANSCENDENCE },
|
transcendence: { ...INITIAL_TRANSCENDENCE },
|
||||||
apotheosis: { ...INITIAL_APOTHEOSIS },
|
apotheosis: { ...INITIAL_APOTHEOSIS },
|
||||||
exploration: structuredClone(INITIAL_EXPLORATION),
|
exploration: structuredClone(INITIAL_EXPLORATION),
|
||||||
|
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { BossChallengeResponse, GameState } from "@elysium/types";
|
import type { BossChallengeResponse, GameState } from "@elysium/types";
|
||||||
import { computeSetBonuses } from "@elysium/types";
|
import { computeSetBonuses, getActiveCompanionBonus } from "@elysium/types";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { HonoEnv } from "../types/hono.js";
|
import type { HonoEnv } from "../types/hono.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
@@ -61,7 +61,14 @@ const calculatePartyStats = (
|
|||||||
|
|
||||||
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
||||||
const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1;
|
const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1;
|
||||||
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier * craftedCombatMultiplier;
|
|
||||||
|
const companionBonus = getActiveCompanionBonus(
|
||||||
|
state.companions?.activeCompanionId,
|
||||||
|
state.companions?.unlockedCompanionIds ?? [],
|
||||||
|
);
|
||||||
|
const companionCombatMult = companionBonus?.type === "bossDamage" ? 1 + companionBonus.value : 1;
|
||||||
|
|
||||||
|
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier * craftedCombatMultiplier * companionCombatMult;
|
||||||
|
|
||||||
return { partyDPS, partyMaxHp };
|
return { partyDPS, partyMaxHp };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GameState, LoginBonusResult, SaveRequest } from "@elysium/types";
|
import type { GameState, LoginBonusResult, SaveRequest } from "@elysium/types";
|
||||||
import { computeSetBonuses } from "@elysium/types";
|
import { computeSetBonuses, computeUnlockedCompanionIds, getActiveCompanionBonus } from "@elysium/types";
|
||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { HonoEnv } from "../types/hono.js";
|
import type { HonoEnv } from "../types/hono.js";
|
||||||
@@ -58,6 +58,13 @@ const computeMaxPassiveIncome = (
|
|||||||
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||||
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 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;
|
||||||
|
|
||||||
let goldPerSecond = 0;
|
let goldPerSecond = 0;
|
||||||
let essencePerSecond = 0;
|
let essencePerSecond = 0;
|
||||||
|
|
||||||
@@ -94,7 +101,7 @@ const computeMaxPassiveIncome = (
|
|||||||
craftedEssenceMultiplier;
|
craftedEssenceMultiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { goldPerSecond, essencePerSecond };
|
return { goldPerSecond: goldPerSecond * companionGoldMult, essencePerSecond: essencePerSecond * companionEssenceMult };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,13 +122,20 @@ const computeMaxClickGoldPerSecond = (state: GameState): number => {
|
|||||||
|
|
||||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||||
|
|
||||||
|
const companionBonus = getActiveCompanionBonus(
|
||||||
|
state.companions?.activeCompanionId,
|
||||||
|
state.companions?.unlockedCompanionIds ?? [],
|
||||||
|
);
|
||||||
|
const companionClickMult = companionBonus?.type === "clickGold" ? 1 + companionBonus.value : 1;
|
||||||
|
|
||||||
const clickPower =
|
const clickPower =
|
||||||
state.baseClickPower *
|
state.baseClickPower *
|
||||||
clickMultiplier *
|
clickMultiplier *
|
||||||
state.prestige.productionMultiplier *
|
state.prestige.productionMultiplier *
|
||||||
runestonesClick *
|
runestonesClick *
|
||||||
equipmentClickMultiplier *
|
equipmentClickMultiplier *
|
||||||
setClickMultiplier;
|
setClickMultiplier *
|
||||||
|
companionClickMult;
|
||||||
|
|
||||||
return clickPower * CLICK_BUFFER_CPS;
|
return clickPower * CLICK_BUFFER_CPS;
|
||||||
};
|
};
|
||||||
@@ -134,12 +148,14 @@ const computeMaxClickGoldPerSecond = (state: GameState): number => {
|
|||||||
* - It is now "completed" in the incoming state.
|
* - It is now "completed" in the incoming state.
|
||||||
*
|
*
|
||||||
* Reward amounts and durations are taken from DEFAULT_QUESTS (authoritative game data)
|
* Reward amounts and durations are taken from DEFAULT_QUESTS (authoritative game data)
|
||||||
* to prevent client-side reward or duration tampering.
|
* to prevent client-side reward or duration tampering. The questTimeReduction parameter
|
||||||
|
* (0–1 fraction) applies a companion time bonus to the effective duration check.
|
||||||
*/
|
*/
|
||||||
const computeQuestRewards = (
|
const computeQuestRewards = (
|
||||||
incoming: GameState,
|
incoming: GameState,
|
||||||
previous: GameState,
|
previous: GameState,
|
||||||
now: number,
|
now: number,
|
||||||
|
questTimeReduction: number,
|
||||||
): { gold: number; essence: number } => {
|
): { gold: number; essence: number } => {
|
||||||
let gold = 0;
|
let gold = 0;
|
||||||
let essence = 0;
|
let essence = 0;
|
||||||
@@ -157,7 +173,9 @@ const computeQuestRewards = (
|
|||||||
const questData = DEFAULT_QUESTS.find((q) => q.id === incomingQuest.id);
|
const questData = DEFAULT_QUESTS.find((q) => q.id === incomingQuest.id);
|
||||||
if (!questData) continue;
|
if (!questData) continue;
|
||||||
|
|
||||||
if (prevQuest.startedAt + questData.durationSeconds * 1000 > now + QUEST_GRACE_MS) continue;
|
// Apply companion quest-time reduction to the effective duration check.
|
||||||
|
const effectiveDuration = questData.durationSeconds * (1 - questTimeReduction);
|
||||||
|
if (prevQuest.startedAt + effectiveDuration * 1000 > now + QUEST_GRACE_MS) continue;
|
||||||
|
|
||||||
for (const reward of questData.rewards) {
|
for (const reward of questData.rewards) {
|
||||||
if (reward.type === "gold" && reward.amount != null) gold += reward.amount;
|
if (reward.type === "gold" && reward.amount != null) gold += reward.amount;
|
||||||
@@ -230,8 +248,15 @@ const validateAndSanitize = (incoming: GameState, previous: GameState): GameStat
|
|||||||
const { goldPerSecond, essencePerSecond } = computeMaxPassiveIncome(previous);
|
const { goldPerSecond, essencePerSecond } = computeMaxPassiveIncome(previous);
|
||||||
const clickGoldPerSecond = computeMaxClickGoldPerSecond(previous);
|
const clickGoldPerSecond = computeMaxClickGoldPerSecond(previous);
|
||||||
|
|
||||||
|
// Determine quest-time reduction from the companion active in the previous (trusted) state.
|
||||||
|
const prevCompanionBonus = getActiveCompanionBonus(
|
||||||
|
previous.companions?.activeCompanionId,
|
||||||
|
previous.companions?.unlockedCompanionIds ?? [],
|
||||||
|
);
|
||||||
|
const questTimeReduction = prevCompanionBonus?.type === "questTime" ? prevCompanionBonus.value : 0;
|
||||||
|
|
||||||
// Precise one-time rewards for events that could have occurred this interval.
|
// Precise one-time rewards for events that could have occurred this interval.
|
||||||
const questRewards = computeQuestRewards(incoming, previous, now);
|
const questRewards = computeQuestRewards(incoming, previous, now, questTimeReduction);
|
||||||
const bossRewards = computeBossRewards(incoming, previous);
|
const bossRewards = computeBossRewards(incoming, previous);
|
||||||
|
|
||||||
// Passive and click income receive a 2× buffer to cover mid-session adventurer/upgrade
|
// Passive and click income receive a 2× buffer to cover mid-session adventurer/upgrade
|
||||||
@@ -775,6 +800,29 @@ gameRouter.post("/save", async (context) => {
|
|||||||
player: { ...stateToSave.player, lastSavedAt: now },
|
player: { ...stateToSave.player, lastSavedAt: now },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||||
|
// This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||||
|
const companionUnlocks = computeUnlockedCompanionIds({
|
||||||
|
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||||
|
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||||
|
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
||||||
|
prestigeCount: stateToSave.prestige.count,
|
||||||
|
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||||
|
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||||
|
});
|
||||||
|
const clientActiveCompanionId = stateToSave.companions?.activeCompanionId ?? null;
|
||||||
|
const validatedActiveCompanionId =
|
||||||
|
clientActiveCompanionId !== null && companionUnlocks.includes(clientActiveCompanionId)
|
||||||
|
? clientActiveCompanionId
|
||||||
|
: null;
|
||||||
|
stateToSave = {
|
||||||
|
...stateToSave,
|
||||||
|
companions: {
|
||||||
|
unlockedCompanionIds: companionUnlocks,
|
||||||
|
activeCompanionId: validatedActiveCompanionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
||||||
const newTitles = checkAndUnlockTitles(
|
const newTitles = checkAndUnlockTitles(
|
||||||
currentUnlocked,
|
currentUnlocked,
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ const HOW_TO_PLAY = [
|
|||||||
title: "🤖 Auto-Quest & Auto-Boss",
|
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.",
|
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",
|
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.",
|
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 { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||||
import { ExplorationPanel } from "./ExplorationPanel.js";
|
import { ExplorationPanel } from "./ExplorationPanel.js";
|
||||||
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
||||||
|
import { CompanionPanel } from "./CompanionPanel.js";
|
||||||
import { CraftingPanel } from "./CraftingPanel.js";
|
import { CraftingPanel } from "./CraftingPanel.js";
|
||||||
import { LoginBonusModal } from "./LoginBonusModal.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 }[] = [
|
const BASE_TABS: { id: Tab; label: string }[] = [
|
||||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||||
@@ -40,6 +41,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
|||||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||||
{ id: "statistics", label: "📊 Statistics" },
|
{ id: "statistics", label: "📊 Statistics" },
|
||||||
|
{ id: "companions", label: "👥 Companions" },
|
||||||
{ id: "character", label: "📋 Character" },
|
{ id: "character", label: "📋 Character" },
|
||||||
{ id: "achievements", label: "🏆 Achievements" },
|
{ id: "achievements", label: "🏆 Achievements" },
|
||||||
{ id: "codex", label: "📖 Codex" },
|
{ id: "codex", label: "📖 Codex" },
|
||||||
@@ -135,6 +137,7 @@ export const GameLayout = (): React.JSX.Element => {
|
|||||||
{activeTab === "crafting" && <CraftingPanel />}
|
{activeTab === "crafting" && <CraftingPanel />}
|
||||||
{activeTab === "statistics" && <StatisticsPanel />}
|
{activeTab === "statistics" && <StatisticsPanel />}
|
||||||
{activeTab === "daily" && <DailyChallengePanel />}
|
{activeTab === "daily" && <DailyChallengePanel />}
|
||||||
|
{activeTab === "companions" && <CompanionPanel />}
|
||||||
{activeTab === "character" && <CharacterSheetPanel />}
|
{activeTab === "character" && <CharacterSheetPanel />}
|
||||||
{activeTab === "codex" && <CodexPanel />}
|
{activeTab === "codex" && <CodexPanel />}
|
||||||
{activeTab === "about" && <AboutPanel />}
|
{activeTab === "about" && <AboutPanel />}
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ interface GameContextValue {
|
|||||||
loginStreak: number;
|
loginStreak: number;
|
||||||
/** Dismiss the login bonus modal */
|
/** Dismiss the login bonus modal */
|
||||||
dismissLoginBonus: () => void;
|
dismissLoginBonus: () => void;
|
||||||
|
/** Set the active companion (null to deactivate) */
|
||||||
|
setActiveCompanion: (companionId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameContext = createContext<GameContextValue | null>(null);
|
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) => {
|
const challengeBoss = useCallback(async (bossId: string) => {
|
||||||
if (!stateRef.current) return;
|
if (!stateRef.current) return;
|
||||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||||
@@ -995,6 +1012,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
loginBonus,
|
loginBonus,
|
||||||
loginStreak,
|
loginStreak,
|
||||||
dismissLoginBonus,
|
dismissLoginBonus,
|
||||||
|
setActiveCompanion,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
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 { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.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 craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||||
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 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 goldGained = 0;
|
||||||
let essenceGained = 0;
|
let essenceGained = 0;
|
||||||
|
|
||||||
@@ -123,6 +131,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
|||||||
equipmentGoldMultiplier *
|
equipmentGoldMultiplier *
|
||||||
setGoldMultiplier *
|
setGoldMultiplier *
|
||||||
craftedGoldMultiplier *
|
craftedGoldMultiplier *
|
||||||
|
companionGoldMult *
|
||||||
deltaSeconds;
|
deltaSeconds;
|
||||||
|
|
||||||
essenceGained +=
|
essenceGained +=
|
||||||
@@ -132,6 +141,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
|||||||
prestige *
|
prestige *
|
||||||
runestonesEssence *
|
runestonesEssence *
|
||||||
craftedEssenceMultiplier *
|
craftedEssenceMultiplier *
|
||||||
|
companionEssenceMult *
|
||||||
deltaSeconds;
|
deltaSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +156,11 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
|||||||
let updatedEquipment = state.equipment ?? [];
|
let updatedEquipment = state.equipment ?? [];
|
||||||
|
|
||||||
const updatedQuests = state.quests.map((quest) => {
|
const updatedQuests = state.quests.map((quest) => {
|
||||||
|
const effectiveQuestMs = quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000;
|
||||||
if (
|
if (
|
||||||
quest.status !== "active" ||
|
quest.status !== "active" ||
|
||||||
quest.startedAt == null ||
|
quest.startedAt == null ||
|
||||||
now < quest.startedAt + quest.durationSeconds * 1000
|
now < quest.startedAt + effectiveQuestMs
|
||||||
) {
|
) {
|
||||||
return quest;
|
return quest;
|
||||||
}
|
}
|
||||||
@@ -323,6 +334,12 @@ export const calculateClickPower = (state: GameState): number => {
|
|||||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||||
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 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 (
|
return (
|
||||||
state.baseClickPower *
|
state.baseClickPower *
|
||||||
clickMultiplier *
|
clickMultiplier *
|
||||||
@@ -331,6 +348,7 @@ export const calculateClickPower = (state: GameState): number => {
|
|||||||
echoIncome *
|
echoIncome *
|
||||||
equipmentClickMultiplier *
|
equipmentClickMultiplier *
|
||||||
setClickMultiplier *
|
setClickMultiplier *
|
||||||
craftedClickMultiplier
|
craftedClickMultiplier *
|
||||||
|
companionClickMult
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3966,3 +3966,139 @@ body {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
export type { ApotheosisData } from "./interfaces/Apotheosis.js";
|
export type { ApotheosisData } from "./interfaces/Apotheosis.js";
|
||||||
|
export type {
|
||||||
|
Companion,
|
||||||
|
CompanionBonus,
|
||||||
|
CompanionBonusType,
|
||||||
|
CompanionState,
|
||||||
|
CompanionUnlockCondition,
|
||||||
|
CompanionUnlockType,
|
||||||
|
} from "./interfaces/Companion.js";
|
||||||
|
export { COMPANIONS, computeUnlockedCompanionIds, getActiveCompanionBonus } from "./interfaces/Companion.js";
|
||||||
export type { CraftingBonusType, CraftingMaterialRequirement, CraftingRecipe } from "./interfaces/CraftingRecipe.js";
|
export type { CraftingBonusType, CraftingMaterialRequirement, CraftingRecipe } from "./interfaces/CraftingRecipe.js";
|
||||||
export type {
|
export type {
|
||||||
ExplorationArea,
|
ExplorationArea,
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
export type CompanionBonusType =
|
||||||
|
| "passiveGold"
|
||||||
|
| "clickGold"
|
||||||
|
| "bossDamage"
|
||||||
|
| "essenceIncome"
|
||||||
|
| "questTime";
|
||||||
|
|
||||||
|
export interface CompanionBonus {
|
||||||
|
type: CompanionBonusType;
|
||||||
|
/** Fractional value: for multiplier types, adds this fraction (0.25 = +25%). For questTime, reduces duration by this fraction (0.15 = 15% faster). */
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanionUnlockType =
|
||||||
|
| "lifetimeBosses"
|
||||||
|
| "lifetimeQuests"
|
||||||
|
| "lifetimeGold"
|
||||||
|
| "prestige"
|
||||||
|
| "transcendence"
|
||||||
|
| "apotheosis";
|
||||||
|
|
||||||
|
export interface CompanionUnlockCondition {
|
||||||
|
type: CompanionUnlockType;
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Companion {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
bonus: CompanionBonus;
|
||||||
|
unlock: CompanionUnlockCondition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanionState {
|
||||||
|
/** Companion IDs the player has unlocked — recomputed server-side on every save. */
|
||||||
|
unlockedCompanionIds: string[];
|
||||||
|
/** The ID of the currently active companion, or null for none. */
|
||||||
|
activeCompanionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMPANIONS: Companion[] = [
|
||||||
|
{
|
||||||
|
id: "lyra",
|
||||||
|
name: "Lyra",
|
||||||
|
title: "Wandering Minstrel",
|
||||||
|
description: "A cheerful bard whose uplifting songs inspire your adventurers to work harder for better coin.",
|
||||||
|
bonus: { type: "passiveGold", value: 0.25 },
|
||||||
|
unlock: { type: "lifetimeBosses", threshold: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "finn",
|
||||||
|
name: "Finn",
|
||||||
|
title: "Quick-Fingered Rogue",
|
||||||
|
description: "A nimble rogue whose sleight of hand ensures far more gold lands in your coffers with every strike.",
|
||||||
|
bonus: { type: "clickGold", value: 0.50 },
|
||||||
|
unlock: { type: "lifetimeQuests", threshold: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wren",
|
||||||
|
name: "Wren",
|
||||||
|
title: "Hedge Witch",
|
||||||
|
description: "A resourceful hedge witch who weaves minor enchantments that accelerate your quest parties.",
|
||||||
|
bonus: { type: "questTime", value: 0.15 },
|
||||||
|
unlock: { type: "lifetimeQuests", threshold: 50 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aldric",
|
||||||
|
name: "Aldric",
|
||||||
|
title: "Veteran Knight",
|
||||||
|
description: "A battle-hardened knight who leads your party with years of tactical experience against fearsome foes.",
|
||||||
|
bonus: { type: "bossDamage", value: 0.20 },
|
||||||
|
unlock: { type: "lifetimeBosses", threshold: 50 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sera",
|
||||||
|
name: "Sera",
|
||||||
|
title: "Arcane Alchemist",
|
||||||
|
description: "A brilliant alchemist who transmutes ambient magic into pure essence, bolstering your income.",
|
||||||
|
bonus: { type: "essenceIncome", value: 0.30 },
|
||||||
|
unlock: { type: "prestige", threshold: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kael",
|
||||||
|
name: "Kael",
|
||||||
|
title: "Battle Mage",
|
||||||
|
description: "A powerful battle mage whose devastating spells tear through even the toughest boss encounters.",
|
||||||
|
bonus: { type: "bossDamage", value: 0.40 },
|
||||||
|
unlock: { type: "lifetimeBosses", threshold: 250 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zuri",
|
||||||
|
name: "Zuri",
|
||||||
|
title: "Chrono Weaver",
|
||||||
|
description: "A time mage who bends the threads of time itself, significantly hastening your quest parties.",
|
||||||
|
bonus: { type: "questTime", value: 0.30 },
|
||||||
|
unlock: { type: "lifetimeQuests", threshold: 200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mira",
|
||||||
|
name: "Mira",
|
||||||
|
title: "Merchant Queen",
|
||||||
|
description: "A wealthy merchant whose golden touch and trade empire dramatically boosts your passive earnings.",
|
||||||
|
bonus: { type: "passiveGold", value: 0.75 },
|
||||||
|
unlock: { type: "lifetimeGold", threshold: 1e12 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vex",
|
||||||
|
name: "Vex",
|
||||||
|
title: "Shadow Broker",
|
||||||
|
description: "A shadowy information broker who channels essence from the void through forbidden knowledge.",
|
||||||
|
bonus: { type: "essenceIncome", value: 0.75 },
|
||||||
|
unlock: { type: "transcendence", threshold: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pria",
|
||||||
|
name: "Pria",
|
||||||
|
title: "Celestial Oracle",
|
||||||
|
description: "A divine oracle whose celestial blessing transforms the very air around you into golden fortune.",
|
||||||
|
bonus: { type: "passiveGold", value: 1.00 },
|
||||||
|
unlock: { type: "apotheosis", threshold: 1 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes which companion IDs the player has unlocked based on their lifetime stats.
|
||||||
|
* Called server-side on every save using DB-authoritative player stats.
|
||||||
|
*/
|
||||||
|
export const computeUnlockedCompanionIds = (params: {
|
||||||
|
lifetimeBossesDefeated: number;
|
||||||
|
lifetimeQuestsCompleted: number;
|
||||||
|
lifetimeGoldEarned: number;
|
||||||
|
prestigeCount: number;
|
||||||
|
transcendenceCount: number;
|
||||||
|
apotheosisCount: number;
|
||||||
|
}): string[] =>
|
||||||
|
COMPANIONS
|
||||||
|
.filter((companion) => {
|
||||||
|
const { type, threshold } = companion.unlock;
|
||||||
|
switch (type) {
|
||||||
|
case "lifetimeBosses": return params.lifetimeBossesDefeated >= threshold;
|
||||||
|
case "lifetimeQuests": return params.lifetimeQuestsCompleted >= threshold;
|
||||||
|
case "lifetimeGold": return params.lifetimeGoldEarned >= threshold;
|
||||||
|
case "prestige": return params.prestigeCount >= threshold;
|
||||||
|
case "transcendence": return params.transcendenceCount >= threshold;
|
||||||
|
case "apotheosis": return params.apotheosisCount >= threshold;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((companion) => companion.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the bonus of the active companion if it is unlocked, otherwise null.
|
||||||
|
* Safe to call with undefined/null activeCompanionId.
|
||||||
|
*/
|
||||||
|
export const getActiveCompanionBonus = (
|
||||||
|
activeCompanionId: string | null | undefined,
|
||||||
|
unlockedCompanionIds: string[],
|
||||||
|
): CompanionBonus | null => {
|
||||||
|
if (!activeCompanionId) return null;
|
||||||
|
if (!unlockedCompanionIds.includes(activeCompanionId)) return null;
|
||||||
|
return COMPANIONS.find((c) => c.id === activeCompanionId)?.bonus ?? null;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import type { Adventurer } from "./Adventurer.js";
|
|||||||
import type { Boss } from "./Boss.js";
|
import type { Boss } from "./Boss.js";
|
||||||
import type { ApotheosisData } from "./Apotheosis.js";
|
import type { ApotheosisData } from "./Apotheosis.js";
|
||||||
import type { CodexState } from "./Codex.js";
|
import type { CodexState } from "./Codex.js";
|
||||||
|
import type { CompanionState } from "./Companion.js";
|
||||||
import type { DailyChallengeState } from "./DailyChallenge.js";
|
import type { DailyChallengeState } from "./DailyChallenge.js";
|
||||||
import type { ExplorationState } from "./Exploration.js";
|
import type { ExplorationState } from "./Exploration.js";
|
||||||
import type { TranscendenceData } from "./Transcendence.js";
|
import type { TranscendenceData } from "./Transcendence.js";
|
||||||
@@ -43,4 +44,6 @@ export interface GameState {
|
|||||||
autoQuest?: boolean;
|
autoQuest?: boolean;
|
||||||
/** When true, the tick engine automatically challenges the highest available boss */
|
/** When true, the tick engine automatically challenges the highest available boss */
|
||||||
autoBoss?: boolean;
|
autoBoss?: boolean;
|
||||||
|
/** Companion unlock and active selection state — optional for backwards compatibility */
|
||||||
|
companions?: CompanionState;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user