feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
12 changed files with 539 additions and 12 deletions
Showing only changes of commit db860ee5d3 - Show all commits
+1
View File
@@ -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 },
});
+9 -2
View File
@@ -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 };
};
+54 -6
View File
@@ -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
* (01 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,
@@ -87,6 +87,10 @@ const HOW_TO_PLAY = [
title: "🤖 Auto-Quest & Auto-Boss",
body: "Toggle automation in the Quests and Boss Encounters panels! Auto-Quest automatically sends your party on the highest-zone available quest as soon as one completes, skipping quests whose combat power requirement isn't met. Auto-Boss automatically challenges the highest available boss as soon as one is ready. Both can be toggled on or off at any time using the 🤖 Auto button in each panel header.",
},
{
title: "👥 Companions",
body: "Unlock companions by reaching certain milestones across all your runs. Each companion provides a powerful permanent bonus: increased passive gold, click gold, boss damage, essence income, or reduced quest time. You can only have one companion active at a time — choose wisely based on your current strategy! Companions are unlocked permanently once their condition is met and will never be lost.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
@@ -0,0 +1,117 @@
import { COMPANIONS } from "@elysium/types";
import type { Companion } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
const BONUS_LABELS: Record<string, string> = {
passiveGold: "Passive Gold",
clickGold: "Click Gold",
bossDamage: "Boss Damage",
essenceIncome: "Essence Income",
questTime: "Quest Time",
};
const UNLOCK_LABELS: Record<string, string> = {
lifetimeBosses: "lifetime bosses defeated",
lifetimeQuests: "lifetime quests completed",
lifetimeGold: "lifetime gold earned",
prestige: "prestige(s)",
transcendence: "transcendence(s)",
apotheosis: "apotheosis",
};
const formatThreshold = (type: string, threshold: number): string => {
if (type === "lifetimeGold") {
if (threshold >= 1e12) return `${(threshold / 1e12).toFixed(0)}T`;
if (threshold >= 1e9) return `${(threshold / 1e9).toFixed(0)}B`;
if (threshold >= 1e6) return `${(threshold / 1e6).toFixed(0)}M`;
if (threshold >= 1e3) return `${(threshold / 1e3).toFixed(0)}K`;
return threshold.toString();
}
return threshold.toString();
};
const CompanionCard = ({
companion,
isUnlocked,
isActive,
onSelect,
}: {
companion: Companion;
isUnlocked: boolean;
isActive: boolean;
onSelect: () => void;
}): React.JSX.Element => {
const bonusSign = companion.bonus.type === "questTime" ? "-" : "+";
const bonusPercent = Math.round(companion.bonus.value * 100);
const bonusLabel = BONUS_LABELS[companion.bonus.type] ?? companion.bonus.type;
return (
<div className={`companion-card ${isUnlocked ? "companion-unlocked" : "companion-locked"} ${isActive ? "companion-active" : ""}`}>
<div className="companion-header">
<div className="companion-name-block">
<span className="companion-name">{companion.name}</span>
<span className="companion-title">{companion.title}</span>
</div>
{isActive && <span className="companion-active-badge">Active</span>}
</div>
<p className="companion-description">{companion.description}</p>
<div className="companion-bonus">
<span className="companion-bonus-label">{bonusLabel}</span>
<span className="companion-bonus-value">{bonusSign}{bonusPercent}%</span>
</div>
{isUnlocked ? (
<button
className={`companion-select-btn ${isActive ? "companion-select-active" : ""}`}
onClick={onSelect}
type="button"
>
{isActive ? "Deactivate" : "Activate"}
</button>
) : (
<div className="companion-unlock-requirement">
🔒 Unlock: {formatThreshold(companion.unlock.type, companion.unlock.threshold)} {UNLOCK_LABELS[companion.unlock.type] ?? companion.unlock.type}
</div>
)}
</div>
);
};
export const CompanionPanel = (): React.JSX.Element => {
const { state, setActiveCompanion } = useGame();
if (!state) return <></>;
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
const activeId = state.companions?.activeCompanionId ?? null;
const handleSelect = (companionId: string): void => {
setActiveCompanion(activeId === companionId ? null : companionId);
};
return (
<div className="companion-panel">
<h2>👥 Companions</h2>
<p className="companion-intro">
Companions provide powerful bonuses while active. You can only have one companion active at a time.
{activeId && (
<> Currently active: <strong>{COMPANIONS.find((c) => c.id === activeId)?.name ?? activeId}</strong>.</>
)}
</p>
<div className="companion-grid">
{COMPANIONS.map((companion) => (
<CompanionCard
key={companion.id}
companion={companion}
isUnlocked={unlockedIds.includes(companion.id)}
isActive={activeId === companion.id}
onSelect={() => { handleSelect(companion.id); }}
/>
))}
</div>
</div>
);
};
+4 -1
View File
@@ -22,10 +22,11 @@ import { UpgradePanel } from "./UpgradePanel.js";
import { DailyChallengePanel } from "./DailyChallengePanel.js";
import { ExplorationPanel } from "./ExplorationPanel.js";
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
import { CompanionPanel } from "./CompanionPanel.js";
import { CraftingPanel } from "./CraftingPanel.js";
import { LoginBonusModal } from "./LoginBonusModal.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions";
const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
@@ -40,6 +41,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "transcendence", label: "🌌 Transcendence" },
{ id: "apotheosis", label: "✨ Apotheosis" },
{ id: "statistics", label: "📊 Statistics" },
{ id: "companions", label: "👥 Companions" },
{ id: "character", label: "📋 Character" },
{ id: "achievements", label: "🏆 Achievements" },
{ id: "codex", label: "📖 Codex" },
@@ -135,6 +137,7 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "crafting" && <CraftingPanel />}
{activeTab === "statistics" && <StatisticsPanel />}
{activeTab === "daily" && <DailyChallengePanel />}
{activeTab === "companions" && <CompanionPanel />}
{activeTab === "character" && <CharacterSheetPanel />}
{activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />}
+18
View File
@@ -208,6 +208,8 @@ interface GameContextValue {
loginStreak: number;
/** Dismiss the login bonus modal */
dismissLoginBonus: () => void;
/** Set the active companion (null to deactivate) */
setActiveCompanion: (companionId: string | null) => void;
}
const GameContext = createContext<GameContextValue | null>(null);
@@ -909,6 +911,21 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
});
}, []);
const setActiveCompanion = useCallback((companionId: string | null) => {
setState((prev) => {
if (!prev) return prev;
const unlockedIds = prev.companions?.unlockedCompanionIds ?? [];
const validatedId = companionId !== null && unlockedIds.includes(companionId) ? companionId : null;
return {
...prev,
companions: {
unlockedCompanionIds: unlockedIds,
activeCompanionId: validatedId,
},
};
});
}, []);
const challengeBoss = useCallback(async (bossId: string) => {
if (!stateRef.current) return;
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
@@ -995,6 +1012,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
loginBonus,
loginStreak,
dismissLoginBonus,
setActiveCompanion,
}}
>
{children}
+21 -3
View File
@@ -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
);
};
+136
View File
@@ -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;
}
+9
View File
@@ -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,
+163
View File
@@ -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 { 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;
}