feat: add titles system with unlock tracking and character sheet display

Titles are earned by reaching milestones (quests, bosses, gold, clicks,
adventurers, guild, prestige, transcendence, apotheosis, achievements,
longevity) and are permanent - never lost on prestige/transcendence/
apotheosis resets. 20 titles available at launch.

Also fixes a pre-existing P2034 write-conflict on the load backfill path
and the exactOptionalPropertyTypes violation in the quest failure handler.
This commit is contained in:
2026-03-07 14:51:30 -08:00
committed by Naomi Carrigan
parent eef807343b
commit b886928e49
13 changed files with 333 additions and 10 deletions
+51
View File
@@ -0,0 +1,51 @@
import type { GameState } from "@elysium/types";
import { TITLES } from "../data/titles.js";
export const checkAndUnlockTitles = (
currentUnlocked: string[],
state: GameState,
guildName: string,
createdAt: number,
): string[] => {
const metrics: Record<string, number | boolean> = {
totalClicks: state.player.totalClicks,
totalGoldEarned: state.player.totalGoldEarned,
bossesDefeated: state.bosses.filter((b) => b.status === "defeated").length,
questsCompleted: state.quests.filter((q) => q.status === "completed").length,
prestigeCount: state.prestige.count,
transcendenceCount: state.transcendence?.count ?? 0,
apotheosisCount: state.apotheosis?.count ?? 0,
adventurerTotal: state.adventurers.reduce((sum, a) => sum + a.count, 0),
achievementsUnlocked: state.achievements.filter((a) => a.unlockedAt !== null).length,
guildFounded: guildName.trim().length > 0,
playedDays: Math.floor((Date.now() - createdAt) / 86_400_000),
};
const newlyUnlocked: string[] = [];
for (const title of TITLES) {
if (currentUnlocked.includes(title.id)) continue;
const { type, amount } = title.condition;
let earned = false;
if (type === "guildFounded") {
earned = metrics.guildFounded === true;
} else if (amount !== undefined) {
earned = (metrics[type] as number) >= amount;
}
if (earned) {
newlyUnlocked.push(title.id);
}
}
return newlyUnlocked;
};
export const parseUnlockedTitles = (raw: unknown): string[] => {
if (Array.isArray(raw)) {
return raw.filter((item): item is string => typeof item === "string");
}
return [];
};