generated from nhcarrigan/template
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>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- 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 {
|
||||
buildPostPrestigeState,
|
||||
calculateMilestoneBonus,
|
||||
calculatePrestigeThreshold,
|
||||
calculateProductionMultiplier,
|
||||
calculateRunestones,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../../src/services/prestige.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makePlayer = (totalGoldEarned: number) => ({
|
||||
discordId: "test_id",
|
||||
username: "testuser",
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
totalGoldEarned,
|
||||
totalClicks: 0,
|
||||
characterName: "Tester",
|
||||
});
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: makePlayer(0),
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("calculatePrestigeThreshold", () => {
|
||||
it("returns base threshold at count 0", () => {
|
||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("returns 5× at count 1", () => {
|
||||
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
|
||||
});
|
||||
|
||||
it("returns 25× at count 2", () => {
|
||||
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
|
||||
});
|
||||
|
||||
it("applies threshold multiplier correctly", () => {
|
||||
expect(calculatePrestigeThreshold(0, 2)).toBe(2_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEligibleForPrestige", () => {
|
||||
it("returns true when totalGoldEarned meets threshold", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(1_000_000) });
|
||||
expect(isEligibleForPrestige(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when totalGoldEarned is below threshold", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(999_999) });
|
||||
expect(isEligibleForPrestige(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses echoPrestigeThresholdMultiplier from transcendence when present", () => {
|
||||
const state = makeMinimalState({
|
||||
player: makePlayer(2_000_000),
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 2,
|
||||
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
// threshold = 1_000_000 × 2 = 2_000_000 — exactly meets
|
||||
expect(isEligibleForPrestige(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateRunestones", () => {
|
||||
it("calculates basic runestones formula", () => {
|
||||
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it("applies echo runestone multiplier", () => {
|
||||
// floor(sqrt(4) × 10) = 20; × 2 = 40
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||||
expect(result).toBe(40);
|
||||
});
|
||||
|
||||
it("applies purchased runestone upgrade multiplier", () => {
|
||||
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||||
expect(result).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateProductionMultiplier", () => {
|
||||
it("returns 1 at count 0", () => {
|
||||
expect(calculateProductionMultiplier(0)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1.15 at count 1", () => {
|
||||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
|
||||
});
|
||||
|
||||
it("scales exponentially", () => {
|
||||
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateMilestoneBonus", () => {
|
||||
it("returns 0 for non-milestone prestiges", () => {
|
||||
expect(calculateMilestoneBonus(1)).toBe(0);
|
||||
expect(calculateMilestoneBonus(3)).toBe(0);
|
||||
expect(calculateMilestoneBonus(4)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 25 at prestige 5", () => {
|
||||
expect(calculateMilestoneBonus(5)).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 50 at prestige 10", () => {
|
||||
expect(calculateMilestoneBonus(10)).toBe(50);
|
||||
});
|
||||
|
||||
it("returns 75 at prestige 15", () => {
|
||||
expect(calculateMilestoneBonus(15)).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRunestoneMultipliers", () => {
|
||||
it("returns all 1s with empty ids", () => {
|
||||
const result = computeRunestoneMultipliers([]);
|
||||
expect(result.runestonesIncomeMultiplier).toBe(1);
|
||||
expect(result.runestonesClickMultiplier).toBe(1);
|
||||
expect(result.runestonesEssenceMultiplier).toBe(1);
|
||||
expect(result.runestonesCrystalMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies income upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["income_1"]);
|
||||
expect(result.runestonesIncomeMultiplier).toBeGreaterThan(1);
|
||||
expect(result.runestonesClickMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies click upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["click_power_1"]);
|
||||
expect(result.runestonesClickMultiplier).toBeGreaterThan(1);
|
||||
expect(result.runestonesIncomeMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies essence upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["essence_1"]);
|
||||
expect(result.runestonesEssenceMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("applies crystals upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["crystal_1"]);
|
||||
expect(result.runestonesCrystalMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostPrestigeState", () => {
|
||||
it("increments prestige count", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(4_000_000) });
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.count).toBe(1);
|
||||
});
|
||||
|
||||
it("sums runestones earned", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(4_000_000) });
|
||||
const { prestigeData, runestonesEarned } = buildPostPrestigeState(state, "Tester");
|
||||
expect(runestonesEarned).toBeGreaterThan(0);
|
||||
expect(prestigeData.runestones).toBe(runestonesEarned);
|
||||
});
|
||||
|
||||
it("adds milestone runestones at prestige 5", () => {
|
||||
const state = makeMinimalState({
|
||||
player: makePlayer(100_000_000),
|
||||
prestige: { count: 4, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const { milestoneRunestones } = buildPostPrestigeState(state, "Tester");
|
||||
expect(milestoneRunestones).toBe(25);
|
||||
});
|
||||
|
||||
it("persists codex from current state", () => {
|
||||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||||
const state = makeMinimalState({ codex });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.codex).toEqual(codex);
|
||||
});
|
||||
|
||||
it("persists story from current state", () => {
|
||||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||||
const state = makeMinimalState({ story });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.story).toEqual(story);
|
||||
});
|
||||
|
||||
it("persists transcendence from current state", () => {
|
||||
const transcendence = {
|
||||
count: 1, echoes: 10, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1,
|
||||
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
};
|
||||
const state = makeMinimalState({ transcendence });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.transcendence).toEqual(transcendence);
|
||||
});
|
||||
|
||||
it("preserves autoPrestigeEnabled when set", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [], autoPrestigeEnabled: true },
|
||||
});
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("omits autoPrestigeEnabled when not set", () => {
|
||||
const state = makeMinimalState();
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves apotheosis data across prestige", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user