generated from nhcarrigan/template
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:
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user