generated from nhcarrigan/template
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:
@@ -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