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
+2
View File
@@ -21,6 +21,8 @@ model Player {
guildName String @default("")
guildDescription String @default("")
profileSettings Json?
unlockedTitles Json?
activeTitle String @default("")
createdAt Float
lastSavedAt Float
totalGoldEarned Float @default(0)
+135
View File
@@ -0,0 +1,135 @@
import type { Title } from "@elysium/types";
export const TITLES: Title[] = [
// Quest milestones
{
id: "the_adventurous",
name: "The Adventurous",
description: "Complete your first quest.",
condition: { type: "questsCompleted", amount: 1 },
},
{
id: "the_persistent",
name: "The Persistent",
description: "Complete 100 quests in a single run.",
condition: { type: "questsCompleted", amount: 100 },
},
// Boss milestones
{
id: "boss_slayer",
name: "Boss Slayer",
description: "Defeat your first boss.",
condition: { type: "bossesDefeated", amount: 1 },
},
{
id: "dungeon_master",
name: "Dungeon Master",
description: "Defeat 10 bosses in a single run.",
condition: { type: "bossesDefeated", amount: 10 },
},
// Gold milestones
{
id: "the_wealthy",
name: "The Wealthy",
description: "Earn 1,000,000 gold in a single run.",
condition: { type: "totalGoldEarned", amount: 1_000_000 },
},
{
id: "the_rich",
name: "The Rich",
description: "Earn 1,000,000,000 gold in a single run.",
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
},
// Click milestones
{
id: "click_maniac",
name: "Click Maniac",
description: "Click the Guild Hall 10,000 times in a single run.",
condition: { type: "totalClicks", amount: 10_000 },
},
// Adventurer milestones
{
id: "commander",
name: "Commander",
description: "Recruit 100 adventurers.",
condition: { type: "adventurerTotal", amount: 100 },
},
{
id: "warlord",
name: "Warlord",
description: "Recruit 1,000 adventurers.",
condition: { type: "adventurerTotal", amount: 1_000 },
},
// Social
{
id: "guild_founder",
name: "Guild Founder",
description: "Give your guild a name.",
condition: { type: "guildFounded" },
},
// Prestige milestones
{
id: "the_undying",
name: "The Undying",
description: "Achieve your first Prestige.",
condition: { type: "prestigeCount", amount: 1 },
},
{
id: "battle_hardened",
name: "Battle Hardened",
description: "Achieve 5 Prestiges.",
condition: { type: "prestigeCount", amount: 5 },
},
{
id: "legend",
name: "Legend",
description: "Achieve 25 Prestiges.",
condition: { type: "prestigeCount", amount: 25 },
},
// Transcendence milestones
{
id: "transcendent",
name: "Transcendent",
description: "Achieve your first Transcendence.",
condition: { type: "transcendenceCount", amount: 1 },
},
{
id: "beyond_mortal",
name: "Beyond Mortal",
description: "Achieve 5 Transcendences.",
condition: { type: "transcendenceCount", amount: 5 },
},
// Apotheosis milestones
{
id: "apotheosised",
name: "Apotheosised",
description: "Achieve your first Apotheosis.",
condition: { type: "apotheosisCount", amount: 1 },
},
{
id: "ascendant",
name: "Ascendant",
description: "Achieve 5 Apotheoses.",
condition: { type: "apotheosisCount", amount: 5 },
},
// Achievement milestone
{
id: "completionist",
name: "Completionist",
description: "Unlock all achievements.",
condition: { type: "achievementsUnlocked", amount: 40 },
},
// Longevity
{
id: "veteran",
name: "Veteran",
description: "Play Elysium for 30 days.",
condition: { type: "playedDays", amount: 30 },
},
{
id: "timeless",
name: "Timeless",
description: "Play Elysium for a full year.",
condition: { type: "playedDays", amount: 365 },
},
];
+22 -1
View File
@@ -15,6 +15,7 @@ import { DEFAULT_QUESTS } from "../data/quests.js";
import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
import { checkAndUnlockTitles, parseUnlockedTitles } from "../services/titles.js";
const RESOURCE_CAP = 1e300;
@@ -646,9 +647,14 @@ gameRouter.get("/load", async (context) => {
state.lastTickAt = now;
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
// Swallow write conflicts (P2034): the corrected state is still returned to the
// client and will be persisted on the next auto-save, so the backfill is not lost.
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
}).catch((err: unknown) => {
const code = (err as { code?: string }).code;
if (code !== "P2034") throw err;
});
}
@@ -666,7 +672,10 @@ gameRouter.post("/save", async (context) => {
}
const secret = process.env.ANTI_CHEAT_SECRET;
const record = await prisma.gameState.findUnique({ where: { discordId } });
const [record, playerRecord] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
let stateToSave = body.state;
@@ -694,6 +703,17 @@ gameRouter.post("/save", async (context) => {
player: { ...stateToSave.player, lastSavedAt: now },
};
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
const newTitles = checkAndUnlockTitles(
currentUnlocked,
stateToSave,
playerRecord?.guildName ?? "",
playerRecord?.createdAt ?? Date.now(),
);
const updatedUnlocked = newTitles.length > 0
? [...currentUnlocked, ...newTitles]
: undefined;
await prisma.player.update({
where: { discordId },
data: {
@@ -701,6 +721,7 @@ gameRouter.post("/save", async (context) => {
totalGoldEarned: stateToSave.player.totalGoldEarned,
totalClicks: stateToSave.player.totalClicks,
characterName: stateToSave.player.characterName,
...(updatedUnlocked ? { unlockedTitles: updatedUnlocked } : {}),
},
});
+24 -1
View File
@@ -8,6 +8,8 @@ import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { TITLES } from "../data/titles.js";
import { parseUnlockedTitles } from "../services/titles.js";
export const profileRouter = new Hono<HonoEnv>();
@@ -67,6 +69,12 @@ profileRouter.get("/:discordId", async (context) => {
const achievementsUnlocked =
(state?.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
const unlockedTitles = unlockedTitleIds.map((id) => {
const title = TITLES.find((t) => t.id === id);
return { id, name: title?.name ?? id };
});
return context.json({
characterName: player.characterName,
pronouns: player.pronouns ?? "",
@@ -96,6 +104,8 @@ profileRouter.get("/:discordId", async (context) => {
questsCompleted,
adventurersRecruited,
achievementsUnlocked,
unlockedTitles,
activeTitle: player.activeTitle ?? "",
});
});
@@ -137,9 +147,21 @@ profileRouter.put("/", authMiddleware, async (context) => {
return context.json({ error: "Character name cannot be empty" }, 400);
}
const activeTitle = typeof body.activeTitle === "string" ? body.activeTitle.slice(0, 64) : undefined;
const updated = await prisma.player.update({
where: { discordId },
data: { characterName, pronouns, characterRace, characterClass, bio, guildName, guildDescription, profileSettings: profileSettings as object },
data: {
characterName,
pronouns,
characterRace,
characterClass,
bio,
guildName,
guildDescription,
profileSettings: profileSettings as object,
...(activeTitle !== undefined ? { activeTitle } : {}),
},
});
return context.json({
@@ -151,5 +173,6 @@ profileRouter.put("/", authMiddleware, async (context) => {
guildName: updated.guildName,
guildDescription: updated.guildDescription,
profileSettings,
activeTitle: updated.activeTitle ?? "",
});
});
+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 [];
};