feat: v0.6.0 balance pass, quest-gated bosses, and achievement fixes

This commit is contained in:
2026-07-17 01:02:12 -05:00
parent 9bb1d01d2b
commit 5928eb73d8
49 changed files with 1836 additions and 528 deletions
+61 -37
View File
@@ -1,6 +1,21 @@
/**
* @file Tests for the about route, covering release fetching, caching, and the
* fallback to pending releases when the upstream request fails.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pendingReleases } from "../../src/data/pendingReleases.js";
const makeApp = async(): Promise<Hono> => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
describe("about route", () => {
const mockFetch = vi.fn();
@@ -15,59 +30,68 @@ describe("about route", () => {
mockFetch.mockReset();
});
const makeApp = async () => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
it("returns releases from a successful fetch", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
it("returns pending and fetched releases on a successful fetch", async() => {
expect.assertions(2);
const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve(releases);
}, ok: true });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual(releases);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { releases: Array<unknown> } = await response.json();
expect(body.releases, "should merge pending and fetched releases").
toStrictEqual([ ...pendingReleases, ...releases ]);
});
it("returns empty releases when fetch is not ok", async () => {
it("returns only pending releases when fetch is not ok", async() => {
expect.assertions(2);
mockFetch.mockResolvedValueOnce({ ok: false });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual([]);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { releases: Array<unknown> } = await response.json();
expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
});
it("returns empty releases when fetch throws", async () => {
it("returns only pending releases when fetch throws", async() => {
expect.assertions(2);
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual([]);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { releases: Array<unknown> } = await response.json();
expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
});
it("returns cached releases on second call within TTL", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
it("returns cached releases on second call within TTL", async() => {
expect.assertions(2);
const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve(releases);
}, ok: true });
const app = await makeApp();
// First call populates cache
await app.fetch(new Request("http://localhost/about"));
// Second call should use cache, not call fetch again
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(1);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
expect(mockFetch, "should not re-fetch within the TTL").
toHaveBeenCalledTimes(1);
});
it("includes apiVersion in response", async () => {
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
it("includes apiVersion in response", async() => {
expect.assertions(2);
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve([]);
}, ok: true });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { apiVersion: string };
expect(typeof body.apiVersion).toBe("string");
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { apiVersion: string } = await response.json();
expect(typeof body.apiVersion, "should expose apiVersion as a string").
toBe("string");
});
});
+127
View File
@@ -121,6 +121,133 @@ describe("boss route", () => {
expect(res.status).toBe(403);
});
it("returns 403 when boss's own unlock quest is not completed", async () => {
const state = makeState({
bosses: [makeBoss({ unlockQuestId: "gate_quest" })] as GameState["bosses"],
quests: [{ id: "gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(403);
});
it("allows challenge when boss's own unlock quest is completed", async () => {
const state = makeState({
bosses: [makeBoss({
unlockQuestId: "gate_quest",
currentHp: 100,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
quests: [{ id: "gate_quest", status: "completed" }] as GameState["quests"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock next zone boss when its own unlock quest is not completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("locked");
});
it("unlocks next zone boss when its own unlock quest is completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("available");
});
it("does not unlock first zone boss when its own unlock quest is not completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedZone = savedState?.zones.find((z) => z.id === "new_zone");
expect(savedZone?.status).toBe("unlocked");
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("locked");
});
it("unlocks first zone boss when its own unlock quest is completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("available");
});
it("returns 400 when party has no adventurers", async () => {
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
+43 -1
View File
@@ -312,6 +312,34 @@ describe("debug route", () => {
expect(body.bossesUnlocked).toBe(1);
});
it("does not unlock boss when its own unlock quest is not completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "active" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("unlocks boss when its own unlock quest is completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "completed" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(1);
});
it("returns explorationUnlocked=0 when exploration is undefined", async () => {
const state = makeState({
exploration: undefined as unknown as GameState["exploration"],
@@ -830,7 +858,7 @@ describe("debug route", () => {
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 150, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
@@ -905,6 +933,20 @@ describe("debug route", () => {
expect(body.bossesPatched).toBe(1);
});
it("patches boss stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 1, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head.", unlockQuestId: "some_ghost_quest" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number; state: GameState };
expect(body.bossesPatched).toBe(1);
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.unlockQuestId).toBeNull();
});
it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
+46
View File
@@ -150,6 +150,52 @@ describe("prestige route", () => {
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
});
it("skips webhook when the new prestige count is not a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
it("posts webhook when the new prestige count is a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(5);
expect(postMilestoneWebhook).toHaveBeenCalledWith(DISCORD_ID, "prestige", expect.objectContaining({ prestige: 5 }));
});
it("skips webhook on a multiple of 5 when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
});
describe("POST /buy-upgrade", () => {
+2 -2
View File
@@ -157,7 +157,7 @@ describe("profile route", () => {
expect(body.profileSettings.showTotalGold).toBe(false);
});
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => {
it("falls back to scientific numberFormat in GET when stored profileSettings has invalid format", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
);
@@ -165,7 +165,7 @@ describe("profile route", () => {
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix");
expect(body.profileSettings.numberFormat).toBe("scientific");
});
it("maps known and unknown unlocked title IDs to name and fallback id", async () => {
+87 -1
View File
@@ -10,9 +10,26 @@ import type { GameState } from "@elysium/types";
const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id);
const makePlayer = (overrides: Partial<GameState["player"]> = {}) => ({
avatar: null,
characterName: "T",
discordId: "t",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
totalClicks: 0,
totalGoldEarned: 0,
username: "u",
...overrides,
});
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
player: makePlayer(),
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
@@ -98,6 +115,19 @@ describe("buildPostApotheosisState", () => {
expect(updatedState.story).toEqual(story);
});
it("persists active companion selection across apotheosis", () => {
const companions = { unlockedCompanionIds: ["lyra", "pria"], activeCompanionId: "pria" };
const state = makeMinimalState({ companions });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("wipes prestige data", () => {
const state = makeMinimalState({
prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] },
@@ -112,4 +142,60 @@ describe("buildPostApotheosisState", () => {
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.apotheosis?.count).toBe(1);
});
it("accumulates current-run gold and clicks into lifetime totals", () => {
const state = makeMinimalState({
player: makePlayer({ totalGoldEarned: 4_000_000, lifetimeGoldEarned: 1_000_000, totalClicks: 500 }),
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeGoldEarned).toBe(5_000_000);
expect(updatedState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses, completed quests, and recruited adventurers into lifetime totals", () => {
const boss = { id: "boss_1", status: "defeated" as const };
const quest = { id: "q_1", status: "completed" as const };
const adventurer = { id: "adv_1", count: 5 };
const state = makeMinimalState({
bosses: [ boss ] as GameState["bosses"],
quests: [ quest ] as GameState["quests"],
adventurers: [ adventurer ] as GameState["adventurers"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeBossesDefeated).toBe(1);
expect(updatedState.player.lifetimeQuestsCompleted).toBe(1);
expect(updatedState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements and accumulates newly-unlocked ones into the lifetime total", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.achievements).toEqual([ achievement ]);
expect(updatedState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("does not re-count achievements already folded into the lifetime total by a previous reset", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
player: makePlayer({ lifetimeAchievementsUnlocked: 1 }),
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("preserves equipment ownership history across apotheosis", () => {
const equipment = { id: "rusty_sword", owned: true, everOwned: true };
const state = makeMinimalState({
equipment: [ equipment ] as GameState["equipment"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
const matchingItem = updatedState.equipment.find((item) => {
return item.id === "rusty_sword";
});
expect(matchingItem?.everOwned).toBe(true);
});
});
+15 -2
View File
@@ -120,9 +120,9 @@ describe("calculateRunestones", () => {
});
it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 400
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200);
expect(result).toBe(400);
});
});
@@ -229,6 +229,19 @@ describe("buildPostPrestigeState", () => {
expect(prestigeState.story).toEqual(story);
});
it("persists active companion selection across prestige", () => {
const companions = { unlockedCompanionIds: ["lyra", "sera"], activeCompanionId: "lyra" };
const state = makeMinimalState({ companions });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("persists transcendence from current state", () => {
const transcendence = {
count: 1, echoes: 10, purchasedUpgradeIds: [],
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "../../src/services/resetHelpers.js";
import type { Boss, Equipment, Quest } from "@elysium/types";
const makeItem = (overrides: Partial<Equipment> = {}): Equipment => ({
bonus: {},
description: "An item",
equipped: false,
id: "item_1",
name: "Item",
owned: false,
rarity: "common",
type: "weapon",
...overrides,
});
const makeQuest = (overrides: Partial<Quest> = {}): Quest => ({
description: "A quest",
durationSeconds: 60,
id: "quest_1",
name: "Quest",
prerequisiteIds: [],
rewards: [],
status: "available",
zoneId: "zone_1",
...overrides,
});
const makeBoss = (overrides: Partial<Boss> = {}): Boss => ({
bountyRunestones: 0,
crystalReward: 0,
currentHp: 100,
damagePerSecond: 1,
description: "A boss",
equipmentRewards: [],
essenceReward: 0,
goldReward: 0,
id: "boss_1",
maxHp: 100,
name: "Boss",
prestigeRequirement: 0,
status: "available",
upgradeRewards: [],
zoneId: "zone_1",
...overrides,
});
describe("preserveEquipmentHistory", () => {
it("marks everOwned when the current item has everOwned true", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ everOwned: true, owned: false }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBe(true);
});
it("marks everOwned when the current item is owned but everOwned is unset", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ owned: true }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBe(true);
});
it("leaves the fresh item unchanged when the current item is neither owned nor everOwned", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ owned: false, everOwned: false }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBeUndefined();
});
it("leaves the fresh item unchanged when no matching id exists in current equipment", () => {
const fresh = [ makeItem({ id: "item_1" }) ];
const current = [ makeItem({ id: "item_2", owned: true, everOwned: true }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBeUndefined();
});
});
describe("preserveQuestHistory", () => {
it("marks everCompleted when the current quest has everCompleted true", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ everCompleted: true, status: "available" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBe(true);
});
it("marks everCompleted when the current quest is completed but everCompleted is unset", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ status: "completed" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBe(true);
});
it("leaves the fresh quest unchanged when the current quest is neither completed nor everCompleted", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ everCompleted: false, status: "locked" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBeUndefined();
});
it("leaves the fresh quest unchanged when no matching id exists in current quests", () => {
const fresh = [ makeQuest({ id: "quest_1" }) ];
const current = [
makeQuest({ everCompleted: true, id: "quest_2", status: "completed" }),
];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBeUndefined();
});
});
describe("preserveBossHistory", () => {
it("marks everDefeated when the current boss has everDefeated true", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ everDefeated: true, status: "available" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBe(true);
});
it("marks everDefeated when the current boss is defeated but everDefeated is unset", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ status: "defeated" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBe(true);
});
it("leaves the fresh boss unchanged when the current boss is neither defeated nor everDefeated", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ everDefeated: false, status: "locked" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBeUndefined();
});
it("leaves the fresh boss unchanged when no matching id exists in current bosses", () => {
const fresh = [ makeBoss({ id: "boss_1" }) ];
const current = [
makeBoss({ everDefeated: true, id: "boss_2", status: "defeated" }),
];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBeUndefined();
});
});
+87 -1
View File
@@ -9,9 +9,26 @@ import {
} from "../../src/services/transcendence.js";
import type { GameState } from "@elysium/types";
const makePlayer = (overrides: Partial<GameState["player"]> = {}) => ({
avatar: null,
characterName: "T",
discordId: "t",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
totalClicks: 0,
totalGoldEarned: 0,
username: "u",
...overrides,
});
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
player: makePlayer(),
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
@@ -166,6 +183,19 @@ describe("buildPostTranscendenceState", () => {
expect(transcendenceState.apotheosis).toEqual(apotheosis);
});
it("persists active companion selection across transcendence", () => {
const companions = { unlockedCompanionIds: ["lyra", "vex"], activeCompanionId: "vex" };
const state = makeMinimalState({ companions });
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("resets prestige to fresh state", () => {
const state = makeMinimalState({
prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] },
@@ -174,4 +204,60 @@ describe("buildPostTranscendenceState", () => {
expect(transcendenceState.prestige.count).toBe(0);
expect(transcendenceState.prestige.runestones).toBe(0);
});
it("accumulates current-run gold and clicks into lifetime totals", () => {
const state = makeMinimalState({
player: makePlayer({ totalGoldEarned: 4_000_000, lifetimeGoldEarned: 1_000_000, totalClicks: 500 }),
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeGoldEarned).toBe(5_000_000);
expect(transcendenceState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses, completed quests, and recruited adventurers into lifetime totals", () => {
const boss = { id: "boss_1", status: "defeated" as const };
const quest = { id: "q_1", status: "completed" as const };
const adventurer = { id: "adv_1", count: 5 };
const state = makeMinimalState({
bosses: [ boss ] as GameState["bosses"],
quests: [ quest ] as GameState["quests"],
adventurers: [ adventurer ] as GameState["adventurers"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeBossesDefeated).toBe(1);
expect(transcendenceState.player.lifetimeQuestsCompleted).toBe(1);
expect(transcendenceState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements and accumulates newly-unlocked ones into the lifetime total", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.achievements).toEqual([ achievement ]);
expect(transcendenceState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("does not re-count achievements already folded into the lifetime total by a previous reset", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
player: makePlayer({ lifetimeAchievementsUnlocked: 1 }),
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("preserves equipment ownership history across transcendence", () => {
const equipment = { id: "rusty_sword", owned: true, everOwned: true };
const state = makeMinimalState({
equipment: [ equipment ] as GameState["equipment"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
const matchingItem = transcendenceState.equipment.find((item) => {
return item.id === "rusty_sword";
});
expect(matchingItem?.everOwned).toBe(true);
});
});
+2 -2
View File
@@ -138,10 +138,10 @@ describe("webhook service", () => {
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 });
await postMilestoneWebhook("user123", "transcendence", { prestige: 3, 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");
expect(body.content).toContain("transcended at 3 prestige");
});
it("posts apotheosis message correctly", async () => {