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
+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 ?? "",
});
});