From db860ee5d39e4b34202bf277ae42a77008c0dc95 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 15:59:24 -0800 Subject: [PATCH] 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. --- apps/api/src/data/initialState.ts | 1 + apps/api/src/routes/boss.ts | 11 +- apps/api/src/routes/game.ts | 60 ++++++- apps/web/src/components/game/AboutPanel.tsx | 4 + .../src/components/game/CompanionPanel.tsx | 117 +++++++++++++ apps/web/src/components/game/GameLayout.tsx | 5 +- apps/web/src/context/GameContext.tsx | 18 ++ apps/web/src/engine/tick.ts | 24 ++- apps/web/src/styles.css | 136 +++++++++++++++ packages/types/src/index.ts | 9 + packages/types/src/interfaces/Companion.ts | 163 ++++++++++++++++++ packages/types/src/interfaces/GameState.ts | 3 + 12 files changed, 539 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/game/CompanionPanel.tsx create mode 100644 packages/types/src/interfaces/Companion.ts diff --git a/apps/api/src/data/initialState.ts b/apps/api/src/data/initialState.ts index d01a51a..898e528 100644 --- a/apps/api/src/data/initialState.ts +++ b/apps/api/src/data/initialState.ts @@ -69,4 +69,5 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS transcendence: { ...INITIAL_TRANSCENDENCE }, apotheosis: { ...INITIAL_APOTHEOSIS }, exploration: structuredClone(INITIAL_EXPLORATION), + companions: { unlockedCompanionIds: [], activeCompanionId: null }, }); diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index 0c06a67..d86644b 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -1,5 +1,5 @@ import type { BossChallengeResponse, GameState } from "@elysium/types"; -import { computeSetBonuses } from "@elysium/types"; +import { computeSetBonuses, getActiveCompanionBonus } from "@elysium/types"; import { Hono } from "hono"; import type { HonoEnv } from "../types/hono.js"; import { prisma } from "../db/client.js"; @@ -61,7 +61,14 @@ const calculatePartyStats = ( const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 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 }; }; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 2ec1d77..82f31b3 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -1,5 +1,5 @@ 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 { Hono } from "hono"; import type { HonoEnv } from "../types/hono.js"; @@ -58,6 +58,13 @@ const computeMaxPassiveIncome = ( 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; + let goldPerSecond = 0; let essencePerSecond = 0; @@ -94,7 +101,7 @@ const computeMaxPassiveIncome = ( 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 companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionClickMult = companionBonus?.type === "clickGold" ? 1 + companionBonus.value : 1; + const clickPower = state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * runestonesClick * equipmentClickMultiplier * - setClickMultiplier; + setClickMultiplier * + companionClickMult; return clickPower * CLICK_BUFFER_CPS; }; @@ -134,12 +148,14 @@ const computeMaxClickGoldPerSecond = (state: GameState): number => { * - It is now "completed" in the incoming state. * * 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 = ( incoming: GameState, previous: GameState, now: number, + questTimeReduction: number, ): { gold: number; essence: number } => { let gold = 0; let essence = 0; @@ -157,7 +173,9 @@ const computeQuestRewards = ( const questData = DEFAULT_QUESTS.find((q) => q.id === incomingQuest.id); 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) { 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 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. - const questRewards = computeQuestRewards(incoming, previous, now); + const questRewards = computeQuestRewards(incoming, previous, now, questTimeReduction); const bossRewards = computeBossRewards(incoming, previous); // 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 }, }; + // 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 newTitles = checkAndUnlockTitles( currentUnlocked, diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index bd7ced4..e656db9 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -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.", diff --git a/apps/web/src/components/game/CompanionPanel.tsx b/apps/web/src/components/game/CompanionPanel.tsx new file mode 100644 index 0000000..648f9e1 --- /dev/null +++ b/apps/web/src/components/game/CompanionPanel.tsx @@ -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 = { + passiveGold: "Passive Gold", + clickGold: "Click Gold", + bossDamage: "Boss Damage", + essenceIncome: "Essence Income", + questTime: "Quest Time", +}; + +const UNLOCK_LABELS: Record = { + 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 ( +
+
+
+ {companion.name} + {companion.title} +
+ {isActive && Active} +
+ +

{companion.description}

+ +
+ {bonusLabel} + {bonusSign}{bonusPercent}% +
+ + {isUnlocked ? ( + + ) : ( +
+ 🔒 Unlock: {formatThreshold(companion.unlock.type, companion.unlock.threshold)} {UNLOCK_LABELS[companion.unlock.type] ?? companion.unlock.type} +
+ )} +
+ ); +}; + +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 ( +
+

👥 Companions

+

+ Companions provide powerful bonuses while active. You can only have one companion active at a time. + {activeId && ( + <> Currently active: {COMPANIONS.find((c) => c.id === activeId)?.name ?? activeId}. + )} +

+ +
+ {COMPANIONS.map((companion) => ( + { handleSelect(companion.id); }} + /> + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index e1d451e..2dec00a 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -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" && } {activeTab === "statistics" && } {activeTab === "daily" && } + {activeTab === "companions" && } {activeTab === "character" && } {activeTab === "codex" && } {activeTab === "about" && } diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index b7714d6..04059ae 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -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(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} diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 965a573..2f96823 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -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 ); }; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 63ca4ed..c4e3ac7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e313b98..73b6a4f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,13 @@ 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 { ExplorationArea, diff --git a/packages/types/src/interfaces/Companion.ts b/packages/types/src/interfaces/Companion.ts new file mode 100644 index 0000000..ae21c93 --- /dev/null +++ b/packages/types/src/interfaces/Companion.ts @@ -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; +}; diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index f34ba04..7f0ee82 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -3,6 +3,7 @@ import type { Adventurer } from "./Adventurer.js"; import type { Boss } from "./Boss.js"; import type { ApotheosisData } from "./Apotheosis.js"; import type { CodexState } from "./Codex.js"; +import type { CompanionState } from "./Companion.js"; import type { DailyChallengeState } from "./DailyChallenge.js"; import type { ExplorationState } from "./Exploration.js"; import type { TranscendenceData } from "./Transcendence.js"; @@ -43,4 +44,6 @@ export interface GameState { autoQuest?: boolean; /** When true, the tick engine automatically challenges the highest available boss */ autoBoss?: boolean; + /** Companion unlock and active selection state — optional for backwards compatibility */ + companions?: CompanionState; }