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