generated from nhcarrigan/template
29c817230d
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
190 lines
7.7 KiB
TypeScript
190 lines
7.7 KiB
TypeScript
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||
import { describe, expect, it } from "vitest";
|
||
import { calculateOfflineEarnings } from "../../src/services/offlineProgress.js";
|
||
import type { GameState } from "@elysium/types";
|
||
|
||
const makeState = (overrides: Partial<GameState> = {}): GameState =>
|
||
({
|
||
lastTickAt: 0,
|
||
adventurers: [],
|
||
upgrades: [],
|
||
equipment: [],
|
||
prestige: {
|
||
count: 0,
|
||
runestones: 0,
|
||
productionMultiplier: 1,
|
||
purchasedUpgradeIds: [],
|
||
runestonesIncomeMultiplier: 1,
|
||
runestonesEssenceMultiplier: 1,
|
||
},
|
||
...overrides,
|
||
} as GameState);
|
||
|
||
describe("calculateOfflineEarnings", () => {
|
||
it("returns zero earnings when no adventurers", () => {
|
||
const state = makeState({ lastTickAt: 0 });
|
||
const result = calculateOfflineEarnings(state, 60_000);
|
||
expect(result.offlineGold).toBe(0);
|
||
expect(result.offlineEssence).toBe(0);
|
||
expect(result.offlineSeconds).toBe(60);
|
||
});
|
||
|
||
it("returns zero when all adventurers have count 0", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 0, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 60_000);
|
||
expect(result.offlineGold).toBe(0);
|
||
});
|
||
|
||
it("returns zero when adventurer is not unlocked", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: false, count: 5, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 60_000);
|
||
expect(result.offlineGold).toBe(0);
|
||
});
|
||
|
||
it("calculates basic gold earnings correctly", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 2, goldPerSecond: 5, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 10_000);
|
||
// 2 adventurers × 5 gps × 10 seconds = 100 gold
|
||
expect(result.offlineGold).toBe(100);
|
||
expect(result.offlineSeconds).toBe(10);
|
||
});
|
||
|
||
it("calculates basic essence earnings correctly", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 0, essencePerSecond: 3 }] as GameState["adventurers"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 10_000);
|
||
expect(result.offlineEssence).toBe(30);
|
||
});
|
||
|
||
it("caps earnings at 8 hours regardless of elapsed time", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 1, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
});
|
||
const twelveHoursMs = 12 * 60 * 60 * 1000;
|
||
const result = calculateOfflineEarnings(state, twelveHoursMs);
|
||
const maxSeconds = 8 * 60 * 60;
|
||
expect(result.offlineGold).toBe(maxSeconds);
|
||
expect(result.offlineSeconds).toBe(maxSeconds);
|
||
});
|
||
|
||
it("applies global upgrade multiplier", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
upgrades: [{ id: "u1", purchased: true, target: "global", multiplier: 2 }] as GameState["upgrades"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineGold).toBe(20);
|
||
});
|
||
|
||
it("applies adventurer-specific upgrade multiplier", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "peasant", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
upgrades: [{ id: "u1", purchased: true, target: "adventurer", adventurerId: "peasant", multiplier: 3 }] as GameState["upgrades"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineGold).toBe(30);
|
||
});
|
||
|
||
it("does not apply upgrade for different adventurer", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "peasant", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
upgrades: [{ id: "u1", purchased: true, target: "adventurer", adventurerId: "knight", multiplier: 3 }] as GameState["upgrades"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineGold).toBe(10);
|
||
});
|
||
|
||
it("applies equipment gold multiplier for equipped items only", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
equipment: [
|
||
{ id: "e1", equipped: true, bonus: { goldMultiplier: 2 } },
|
||
{ id: "e2", equipped: false, bonus: { goldMultiplier: 5 } },
|
||
] as GameState["equipment"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
// Only e1 applies: 10 × 2 × 1s = 20
|
||
expect(result.offlineGold).toBe(20);
|
||
});
|
||
|
||
it("applies runestone income multiplier to gold", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
prestige: {
|
||
count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [],
|
||
runestonesIncomeMultiplier: 2,
|
||
runestonesEssenceMultiplier: 1,
|
||
} as GameState["prestige"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineGold).toBe(20);
|
||
});
|
||
|
||
it("applies runestone essence multiplier to essence", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 0, essencePerSecond: 5 }] as GameState["adventurers"],
|
||
prestige: {
|
||
count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [],
|
||
runestonesIncomeMultiplier: 1,
|
||
runestonesEssenceMultiplier: 3,
|
||
} as GameState["prestige"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineEssence).toBe(15);
|
||
});
|
||
|
||
it("defaults to 1 when runestonesIncomeMultiplier is undefined", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 2 }] as GameState["adventurers"],
|
||
// Prestige without runestone multiplier fields — hits the ?? 1 fallback
|
||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] } as GameState["prestige"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineGold).toBe(10);
|
||
expect(result.offlineEssence).toBe(2);
|
||
});
|
||
|
||
it("defaults to 1 when equipment is undefined", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
equipment: undefined,
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
expect(result.offlineGold).toBe(10);
|
||
});
|
||
|
||
it("defaults goldMultiplier to 1 when equipment item has no goldMultiplier", () => {
|
||
const state = makeState({
|
||
lastTickAt: 0,
|
||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||
equipment: [
|
||
{ id: "e1", equipped: true, bonus: {} }, // no goldMultiplier — hits ?? 1
|
||
] as GameState["equipment"],
|
||
});
|
||
const result = calculateOfflineEarnings(state, 1_000);
|
||
// goldMultiplier defaults to 1, so no boost
|
||
expect(result.offlineGold).toBe(10);
|
||
});
|
||
});
|