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