feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## 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:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+115
View File
@@ -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);
});
});
+90
View File
@@ -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");
});
});
});
+76
View File
@@ -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);
});
});
+245
View File
@@ -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);
});
});
+151
View File
@@ -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);
});
});
+123
View File
@@ -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();
});
});
});