Files
elysium/apps/api/test/services/offlineProgress.spec.ts
T
hikari 29c817230d
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s
feat: initial prototype — core game systems (#30)
## 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>
2026-03-08 15:53:39 -07:00

190 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
});
});