generated from nhcarrigan/template
feat: v0.6.0 balance pass, quest-gated bosses, and achievement fixes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user