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.
This commit is contained in:
2026-03-07 15:59:24 -08:00
committed by Naomi Carrigan
parent bcb523f598
commit db860ee5d3
12 changed files with 539 additions and 12 deletions
+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,