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 },
|
||||
apotheosis: { ...INITIAL_APOTHEOSIS },
|
||||
exploration: structuredClone(INITIAL_EXPLORATION),
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,10 @@ const HOW_TO_PLAY = [
|
||||
title: "🤖 Auto-Quest & Auto-Boss",
|
||||
body: "Toggle automation in the Quests and Boss Encounters panels! Auto-Quest automatically sends your party on the highest-zone available quest as soon as one completes, skipping quests whose combat power requirement isn't met. Auto-Boss automatically challenges the highest available boss as soon as one is ready. Both can be toggled on or off at any time using the 🤖 Auto button in each panel header.",
|
||||
},
|
||||
{
|
||||
title: "👥 Companions",
|
||||
body: "Unlock companions by reaching certain milestones across all your runs. Each companion provides a powerful permanent bonus: increased passive gold, click gold, boss damage, essence income, or reduced quest time. You can only have one companion active at a time — choose wisely based on your current strategy! Companions are unlocked permanently once their condition is met and will never be lost.",
|
||||
},
|
||||
{
|
||||
title: "☁️ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { COMPANIONS } from "@elysium/types";
|
||||
import type { Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const BONUS_LABELS: Record<string, string> = {
|
||||
passiveGold: "Passive Gold",
|
||||
clickGold: "Click Gold",
|
||||
bossDamage: "Boss Damage",
|
||||
essenceIncome: "Essence Income",
|
||||
questTime: "Quest Time",
|
||||
};
|
||||
|
||||
const UNLOCK_LABELS: Record<string, string> = {
|
||||
lifetimeBosses: "lifetime bosses defeated",
|
||||
lifetimeQuests: "lifetime quests completed",
|
||||
lifetimeGold: "lifetime gold earned",
|
||||
prestige: "prestige(s)",
|
||||
transcendence: "transcendence(s)",
|
||||
apotheosis: "apotheosis",
|
||||
};
|
||||
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e12) return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
if (threshold >= 1e9) return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
if (threshold >= 1e6) return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
if (threshold >= 1e3) return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
return threshold.toString();
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
const CompanionCard = ({
|
||||
companion,
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
companion: Companion;
|
||||
isUnlocked: boolean;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): React.JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime" ? "-" : "+";
|
||||
const bonusPercent = Math.round(companion.bonus.value * 100);
|
||||
const bonusLabel = BONUS_LABELS[companion.bonus.type] ?? companion.bonus.type;
|
||||
|
||||
return (
|
||||
<div className={`companion-card ${isUnlocked ? "companion-unlocked" : "companion-locked"} ${isActive ? "companion-active" : ""}`}>
|
||||
<div className="companion-header">
|
||||
<div className="companion-name-block">
|
||||
<span className="companion-name">{companion.name}</span>
|
||||
<span className="companion-title">{companion.title}</span>
|
||||
</div>
|
||||
{isActive && <span className="companion-active-badge">Active</span>}
|
||||
</div>
|
||||
|
||||
<p className="companion-description">{companion.description}</p>
|
||||
|
||||
<div className="companion-bonus">
|
||||
<span className="companion-bonus-label">{bonusLabel}</span>
|
||||
<span className="companion-bonus-value">{bonusSign}{bonusPercent}%</span>
|
||||
</div>
|
||||
|
||||
{isUnlocked ? (
|
||||
<button
|
||||
className={`companion-select-btn ${isActive ? "companion-select-active" : ""}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{isActive ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="companion-unlock-requirement">
|
||||
🔒 Unlock: {formatThreshold(companion.unlock.type, companion.unlock.threshold)} {UNLOCK_LABELS[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompanionPanel = (): React.JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
|
||||
if (!state) return <></>;
|
||||
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
const handleSelect = (companionId: string): void => {
|
||||
setActiveCompanion(activeId === companionId ? null : companionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="companion-panel">
|
||||
<h2>👥 Companions</h2>
|
||||
<p className="companion-intro">
|
||||
Companions provide powerful bonuses while active. You can only have one companion active at a time.
|
||||
{activeId && (
|
||||
<> Currently active: <strong>{COMPANIONS.find((c) => c.id === activeId)?.name ?? activeId}</strong>.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="companion-grid">
|
||||
{COMPANIONS.map((companion) => (
|
||||
<CompanionCard
|
||||
key={companion.id}
|
||||
companion={companion}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
isActive={activeId === companion.id}
|
||||
onSelect={() => { handleSelect(companion.id); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,10 +22,11 @@ import { UpgradePanel } from "./UpgradePanel.js";
|
||||
import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
import { ExplorationPanel } from "./ExplorationPanel.js";
|
||||
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
||||
import { CompanionPanel } from "./CompanionPanel.js";
|
||||
import { CraftingPanel } from "./CraftingPanel.js";
|
||||
import { LoginBonusModal } from "./LoginBonusModal.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions";
|
||||
|
||||
const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
@@ -40,6 +41,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "companions", label: "👥 Companions" },
|
||||
{ id: "character", label: "📋 Character" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "codex", label: "📖 Codex" },
|
||||
@@ -135,6 +137,7 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
|
||||
@@ -208,6 +208,8 @@ interface GameContextValue {
|
||||
loginStreak: number;
|
||||
/** Dismiss the login bonus modal */
|
||||
dismissLoginBonus: () => void;
|
||||
/** Set the active companion (null to deactivate) */
|
||||
setActiveCompanion: (companionId: string | null) => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -909,6 +911,21 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActiveCompanion = useCallback((companionId: string | null) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const unlockedIds = prev.companions?.unlockedCompanionIds ?? [];
|
||||
const validatedId = companionId !== null && unlockedIds.includes(companionId) ? companionId : null;
|
||||
return {
|
||||
...prev,
|
||||
companions: {
|
||||
unlockedCompanionIds: unlockedIds,
|
||||
activeCompanionId: validatedId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
@@ -995,6 +1012,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
dismissLoginBonus,
|
||||
setActiveCompanion,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
import { computeSetBonuses, getActiveCompanionBonus } from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
@@ -94,6 +94,14 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1;
|
||||
const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1;
|
||||
const companionQuestTimeReduction = companionBonus?.type === "questTime" ? companionBonus.value : 0;
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 0;
|
||||
|
||||
@@ -123,6 +131,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
equipmentGoldMultiplier *
|
||||
setGoldMultiplier *
|
||||
craftedGoldMultiplier *
|
||||
companionGoldMult *
|
||||
deltaSeconds;
|
||||
|
||||
essenceGained +=
|
||||
@@ -132,6 +141,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
prestige *
|
||||
runestonesEssence *
|
||||
craftedEssenceMultiplier *
|
||||
companionEssenceMult *
|
||||
deltaSeconds;
|
||||
}
|
||||
|
||||
@@ -146,10 +156,11 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
let updatedEquipment = state.equipment ?? [];
|
||||
|
||||
const updatedQuests = state.quests.map((quest) => {
|
||||
const effectiveQuestMs = quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000;
|
||||
if (
|
||||
quest.status !== "active" ||
|
||||
quest.startedAt == null ||
|
||||
now < quest.startedAt + quest.durationSeconds * 1000
|
||||
now < quest.startedAt + effectiveQuestMs
|
||||
) {
|
||||
return quest;
|
||||
}
|
||||
@@ -323,6 +334,12 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
|
||||
|
||||
const companionClickBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionClickMult = companionClickBonus?.type === "clickGold" ? 1 + companionClickBonus.value : 1;
|
||||
|
||||
return (
|
||||
state.baseClickPower *
|
||||
clickMultiplier *
|
||||
@@ -331,6 +348,7 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
echoIncome *
|
||||
equipmentClickMultiplier *
|
||||
setClickMultiplier *
|
||||
craftedClickMultiplier
|
||||
craftedClickMultiplier *
|
||||
companionClickMult
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3966,3 +3966,139 @@ body {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Companions ──────────────────────────────────────────────────────── */
|
||||
|
||||
.companion-panel h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.companion-intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--colour-muted);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.companion-intro strong {
|
||||
color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.companion-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.companion-card {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.companion-unlocked {
|
||||
border-color: var(--colour-border);
|
||||
}
|
||||
|
||||
.companion-locked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.companion-active {
|
||||
border-color: var(--colour-accent);
|
||||
box-shadow: 0 0 0 1px var(--colour-accent);
|
||||
}
|
||||
|
||||
.companion-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.companion-name-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.companion-name {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--colour-text);
|
||||
}
|
||||
|
||||
.companion-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--colour-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.companion-active-badge {
|
||||
background: var(--colour-accent);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.companion-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--colour-muted);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.companion-bonus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(120, 80, 200, 0.12);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.companion-bonus-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--colour-muted);
|
||||
}
|
||||
|
||||
.companion-bonus-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.companion-select-btn {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--colour-accent);
|
||||
background: transparent;
|
||||
color: var(--colour-accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.companion-select-btn:hover {
|
||||
background: var(--colour-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.companion-select-active {
|
||||
background: var(--colour-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.companion-unlock-requirement {
|
||||
font-size: 0.8rem;
|
||||
color: var(--colour-muted);
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user