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:
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
};
|
||||
Reference in New Issue
Block a user