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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
<h1 className="character-page-name">
|
||||
{profile.characterName || profile.username}
|
||||
</h1>
|
||||
{activeTitleName && (
|
||||
<p className="character-page-title">{activeTitleName}</p>
|
||||
)}
|
||||
{profile.pronouns && (
|
||||
<p className="character-page-pronouns">{profile.pronouns}</p>
|
||||
)}
|
||||
|
||||
@@ -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 })); }}
|
||||
/>
|
||||
<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 className="character-sheet-section">
|
||||
@@ -268,6 +294,14 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="character-sheet-field">
|
||||
<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;
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user