From b886928e492a0cf276e618d902b586e20a213469 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 14:51:30 -0800 Subject: [PATCH] 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. --- apps/api/prisma/schema.prisma | 2 + apps/api/src/data/titles.ts | 135 ++++++++++++++++++ apps/api/src/routes/game.ts | 23 ++- apps/api/src/routes/profile.ts | 25 +++- apps/api/src/services/titles.ts | 51 +++++++ apps/web/src/components/game/AboutPanel.tsx | 6 +- .../web/src/components/game/CharacterPage.tsx | 6 + .../components/game/CharacterSheetPanel.tsx | 34 +++++ apps/web/src/engine/tick.ts | 3 +- apps/web/src/styles.css | 12 ++ packages/types/src/index.ts | 1 + packages/types/src/interfaces/Api.ts | 19 ++- packages/types/src/interfaces/Title.ts | 26 ++++ 13 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/data/titles.ts create mode 100644 apps/api/src/services/titles.ts create mode 100644 packages/types/src/interfaces/Title.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index eab9caa..71b8d82 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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) diff --git a/apps/api/src/data/titles.ts b/apps/api/src/data/titles.ts new file mode 100644 index 0000000..e29c74e --- /dev/null +++ b/apps/api/src/data/titles.ts @@ -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 }, + }, +]; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index ff41c3b..ea24d49 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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 } : {}), }, }); diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index 64ecb43..58030f1 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -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(); @@ -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 ?? "", }); }); diff --git a/apps/api/src/services/titles.ts b/apps/api/src/services/titles.ts new file mode 100644 index 0000000..a2ab43d --- /dev/null +++ b/apps/api/src/services/titles.ts @@ -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 = { + 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 []; +}; diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index 673152d..1d7bfcf 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -65,7 +65,11 @@ const HOW_TO_PLAY = [ }, { title: "๐Ÿ“‹ Character Sheet", - body: "Visit the Character tab to write about your character and guild. Fill in your character's name, pronouns, and backstory, then create a guild with its own name and lore. Your character sheet is visible on your public profile page.", + body: "Visit the Character tab to write about your character and guild. Fill in your character's name, pronouns, race, class, and backstory, then create a guild with its own name and lore. Your character sheet is visible on your public profile page.", + }, + { + title: "๐Ÿ… Titles", + body: "Earn Titles by reaching milestones โ€” defeating bosses, completing quests, prestiging, and more. Once unlocked, titles are yours forever and are never lost on prestige or transcendence resets. Set your active title from the Character tab to display it on your character sheet and public profile.", }, { title: "โ˜๏ธ Cloud Saves", diff --git a/apps/web/src/components/game/CharacterPage.tsx b/apps/web/src/components/game/CharacterPage.tsx index 1c5a794..6f642ad 100644 --- a/apps/web/src/components/game/CharacterPage.tsx +++ b/apps/web/src/components/game/CharacterPage.tsx @@ -53,6 +53,9 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem : `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`; const subtitle = [profile.characterRace, profile.characterClass].filter(Boolean).join(" ยท "); + const activeTitleName = profile.activeTitle + ? (profile.unlockedTitles.find((t) => t.id === profile.activeTitle)?.name ?? profile.activeTitle) + : null; const hasBadge = profile.apotheosisCount > 0 || profile.transcendenceCount > 0 || profile.prestigeCount > 0; return ( @@ -68,6 +71,9 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem

{profile.characterName || profile.username}

+ {activeTitleName && ( +

{activeTitleName}

+ )} {profile.pronouns && (

{profile.pronouns}

)} diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx index b448643..f50f90a 100644 --- a/apps/web/src/components/game/CharacterSheetPanel.tsx +++ b/apps/web/src/components/game/CharacterSheetPanel.tsx @@ -12,6 +12,8 @@ interface CharacterSheetData { bio: string; guildName: string; guildDescription: string; + activeTitle: string; + unlockedTitles: Array<{ id: string; name: string }>; } const EMPTY_SHEET: CharacterSheetData = { @@ -22,6 +24,8 @@ const EMPTY_SHEET: CharacterSheetData = { bio: "", guildName: "", guildDescription: "", + activeTitle: "", + unlockedTitles: [], }; export const CharacterSheetPanel = (): React.JSX.Element => { @@ -52,6 +56,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => { guildName: string; guildDescription: string; profileSettings: ProfileSettings; + activeTitle: string; + unlockedTitles: Array<{ id: string; name: string }>; }; const loaded: CharacterSheetData = { characterName: data.characterName ?? "", @@ -61,6 +67,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => { bio: data.bio ?? "", guildName: data.guildName ?? "", guildDescription: data.guildDescription ?? "", + activeTitle: data.activeTitle ?? "", + unlockedTitles: data.unlockedTitles ?? [], }; setSheet(loaded); setDraft(loaded); @@ -95,6 +103,7 @@ export const CharacterSheetPanel = (): React.JSX.Element => { guildName: draft.guildName, guildDescription: draft.guildDescription, profileSettings: savedSettingsRef.current, + activeTitle: draft.activeTitle, }); setSheet({ ...draft }); setSaved(true); @@ -183,6 +192,23 @@ export const CharacterSheetPanel = (): React.JSX.Element => { onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }} /> {draft.bio.length} / 200 + + {draft.unlockedTitles.length > 0 && ( + <> + + + + )}
@@ -268,6 +294,14 @@ export const CharacterSheetPanel = (): React.JSX.Element => { {sheet.characterName || Not set}
+ {sheet.activeTitle && ( +
+ Title + + {sheet.unlockedTitles.find((t) => t.id === sheet.activeTitle)?.name ?? sheet.activeTitle} + +
+ )} {sheet.pronouns && (
Pronouns diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 2318815..965a573 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -156,7 +156,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => const failureChance = ZONE_FAILURE_CHANCE[quest.zoneId] ?? 0.20; if (Math.random() < failureChance) { - return { ...quest, status: "available" as const, startedAt: undefined, lastFailedAt: now }; + const { startedAt: _dropped, ...questWithoutStartedAt } = quest; + return { ...questWithoutStartedAt, status: "available" as const, lastFailedAt: now }; } for (const reward of quest.rewards) { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e822729..ec90ded 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3210,6 +3210,11 @@ body { text-align: right; } +.character-sheet-title { + color: var(--colour-accent); + font-style: italic; +} + .character-sheet-error { color: #e74c3c; font-size: 0.85rem; @@ -3308,6 +3313,13 @@ body { line-height: 1.1; } +.character-page-title { + color: var(--colour-accent); + font-size: 0.9rem; + font-style: italic; + margin-top: 2px; +} + .character-page-pronouns { color: var(--colour-text-muted); font-size: 0.85rem; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b80ac6b..6dd0cc9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -89,3 +89,4 @@ export type { TranscendenceUpgrade, TranscendenceUpgradeCategory, } from "./interfaces/Transcendence.js"; +export type { Title, TitleCondition, TitleConditionType } from "./interfaces/Title.js"; diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index 19c3682..4e8290d 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -118,17 +118,23 @@ export interface PublicProfileResponse { questsCompleted: number; adventurersRecruited: number; achievementsUnlocked: number; + /** Titles this player has unlocked, as {id, name} pairs for display */ + unlockedTitles: Array<{ id: string; name: string }>; + /** The player's active title display name (empty string if none set) */ + activeTitle: string; } export interface UpdateProfileRequest { characterName: string; - pronouns: string; - characterRace: string; - characterClass: string; - bio: string; - guildName: string; - guildDescription: string; + pronouns?: string; + characterRace?: string; + characterClass?: string; + bio?: string; + guildName?: string; + guildDescription?: string; profileSettings: ProfileSettings; + /** Title ID to set as active (empty string to clear) */ + activeTitle?: string; } export interface UpdateProfileResponse { @@ -139,6 +145,7 @@ export interface UpdateProfileResponse { bio: string; guildName: string; guildDescription: string; + activeTitle: string; profileSettings: ProfileSettings; } diff --git a/packages/types/src/interfaces/Title.ts b/packages/types/src/interfaces/Title.ts new file mode 100644 index 0000000..acc4d68 --- /dev/null +++ b/packages/types/src/interfaces/Title.ts @@ -0,0 +1,26 @@ +export type TitleConditionType = + | "totalClicks" + | "totalGoldEarned" + | "bossesDefeated" + | "questsCompleted" + | "prestigeCount" + | "transcendenceCount" + | "apotheosisCount" + | "adventurerTotal" + | "achievementsUnlocked" + | "guildFounded" + | "playedDays"; + +export interface TitleCondition { + type: TitleConditionType; + /** Threshold required to unlock (not used for guildFounded) */ + amount?: number; +} + +export interface Title { + id: string; + name: string; + /** Human-readable description shown as the unlock hint */ + description: string; + condition: TitleCondition; +}