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,115 @@
|
||||
/* 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 {
|
||||
buildPostApotheosisState,
|
||||
isEligibleForApotheosis,
|
||||
} from "../../src/services/apotheosis.js";
|
||||
import { defaultTranscendenceUpgrades } from "../../src/data/transcendenceUpgrades.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id);
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
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("isEligibleForApotheosis", () => {
|
||||
it("returns true when all transcendence upgrades are purchased", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: ALL_UPGRADE_IDS,
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
expect(isEligibleForApotheosis(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when one upgrade is missing", () => {
|
||||
const partialIds = ALL_UPGRADE_IDS.slice(0, -1);
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: partialIds,
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
expect(isEligibleForApotheosis(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when transcendence is undefined", () => {
|
||||
const state = makeMinimalState({ transcendence: undefined });
|
||||
expect(isEligibleForApotheosis(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when purchasedUpgradeIds is empty", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
expect(isEligibleForApotheosis(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostApotheosisState", () => {
|
||||
it("increments apotheosis count from 0", () => {
|
||||
const state = makeMinimalState();
|
||||
const { updatedApotheosisData } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedApotheosisData.count).toBe(1);
|
||||
});
|
||||
|
||||
it("increments apotheosis count from existing value", () => {
|
||||
const state = makeMinimalState({ apotheosis: { count: 2 } });
|
||||
const { updatedApotheosisData } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedApotheosisData.count).toBe(3);
|
||||
});
|
||||
|
||||
it("persists codex", () => {
|
||||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||||
const state = makeMinimalState({ codex });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.codex).toEqual(codex);
|
||||
});
|
||||
|
||||
it("persists story", () => {
|
||||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||||
const state = makeMinimalState({ story });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.story).toEqual(story);
|
||||
});
|
||||
|
||||
it("wipes prestige data", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.prestige.count).toBe(0);
|
||||
expect(updatedState.prestige.runestones).toBe(0);
|
||||
});
|
||||
|
||||
it("sets apotheosis count on new state", () => {
|
||||
const state = makeMinimalState({ apotheosis: { count: 0 } });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.apotheosis?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DailyChallengeState, GameState } from "@elysium/types";
|
||||
|
||||
// We reset modules so the module picks up fake timers when re-imported
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const makeState = (dailyChallenges?: DailyChallengeState): GameState =>
|
||||
({ dailyChallenges } as unknown as GameState);
|
||||
|
||||
const LA_MIDNIGHT_2024_01_15 = new Date("2024-01-15T08:00:00.000Z"); // LA midnight = UTC+8
|
||||
const LA_MIDNIGHT_2024_01_16 = new Date("2024-01-16T08:00:00.000Z");
|
||||
|
||||
describe("generateDailyChallenges", () => {
|
||||
it("returns exactly 3 challenges", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const result = generateDailyChallenges("2024-01-15");
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("all challenges start with progress 0 and completed false", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const result = generateDailyChallenges("2024-01-15");
|
||||
for (const challenge of result) {
|
||||
expect(challenge.progress).toBe(0);
|
||||
expect(challenge.completed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for the same date", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const a = generateDailyChallenges("2024-01-15");
|
||||
const b = generateDailyChallenges("2024-01-15");
|
||||
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
||||
});
|
||||
|
||||
it("generates different challenges for different dates", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const day1 = generateDailyChallenges("2024-01-15");
|
||||
const day2 = generateDailyChallenges("2024-01-16");
|
||||
// They should differ in at least one challenge ID (types vary by seed)
|
||||
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrResetDailyChallenges", () => {
|
||||
it("returns existing challenges when date matches today", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { getOrResetDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const existing: DailyChallengeState = {
|
||||
date: "2024-01-15",
|
||||
challenges: [{ id: "old_challenge", type: "clicks", label: "l", target: 100, progress: 50, completed: false, rewardCrystals: 1 }],
|
||||
};
|
||||
const state = makeState(existing);
|
||||
const result = getOrResetDailyChallenges(state);
|
||||
expect(result).toBe(existing);
|
||||
});
|
||||
|
||||
it("generates fresh challenges when date is yesterday", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_16);
|
||||
const { getOrResetDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const stale: DailyChallengeState = {
|
||||
date: "2024-01-15",
|
||||
challenges: [],
|
||||
};
|
||||
const state = makeState(stale);
|
||||
const result = getOrResetDailyChallenges(state);
|
||||
expect(result.date).toBe("2024-01-16");
|
||||
expect(result.challenges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("generates fresh challenges when dailyChallenges is undefined", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { getOrResetDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState(undefined);
|
||||
const result = getOrResetDailyChallenges(state);
|
||||
expect(result.challenges).toHaveLength(3);
|
||||
expect(result.date).toBe("2024-01-15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChallengeProgress", () => {
|
||||
const makeChallenge = (
|
||||
type: DailyChallengeState["challenges"][0]["type"],
|
||||
progress: number,
|
||||
completed: boolean,
|
||||
) => ({
|
||||
id: `${type}_test`,
|
||||
type,
|
||||
label: "Test",
|
||||
target: 100,
|
||||
progress,
|
||||
completed,
|
||||
rewardCrystals: 10,
|
||||
});
|
||||
|
||||
const makeState2 = (challenges: DailyChallengeState["challenges"]): DailyChallengeState => ({
|
||||
date: "2024-01-15",
|
||||
challenges,
|
||||
});
|
||||
|
||||
it("increments progress for matching non-completed challenges", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 0, false)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 10);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(10);
|
||||
});
|
||||
|
||||
it("does not modify already-completed challenges", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 100, true)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 50);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("does not modify challenges of a different type", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("bossesDefeated", 0, false)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 10);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(0);
|
||||
});
|
||||
|
||||
it("awards crystals when challenge completes", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 90, false)]);
|
||||
const { crystalsAwarded } = updateChallengeProgress(state, "clicks", 20);
|
||||
expect(crystalsAwarded).toBe(10);
|
||||
});
|
||||
|
||||
it("caps progress at target value", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 95, false)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 100);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("returns zero crystals when no challenge completes", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 0, false)]);
|
||||
const { crystalsAwarded } = updateChallengeProgress(state, "clicks", 10);
|
||||
expect(crystalsAwarded).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("discord service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("buildOAuthUrl", () => {
|
||||
it("throws when DISCORD_CLIENT_ID is missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_ID"];
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||
delete process.env["DISCORD_REDIRECT_URI"];
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("returns a URL with correct query params", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
const url = buildOAuthUrl();
|
||||
expect(url).toContain("client_id=client123");
|
||||
expect(url).toContain("response_type=code");
|
||||
expect(url).toContain("scope=identify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeCode", () => {
|
||||
it("throws when env vars are missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_ID"];
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("throws when response is not ok", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||
});
|
||||
|
||||
it("returns parsed body on success", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
const result = await exchangeCode("good_code");
|
||||
expect(result.access_token).toBe("tok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDiscordUser", () => {
|
||||
it("throws when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Forbidden" });
|
||||
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||
await expect(fetchDiscordUser("bad_token")).rejects.toThrow("Discord user fetch failed");
|
||||
});
|
||||
|
||||
it("returns parsed user on success", async () => {
|
||||
const user = { id: "123", username: "testuser", discriminator: "0", avatar: null };
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
|
||||
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||
const result = await fetchDiscordUser("valid_token");
|
||||
expect(result.id).toBe("123");
|
||||
expect(result.username).toBe("testuser");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("jwt service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
describe("signToken", () => {
|
||||
it("throws when JWT_SECRET is not set", async () => {
|
||||
delete process.env["JWT_SECRET"];
|
||||
const { signToken } = await import("../../src/services/jwt.js");
|
||||
expect(() => signToken("test_id")).toThrow("JWT_SECRET environment variable is required");
|
||||
});
|
||||
|
||||
it("returns a three-part dot-separated token", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { signToken } = await import("../../src/services/jwt.js");
|
||||
const token = signToken("test_id");
|
||||
expect(token.split(".")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
it("throws when JWT_SECRET is not set", async () => {
|
||||
delete process.env["JWT_SECRET"];
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
expect(() => verifyToken("a.b.c")).toThrow("JWT_SECRET environment variable is required");
|
||||
});
|
||||
|
||||
it("round-trips a token correctly", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { signToken, verifyToken } = await import("../../src/services/jwt.js");
|
||||
const token = signToken("user_123");
|
||||
const payload = verifyToken(token);
|
||||
expect(payload.discordId).toBe("user_123");
|
||||
});
|
||||
|
||||
it("throws on wrong token format (not 3 parts)", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
expect(() => verifyToken("only.two")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws on tampered signature", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { signToken, verifyToken } = await import("../../src/services/jwt.js");
|
||||
const token = signToken("user_123");
|
||||
const parts = token.split(".");
|
||||
const tampered = `${parts[0]}.${parts[1]}.BAD_SIGNATURE`;
|
||||
expect(() => verifyToken(tampered)).toThrow("Invalid token signature");
|
||||
});
|
||||
|
||||
it("throws on expired token", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
// Build a token with exp in the past
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ discordId: "x", iat: 1000, exp: 1001 }),
|
||||
).toString("base64url");
|
||||
const { createHmac } = await import("crypto");
|
||||
const signature = createHmac("sha256", "test_secret")
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
expect(() => verifyToken(`${header}.${payload}.${signature}`)).toThrow("Token has expired");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/* 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkAndUnlockTitles,
|
||||
parseUnlockedTitles,
|
||||
} from "../../src/services/titles.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
bosses: [],
|
||||
quests: [],
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
adventurers: [],
|
||||
achievements: [],
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("parseUnlockedTitles", () => {
|
||||
it("returns the array as-is when input is a string array", () => {
|
||||
expect(parseUnlockedTitles(["boss_slayer", "the_adventurous"])).toEqual(["boss_slayer", "the_adventurous"]);
|
||||
});
|
||||
|
||||
it("returns empty array for null input", () => {
|
||||
expect(parseUnlockedTitles(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for undefined input", () => {
|
||||
expect(parseUnlockedTitles(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for object input", () => {
|
||||
expect(parseUnlockedTitles({ key: "value" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for number input", () => {
|
||||
expect(parseUnlockedTitles(42)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters non-string values from mixed array", () => {
|
||||
expect(parseUnlockedTitles(["valid", 42, null, "also_valid"])).toEqual(["valid", "also_valid"]);
|
||||
});
|
||||
|
||||
it("returns empty array for an empty array", () => {
|
||||
expect(parseUnlockedTitles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAndUnlockTitles", () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
const THIRTY_DAYS_MS = 30 * 86_400_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array when no new titles are earned", () => {
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips titles already unlocked", () => {
|
||||
const state = makeMinimalState({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 10_000, characterName: "T" },
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: ["click_maniac"], state, guildName: "", createdAt: NOW });
|
||||
expect(result).not.toContain("click_maniac");
|
||||
});
|
||||
|
||||
it("unlocks guild_founder when guild name is non-empty", () => {
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "My Guild", createdAt: NOW });
|
||||
expect(result).toContain("guild_founder");
|
||||
});
|
||||
|
||||
it("does not unlock guild_founder for whitespace-only guild name", () => {
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: " ", createdAt: NOW });
|
||||
expect(result).not.toContain("guild_founder");
|
||||
});
|
||||
|
||||
it("unlocks the_adventurous when 1 quest is completed", () => {
|
||||
const state = makeMinimalState({
|
||||
quests: [{ status: "completed" }] as GameState["quests"],
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toContain("the_adventurous");
|
||||
});
|
||||
|
||||
it("unlocks boss_slayer when 1 boss is defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ status: "defeated" }] as GameState["bosses"],
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toContain("boss_slayer");
|
||||
});
|
||||
|
||||
it("unlocks the_undying at prestige 1", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 1, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toContain("the_undying");
|
||||
});
|
||||
|
||||
it("unlocks veteran after 30 days of play", () => {
|
||||
const createdAt = NOW - THIRTY_DAYS_MS;
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt });
|
||||
expect(result).toContain("veteran");
|
||||
});
|
||||
|
||||
it("does not unlock veteran before 30 days", () => {
|
||||
const createdAt = NOW - (29 * 86_400_000);
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt });
|
||||
expect(result).not.toContain("veteran");
|
||||
});
|
||||
|
||||
it("returns multiple newly unlocked titles at once", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ status: "defeated" }] as GameState["bosses"],
|
||||
quests: [{ status: "completed" }] as GameState["quests"],
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "Guild", createdAt: NOW });
|
||||
expect(result).toContain("boss_slayer");
|
||||
expect(result).toContain("the_adventurous");
|
||||
expect(result).toContain("guild_founder");
|
||||
});
|
||||
|
||||
it("reads transcendenceCount and apotheosisCount from state when present", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
apotheosis: { count: 1 },
|
||||
});
|
||||
// Just verify this runs without error — the counts are read via ?. chains
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/* 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 {
|
||||
buildPostTranscendenceState,
|
||||
calculateEchoes,
|
||||
computeTranscendenceMultipliers,
|
||||
isEligibleForTranscendence,
|
||||
} from "../../src/services/transcendence.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
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("computeTranscendenceMultipliers", () => {
|
||||
it("returns all 1s with empty ids", () => {
|
||||
const result = computeTranscendenceMultipliers([]);
|
||||
expect(result.echoIncomeMultiplier).toBe(1);
|
||||
expect(result.echoCombatMultiplier).toBe(1);
|
||||
expect(result.echoPrestigeThresholdMultiplier).toBe(1);
|
||||
expect(result.echoPrestigeRunestoneMultiplier).toBe(1);
|
||||
expect(result.echoMetaMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies income upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_income_1"]);
|
||||
expect(result.echoIncomeMultiplier).toBeGreaterThan(1);
|
||||
expect(result.echoCombatMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies combat upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_combat_1"]);
|
||||
expect(result.echoCombatMultiplier).toBeGreaterThan(1);
|
||||
expect(result.echoIncomeMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies prestige_threshold upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_prestige_threshold_1"]);
|
||||
expect(result.echoPrestigeThresholdMultiplier).not.toBe(1);
|
||||
});
|
||||
|
||||
it("applies prestige_runestones upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_prestige_runestones_1"]);
|
||||
expect(result.echoPrestigeRunestoneMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("applies echo_meta upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_meta_1"]);
|
||||
expect(result.echoMetaMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEligibleForTranscendence", () => {
|
||||
it("returns true when final boss is defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ id: "the_absolute_one", status: "defeated" }] as GameState["bosses"],
|
||||
});
|
||||
expect(isEligibleForTranscendence(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when final boss is available but not defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ id: "the_absolute_one", status: "available" }] as GameState["bosses"],
|
||||
});
|
||||
expect(isEligibleForTranscendence(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when final boss is not in the list", () => {
|
||||
const state = makeMinimalState({ bosses: [] });
|
||||
expect(isEligibleForTranscendence(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when a different boss is defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ id: "some_other_boss", status: "defeated" }] as GameState["bosses"],
|
||||
});
|
||||
expect(isEligibleForTranscendence(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEchoes", () => {
|
||||
it("handles prestige count of 0 by treating it as 1", () => {
|
||||
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
|
||||
expect(calculateEchoes(0, 1)).toBe(853);
|
||||
});
|
||||
|
||||
it("calculates echoes at count 1", () => {
|
||||
expect(calculateEchoes(1, 1)).toBe(853);
|
||||
});
|
||||
|
||||
it("decreases echoes with higher prestige count", () => {
|
||||
const echoesAt1 = calculateEchoes(1, 1);
|
||||
const echoesAt4 = calculateEchoes(4, 1);
|
||||
expect(echoesAt4).toBeLessThan(echoesAt1);
|
||||
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
|
||||
expect(echoesAt4).toBe(426);
|
||||
});
|
||||
|
||||
it("applies echoMetaMultiplier", () => {
|
||||
const base = calculateEchoes(1, 1);
|
||||
const withMult = calculateEchoes(1, 2);
|
||||
expect(withMult).toBe(base * 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostTranscendenceState", () => {
|
||||
it("increments transcendence count from 0", () => {
|
||||
const state = makeMinimalState();
|
||||
const { transcendenceData } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceData.count).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates echoes", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 100, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
const { transcendenceData, echoesEarned } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceData.echoes).toBe(100 + echoesEarned);
|
||||
});
|
||||
|
||||
it("persists codex from current state", () => {
|
||||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||||
const state = makeMinimalState({ codex });
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.codex).toEqual(codex);
|
||||
});
|
||||
|
||||
it("persists story from current state", () => {
|
||||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||||
const state = makeMinimalState({ story });
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.story).toEqual(story);
|
||||
});
|
||||
|
||||
it("persists apotheosis from current state", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.apotheosis).toEqual(apotheosis);
|
||||
});
|
||||
|
||||
it("resets prestige to fresh state", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.prestige.count).toBe(0);
|
||||
expect(transcendenceState.prestige.runestones).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("webhook service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("grantApotheosisRole", () => {
|
||||
it("does nothing when bot token is missing", async () => {
|
||||
delete process.env["DISCORD_BOT_TOKEN"];
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when guild id is missing", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||
delete process.env["DISCORD_GUILD_ID"];
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when role id is missing", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls Discord API with correct URL and auth when env vars are set", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user789");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("postMilestoneWebhook", () => {
|
||||
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
|
||||
|
||||
it("does nothing when webhook URL is missing", async () => {
|
||||
delete process.env["DISCORD_MILESTONE_WEBHOOK"];
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "prestige", counts);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts prestige message with correct body", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "prestige", counts);
|
||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://discord.com/webhook/abc");
|
||||
const body = JSON.parse(options.body as string) as { content: string };
|
||||
expect(body.content).toContain("<@user123>");
|
||||
expect(body.content).toContain("prestiged");
|
||||
});
|
||||
|
||||
it("posts transcendence message correctly", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "transcendence", { prestige: 0, transcendence: 1, apotheosis: 0 });
|
||||
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(options.body as string) as { content: string };
|
||||
expect(body.content).toContain("transcended");
|
||||
});
|
||||
|
||||
it("posts apotheosis message correctly", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "apotheosis", { prestige: 0, transcendence: 0, apotheosis: 1 });
|
||||
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(options.body as string) as { content: string };
|
||||
expect(body.content).toContain("reached apotheosis");
|
||||
});
|
||||
|
||||
it("swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network timeout"));
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user