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("")
|
guildName String @default("")
|
||||||
guildDescription String @default("")
|
guildDescription String @default("")
|
||||||
profileSettings Json?
|
profileSettings Json?
|
||||||
|
unlockedTitles Json?
|
||||||
|
activeTitle String @default("")
|
||||||
createdAt Float
|
createdAt Float
|
||||||
lastSavedAt Float
|
lastSavedAt Float
|
||||||
totalGoldEarned Float @default(0)
|
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 { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
|
import { checkAndUnlockTitles, parseUnlockedTitles } from "../services/titles.js";
|
||||||
|
|
||||||
const RESOURCE_CAP = 1e300;
|
const RESOURCE_CAP = 1e300;
|
||||||
|
|
||||||
@@ -646,9 +647,14 @@ gameRouter.get("/load", async (context) => {
|
|||||||
state.lastTickAt = now;
|
state.lastTickAt = now;
|
||||||
|
|
||||||
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
|
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({
|
await prisma.gameState.update({
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
data: { state: state as object, updatedAt: now },
|
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 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;
|
let stateToSave = body.state;
|
||||||
|
|
||||||
@@ -694,6 +703,17 @@ gameRouter.post("/save", async (context) => {
|
|||||||
player: { ...stateToSave.player, lastSavedAt: now },
|
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({
|
await prisma.player.update({
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
data: {
|
data: {
|
||||||
@@ -701,6 +721,7 @@ gameRouter.post("/save", async (context) => {
|
|||||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||||
totalClicks: stateToSave.player.totalClicks,
|
totalClicks: stateToSave.player.totalClicks,
|
||||||
characterName: stateToSave.player.characterName,
|
characterName: stateToSave.player.characterName,
|
||||||
|
...(updatedUnlocked ? { unlockedTitles: updatedUnlocked } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Hono } from "hono";
|
|||||||
import type { HonoEnv } from "../types/hono.js";
|
import type { HonoEnv } from "../types/hono.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.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>();
|
export const profileRouter = new Hono<HonoEnv>();
|
||||||
|
|
||||||
@@ -67,6 +69,12 @@ profileRouter.get("/:discordId", async (context) => {
|
|||||||
const achievementsUnlocked =
|
const achievementsUnlocked =
|
||||||
(state?.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
|
(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({
|
return context.json({
|
||||||
characterName: player.characterName,
|
characterName: player.characterName,
|
||||||
pronouns: player.pronouns ?? "",
|
pronouns: player.pronouns ?? "",
|
||||||
@@ -96,6 +104,8 @@ profileRouter.get("/:discordId", async (context) => {
|
|||||||
questsCompleted,
|
questsCompleted,
|
||||||
adventurersRecruited,
|
adventurersRecruited,
|
||||||
achievementsUnlocked,
|
achievementsUnlocked,
|
||||||
|
unlockedTitles,
|
||||||
|
activeTitle: player.activeTitle ?? "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,9 +147,21 @@ profileRouter.put("/", authMiddleware, async (context) => {
|
|||||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
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({
|
const updated = await prisma.player.update({
|
||||||
where: { discordId },
|
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({
|
return context.json({
|
||||||
@@ -151,5 +173,6 @@ profileRouter.put("/", authMiddleware, async (context) => {
|
|||||||
guildName: updated.guildName,
|
guildName: updated.guildName,
|
||||||
guildDescription: updated.guildDescription,
|
guildDescription: updated.guildDescription,
|
||||||
profileSettings,
|
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 [];
|
||||||
|
};
|
||||||
@@ -65,7 +65,11 @@ const HOW_TO_PLAY = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "📋 Character Sheet",
|
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",
|
title: "☁️ Cloud Saves",
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem
|
|||||||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
|
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
|
||||||
|
|
||||||
const subtitle = [profile.characterRace, profile.characterClass].filter(Boolean).join(" · ");
|
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;
|
const hasBadge = profile.apotheosisCount > 0 || profile.transcendenceCount > 0 || profile.prestigeCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +71,9 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem
|
|||||||
<h1 className="character-page-name">
|
<h1 className="character-page-name">
|
||||||
{profile.characterName || profile.username}
|
{profile.characterName || profile.username}
|
||||||
</h1>
|
</h1>
|
||||||
|
{activeTitleName && (
|
||||||
|
<p className="character-page-title">{activeTitleName}</p>
|
||||||
|
)}
|
||||||
{profile.pronouns && (
|
{profile.pronouns && (
|
||||||
<p className="character-page-pronouns">{profile.pronouns}</p>
|
<p className="character-page-pronouns">{profile.pronouns}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface CharacterSheetData {
|
|||||||
bio: string;
|
bio: string;
|
||||||
guildName: string;
|
guildName: string;
|
||||||
guildDescription: string;
|
guildDescription: string;
|
||||||
|
activeTitle: string;
|
||||||
|
unlockedTitles: Array<{ id: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_SHEET: CharacterSheetData = {
|
const EMPTY_SHEET: CharacterSheetData = {
|
||||||
@@ -22,6 +24,8 @@ const EMPTY_SHEET: CharacterSheetData = {
|
|||||||
bio: "",
|
bio: "",
|
||||||
guildName: "",
|
guildName: "",
|
||||||
guildDescription: "",
|
guildDescription: "",
|
||||||
|
activeTitle: "",
|
||||||
|
unlockedTitles: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CharacterSheetPanel = (): React.JSX.Element => {
|
export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||||
@@ -52,6 +56,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
|||||||
guildName: string;
|
guildName: string;
|
||||||
guildDescription: string;
|
guildDescription: string;
|
||||||
profileSettings: ProfileSettings;
|
profileSettings: ProfileSettings;
|
||||||
|
activeTitle: string;
|
||||||
|
unlockedTitles: Array<{ id: string; name: string }>;
|
||||||
};
|
};
|
||||||
const loaded: CharacterSheetData = {
|
const loaded: CharacterSheetData = {
|
||||||
characterName: data.characterName ?? "",
|
characterName: data.characterName ?? "",
|
||||||
@@ -61,6 +67,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
|||||||
bio: data.bio ?? "",
|
bio: data.bio ?? "",
|
||||||
guildName: data.guildName ?? "",
|
guildName: data.guildName ?? "",
|
||||||
guildDescription: data.guildDescription ?? "",
|
guildDescription: data.guildDescription ?? "",
|
||||||
|
activeTitle: data.activeTitle ?? "",
|
||||||
|
unlockedTitles: data.unlockedTitles ?? [],
|
||||||
};
|
};
|
||||||
setSheet(loaded);
|
setSheet(loaded);
|
||||||
setDraft(loaded);
|
setDraft(loaded);
|
||||||
@@ -95,6 +103,7 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
|||||||
guildName: draft.guildName,
|
guildName: draft.guildName,
|
||||||
guildDescription: draft.guildDescription,
|
guildDescription: draft.guildDescription,
|
||||||
profileSettings: savedSettingsRef.current,
|
profileSettings: savedSettingsRef.current,
|
||||||
|
activeTitle: draft.activeTitle,
|
||||||
});
|
});
|
||||||
setSheet({ ...draft });
|
setSheet({ ...draft });
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
@@ -183,6 +192,23 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
|||||||
onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }}
|
onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }}
|
||||||
/>
|
/>
|
||||||
<span className="character-sheet-hint">{draft.bio.length} / 200</span>
|
<span className="character-sheet-hint">{draft.bio.length} / 200</span>
|
||||||
|
|
||||||
|
{draft.unlockedTitles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<label className="character-sheet-label" htmlFor="cs-title">Active Title</label>
|
||||||
|
<select
|
||||||
|
className="character-sheet-input"
|
||||||
|
id="cs-title"
|
||||||
|
value={draft.activeTitle}
|
||||||
|
onChange={(e) => { setDraft((d) => ({ ...d, activeTitle: e.target.value })); }}
|
||||||
|
>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{draft.unlockedTitles.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="character-sheet-section">
|
<div className="character-sheet-section">
|
||||||
@@ -268,6 +294,14 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
|||||||
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{sheet.activeTitle && (
|
||||||
|
<div className="character-sheet-field">
|
||||||
|
<span className="character-sheet-field-label">Title</span>
|
||||||
|
<span className="character-sheet-field-value character-sheet-title">
|
||||||
|
{sheet.unlockedTitles.find((t) => t.id === sheet.activeTitle)?.name ?? sheet.activeTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{sheet.pronouns && (
|
{sheet.pronouns && (
|
||||||
<div className="character-sheet-field">
|
<div className="character-sheet-field">
|
||||||
<span className="character-sheet-field-label">Pronouns</span>
|
<span className="character-sheet-field-label">Pronouns</span>
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
|||||||
|
|
||||||
const failureChance = ZONE_FAILURE_CHANCE[quest.zoneId] ?? 0.20;
|
const failureChance = ZONE_FAILURE_CHANCE[quest.zoneId] ?? 0.20;
|
||||||
if (Math.random() < failureChance) {
|
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) {
|
for (const reward of quest.rewards) {
|
||||||
|
|||||||
@@ -3210,6 +3210,11 @@ body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-sheet-title {
|
||||||
|
color: var(--colour-accent);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.character-sheet-error {
|
.character-sheet-error {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -3308,6 +3313,13 @@ body {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-page-title {
|
||||||
|
color: var(--colour-accent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.character-page-pronouns {
|
.character-page-pronouns {
|
||||||
color: var(--colour-text-muted);
|
color: var(--colour-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -89,3 +89,4 @@ export type {
|
|||||||
TranscendenceUpgrade,
|
TranscendenceUpgrade,
|
||||||
TranscendenceUpgradeCategory,
|
TranscendenceUpgradeCategory,
|
||||||
} from "./interfaces/Transcendence.js";
|
} from "./interfaces/Transcendence.js";
|
||||||
|
export type { Title, TitleCondition, TitleConditionType } from "./interfaces/Title.js";
|
||||||
|
|||||||
@@ -118,17 +118,23 @@ export interface PublicProfileResponse {
|
|||||||
questsCompleted: number;
|
questsCompleted: number;
|
||||||
adventurersRecruited: number;
|
adventurersRecruited: number;
|
||||||
achievementsUnlocked: 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 {
|
export interface UpdateProfileRequest {
|
||||||
characterName: string;
|
characterName: string;
|
||||||
pronouns: string;
|
pronouns?: string;
|
||||||
characterRace: string;
|
characterRace?: string;
|
||||||
characterClass: string;
|
characterClass?: string;
|
||||||
bio: string;
|
bio?: string;
|
||||||
guildName: string;
|
guildName?: string;
|
||||||
guildDescription: string;
|
guildDescription?: string;
|
||||||
profileSettings: ProfileSettings;
|
profileSettings: ProfileSettings;
|
||||||
|
/** Title ID to set as active (empty string to clear) */
|
||||||
|
activeTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProfileResponse {
|
export interface UpdateProfileResponse {
|
||||||
@@ -139,6 +145,7 @@ export interface UpdateProfileResponse {
|
|||||||
bio: string;
|
bio: string;
|
||||||
guildName: string;
|
guildName: string;
|
||||||
guildDescription: string;
|
guildDescription: string;
|
||||||
|
activeTitle: string;
|
||||||
profileSettings: ProfileSettings;
|
profileSettings: ProfileSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user