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
+92 -2
View File
@@ -4,10 +4,16 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- buildPostApotheosisState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js";
import {
defaultTranscendenceUpgrades,
} from "../data/transcendenceUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { ApotheosisData, GameState } from "@elysium/types";
/**
@@ -34,7 +40,8 @@ const isEligibleForApotheosis = (state: GameState): boolean => {
/**
* Builds the updated game state after Apotheosis — the ultimate nuclear reset.
* Wipes absolutely everything including prestige and transcendence.
* Only codex lore entries and the apotheosis count itself are preserved.
* Only codex lore entries, story progress, the active companion selection, and
* the apotheosis count itself are preserved.
* @param currentState - The current game state before apotheosis.
* @param characterName - The character name to carry over.
* @returns The updated game state and apotheosis data.
@@ -47,9 +54,88 @@ const buildPostApotheosisState = (
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the apotheosis reset.
*/
const bossesWithHistory = preserveBossHistory(
freshState.bosses,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the apotheosis reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the apotheosis reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after apotheosis.
*/
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
const runAdventurersRecruited = currentState.adventurers.reduce(
(sum, adventurer) => {
return sum + adventurer.count;
},
0,
);
const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const updatedState: GameState = {
...freshState,
lastTickAt: Date.now(),
// Achievements are permanent — earned achievements survive apotheosis
achievements: currentState.achievements,
bosses: bossesWithHistory,
equipment: equipmentWithHistory,
lastTickAt: Date.now(),
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ achievementsUnlockedDelta,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
lifetimeBossesDefeated:
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
lifetimeClicks:
freshState.player.lifetimeClicks + currentState.player.totalClicks,
lifetimeGoldEarned:
freshState.player.lifetimeGoldEarned
+ currentState.player.totalGoldEarned,
lifetimeQuestsCompleted:
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
// Codex lore persists through all resets — players keep their discovered entries
...currentState.codex
? { codex: currentState.codex }
@@ -60,6 +146,10 @@ const buildPostApotheosisState = (
...currentState.story
? { story: currentState.story }
: {},
// Active companion selection persists across apotheosis
...currentState.companions
? { companions: currentState.companions }
: {},
};
return { updatedApotheosisData, updatedState };
+56 -5
View File
@@ -8,6 +8,11 @@
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
import { initialGameState } from "../data/initialState.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type {
GameState,
PrestigeData,
@@ -22,9 +27,9 @@ const milestoneRunestonesPerInterval = 25;
/*
* Hard cap on the base runestone yield (before multipliers) to prevent
* extreme AFK accumulation from producing game-breaking runestone counts.
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige.
* With all upgrades (5.625× max) this caps out at ~2,250 per prestige.
*/
const maxBaseRunestones = 200;
const maxBaseRunestones = 400;
/**
* Calculates the gold threshold required for the next prestige.
@@ -240,6 +245,33 @@ const buildPostPrestigeState = (
return freshBoss;
});
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the prestige reset.
*/
const bossesWithHistory = preserveBossHistory(
bossesWithBountyClaimed,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the prestige reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the prestige reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
// Compute current-run contributions to accumulate into lifetime totals
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
@@ -251,12 +283,23 @@ const buildPostPrestigeState = (
for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
const runAchievementsUnlocked = currentState.achievements.filter(
const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
/*
* Achievements persist across prestiges, so `totalAchievementsUnlocked`
* already includes everything counted at previous prestiges — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const prestigeState: GameState = {
...freshState,
@@ -272,7 +315,9 @@ const buildPostPrestigeState = (
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed,
bosses: bossesWithHistory,
// Ownership resets for gameplay, but ever-owned history is preserved
equipment: equipmentWithHistory,
lastTickAt: Date.now(),
/*
@@ -283,7 +328,7 @@ const buildPostPrestigeState = (
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ runAchievementsUnlocked,
+ achievementsUnlockedDelta,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
@@ -298,6 +343,12 @@ const buildPostPrestigeState = (
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
prestige: prestigeData,
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
// Active companion selection persists across prestige
...currentState.companions === undefined
? {}
: { companions: currentState.companions },
// Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined
? {}
+89
View File
@@ -0,0 +1,89 @@
/**
* @file Shared helpers for merging permanent per-item history across
* prestige/transcendence/apotheosis resets.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Boss, Equipment, Quest } from "@elysium/types";
/**
* Carries forward each equipment item's ownership history across a reset.
* Ownership itself (and any stat bonuses it grants) still resets to the fresh
* defaults — only the "have I ever owned this" flag used by collection
* achievements survives, mirroring how boss bounty-claimed flags persist.
* @param freshEquipment - The freshly-reset equipment array.
* @param currentEquipment - The equipment array from the state being reset.
* @returns The fresh equipment array with `everOwned` merged in per item id.
*/
const preserveEquipmentHistory = (
freshEquipment: Array<Equipment>,
currentEquipment: Array<Equipment>,
): Array<Equipment> => {
return freshEquipment.map((freshItem) => {
const currentItem = currentEquipment.find((item) => {
return item.id === freshItem.id;
});
const everOwned
= currentItem?.everOwned === true || currentItem?.owned === true;
return everOwned
? { ...freshItem, everOwned: true }
: freshItem;
});
};
/**
* Carries forward each quest's completion history across a reset. Quest
* status itself still resets to the fresh defaults — only the "have I ever
* completed this" flag used by the all-quests achievement survives, so
* repeatedly re-completing the same subset of quests across resets can't
* be mistaken for having completed every distinct quest.
* @param freshQuests - The freshly-reset quests array.
* @param currentQuests - The quests array from the state being reset.
* @returns The fresh quests array with `everCompleted` merged in per quest id.
*/
const preserveQuestHistory = (
freshQuests: Array<Quest>,
currentQuests: Array<Quest>,
): Array<Quest> => {
return freshQuests.map((freshQuest) => {
const currentQuest = currentQuests.find((quest) => {
return quest.id === freshQuest.id;
});
const everCompleted
= currentQuest?.everCompleted === true
|| currentQuest?.status === "completed";
return everCompleted
? { ...freshQuest, everCompleted: true }
: freshQuest;
});
};
/**
* Carries forward each boss's defeat history across a reset. Boss status
* itself still resets to the fresh defaults — only the "have I ever
* defeated this" flag used by the all-bosses achievement survives, so
* repeatedly re-defeating the same subset of bosses across resets can't
* be mistaken for having defeated every distinct boss.
* @param freshBosses - The freshly-reset bosses array.
* @param currentBosses - The bosses array from the state being reset.
* @returns The fresh bosses array with `everDefeated` merged in per boss id.
*/
const preserveBossHistory = (
freshBosses: Array<Boss>,
currentBosses: Array<Boss>,
): Array<Boss> => {
return freshBosses.map((freshBoss) => {
const currentBoss = currentBosses.find((boss) => {
return boss.id === freshBoss.id;
});
const everDefeated
= currentBoss?.everDefeated === true
|| currentBoss?.status === "defeated";
return everDefeated
? { ...freshBoss, everDefeated: true }
: freshBoss;
});
};
export { preserveBossHistory, preserveEquipmentHistory, preserveQuestHistory };
+87
View File
@@ -4,8 +4,14 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- buildPostTranscendenceState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type {
GameState,
TranscendenceData,
@@ -106,6 +112,8 @@ const buildPermanentSpreads = (
transcendenceData: TranscendenceData,
): Partial<GameState> => {
return {
// Achievements are permanent — earned achievements survive all transcendences
achievements: currentState.achievements,
transcendence: transcendenceData,
...currentState.codex === undefined
? {}
@@ -116,6 +124,10 @@ const buildPermanentSpreads = (
...currentState.story === undefined
? {}
: { story: currentState.story },
// Active companion selection persists across transcendence
...currentState.companions === undefined
? {}
: { companions: currentState.companions },
};
};
@@ -153,9 +165,84 @@ const buildPostTranscendenceState = (
};
const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the transcendence reset.
*/
const bossesWithHistory = preserveBossHistory(
freshState.bosses,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the transcendence reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the transcendence reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after transcendence.
*/
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const transcendenceState: GameState = {
...freshState,
bosses: bossesWithHistory,
equipment: equipmentWithHistory,
lastTickAt: Date.now(),
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ achievementsUnlockedDelta,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
lifetimeBossesDefeated:
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
lifetimeClicks:
freshState.player.lifetimeClicks + currentState.player.totalClicks,
lifetimeGoldEarned:
freshState.player.lifetimeGoldEarned
+ currentState.player.totalGoldEarned,
lifetimeQuestsCompleted:
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
...buildPermanentSpreads(currentState, transcendenceData),
};
+13 -1
View File
@@ -107,6 +107,17 @@ const milestoneVerbs: Record<MilestoneType, string> = {
transcendence: "transcended",
};
type MilestoneSuffixBuilder = (counts: MilestoneCounts)=> string;
const milestoneSuffixes: Partial<
Record<MilestoneType, MilestoneSuffixBuilder>
> = {
transcendence: (counts) => {
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts.prestige is a number, intentionally stringified */
return ` at ${counts.prestige} prestige`;
},
};
/**
* Posts a milestone announcement to the configured Discord webhook.
* Fails silently so webhook errors do not affect the game action.
@@ -126,8 +137,9 @@ const postMilestoneWebhook = async(
}
const verb = milestoneVerbs[milestone];
const suffix = milestoneSuffixes[milestone]?.(counts) ?? "";
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
const content = `<@${discordId}> has ${verb}${suffix}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
try {
await fetch(webhookUrl, {