feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
7 changed files with 460 additions and 5 deletions
Showing only changes of commit 924b9f541d - Show all commits
+6 -3
View File
@@ -13,9 +13,12 @@ model Player {
username String
discriminator String
avatar String?
characterName String @default("")
bio String @default("")
profileSettings Json?
characterName String @default("")
pronouns String @default("")
bio String @default("")
guildName String @default("")
guildDescription String @default("")
profileSettings Json?
createdAt Float
lastSavedAt Float
totalGoldEarned Float @default(0)
+10 -1
View File
@@ -69,9 +69,12 @@ profileRouter.get("/:discordId", async (context) => {
return context.json({
characterName: player.characterName,
pronouns: player.pronouns ?? "",
username: player.username,
avatar: player.avatar ?? null,
bio: player.bio ?? "",
guildName: player.guildName ?? "",
guildDescription: player.guildDescription ?? "",
profileSettings,
createdAt: player.createdAt,
// All Time stats — cumulative across all runs, never reset
@@ -99,7 +102,10 @@ profileRouter.put("/", authMiddleware, async (context) => {
const body = await context.req.json<UpdateProfileRequest>();
const characterName = (body.characterName ?? "").trim().slice(0, 32);
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
const numberFormat = VALID_NUMBER_FORMATS.has(body.profileSettings?.numberFormat as string)
? (body.profileSettings?.numberFormat as ProfileSettings["numberFormat"])
: "suffix";
@@ -129,12 +135,15 @@ profileRouter.put("/", authMiddleware, async (context) => {
const updated = await prisma.player.update({
where: { discordId },
data: { characterName, bio, profileSettings: profileSettings as object },
data: { characterName, pronouns, bio, guildName, guildDescription, profileSettings: profileSettings as object },
});
return context.json({
characterName: updated.characterName,
pronouns: updated.pronouns,
bio: updated.bio,
guildName: updated.guildName,
guildDescription: updated.guildDescription,
profileSettings,
});
});
@@ -63,6 +63,10 @@ const HOW_TO_PLAY = [
title: "📖 Codex",
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, discovering new zones, collecting from exploration areas, and crafting recipes all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 472 entries to build a complete picture of the world of Elysium.",
},
{
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.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
@@ -0,0 +1,255 @@
import type { ProfileSettings } from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
import { useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/GameContext.js";
interface CharacterSheetData {
characterName: string;
pronouns: string;
bio: string;
guildName: string;
guildDescription: string;
}
const EMPTY_SHEET: CharacterSheetData = {
characterName: "",
pronouns: "",
bio: "",
guildName: "",
guildDescription: "",
};
export const CharacterSheetPanel = (): React.JSX.Element => {
const { state } = useGame();
const player = state?.player;
const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET);
const [draft, setDraft] = useState<CharacterSheetData>(EMPTY_SHEET);
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const savedSettingsRef = useRef<ProfileSettings>({ ...DEFAULT_PROFILE_SETTINGS });
useEffect(() => {
if (!player?.discordId) return;
fetch(`/api/profile/${player.discordId}`)
.then(async (res) => {
if (!res.ok) return;
const data = (await res.json()) as {
characterName: string;
pronouns: string;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
};
const loaded: CharacterSheetData = {
characterName: data.characterName ?? "",
pronouns: data.pronouns ?? "",
bio: data.bio ?? "",
guildName: data.guildName ?? "",
guildDescription: data.guildDescription ?? "",
};
setSheet(loaded);
setDraft(loaded);
savedSettingsRef.current = { ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings };
})
.catch(() => { /* fall back to empty */ })
.finally(() => { setLoading(false); });
}, [player?.discordId]);
const handleEdit = (): void => {
setDraft({ ...sheet });
setEditing(true);
setError(null);
setSaved(false);
};
const handleCancel = (): void => {
setEditing(false);
setError(null);
};
const handleSave = async (): Promise<void> => {
setSaving(true);
setError(null);
try {
await updateProfile({
characterName: draft.characterName || (player?.characterName ?? ""),
pronouns: draft.pronouns,
bio: draft.bio,
guildName: draft.guildName,
guildDescription: draft.guildDescription,
profileSettings: savedSettingsRef.current,
});
setSheet({ ...draft });
setSaved(true);
setTimeout(() => {
setEditing(false);
setSaved(false);
}, 900);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
if (loading) {
return <section className="panel"><p>Loading character sheet</p></section>;
}
if (editing) {
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
<h2>📋 Character Sheet</h2>
</div>
<div className="character-sheet-form">
<div className="character-sheet-section">
<h3 className="character-sheet-section-title"> Character</h3>
<label className="character-sheet-label" htmlFor="cs-name">Character Name</label>
<input
className="character-sheet-input"
id="cs-name"
maxLength={32}
placeholder="Your character's name"
type="text"
value={draft.characterName}
onChange={(e) => { setDraft((d) => ({ ...d, characterName: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.characterName.length} / 32</span>
<label className="character-sheet-label" htmlFor="cs-pronouns">Pronouns</label>
<input
className="character-sheet-input"
id="cs-pronouns"
maxLength={20}
placeholder="e.g. she/her, he/him, they/them"
type="text"
value={draft.pronouns}
onChange={(e) => { setDraft((d) => ({ ...d, pronouns: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.pronouns.length} / 20</span>
<label className="character-sheet-label" htmlFor="cs-bio">About Your Character</label>
<textarea
className="character-sheet-textarea"
id="cs-bio"
maxLength={200}
placeholder="Describe your character's story, personality, or appearance…"
rows={4}
value={draft.bio}
onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.bio.length} / 200</span>
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">🏰 Guild</h3>
<label className="character-sheet-label" htmlFor="cs-guild-name">Guild Name</label>
<input
className="character-sheet-input"
id="cs-guild-name"
maxLength={64}
placeholder="Name your guild"
type="text"
value={draft.guildName}
onChange={(e) => { setDraft((d) => ({ ...d, guildName: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.guildName.length} / 64</span>
<label className="character-sheet-label" htmlFor="cs-guild-desc">Guild Description</label>
<textarea
className="character-sheet-textarea"
id="cs-guild-desc"
maxLength={500}
placeholder="Describe your guild's history, goals, or lore…"
rows={6}
value={draft.guildDescription}
onChange={(e) => { setDraft((d) => ({ ...d, guildDescription: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.guildDescription.length} / 500</span>
</div>
{error && <p className="character-sheet-error">{error}</p>}
<div className="character-sheet-actions">
<button className="character-sheet-cancel" onClick={handleCancel} type="button">
Cancel
</button>
<button
className="character-sheet-save"
disabled={saving || !draft.characterName.trim()}
onClick={() => { void handleSave(); }}
type="button"
>
{saved ? "✓ Saved!" : saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</section>
);
}
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
<h2>📋 Character Sheet</h2>
<button className="character-sheet-edit-btn" onClick={handleEdit} type="button">
Edit
</button>
</div>
<div className="character-sheet-view">
<div className="character-sheet-section">
<h3 className="character-sheet-section-title"> Character</h3>
<div className="character-sheet-field">
<span className="character-sheet-field-label">Name</span>
<span className="character-sheet-field-value">
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
</span>
</div>
{sheet.pronouns && (
<div className="character-sheet-field">
<span className="character-sheet-field-label">Pronouns</span>
<span className="character-sheet-field-value">{sheet.pronouns}</span>
</div>
)}
{sheet.bio && (
<div className="character-sheet-bio">
<span className="character-sheet-field-label">About</span>
<p className="character-sheet-bio-text">{sheet.bio}</p>
</div>
)}
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">🏰 Guild</h3>
{sheet.guildName ? (
<>
<div className="character-sheet-field">
<span className="character-sheet-field-label">Name</span>
<span className="character-sheet-field-value">{sheet.guildName}</span>
</div>
{sheet.guildDescription && (
<div className="character-sheet-bio">
<span className="character-sheet-field-label">Lore</span>
<p className="character-sheet-bio-text">{sheet.guildDescription}</p>
</div>
)}
</>
) : (
<p className="character-sheet-empty">No guild registered yet. Click Edit to add one!</p>
)}
</div>
</div>
</section>
);
};
+4 -1
View File
@@ -21,9 +21,10 @@ import { StatisticsPanel } from "./StatisticsPanel.js";
import { UpgradePanel } from "./UpgradePanel.js";
import { DailyChallengePanel } from "./DailyChallengePanel.js";
import { ExplorationPanel } from "./ExplorationPanel.js";
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
import { CraftingPanel } from "./CraftingPanel.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
@@ -38,6 +39,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "transcendence", label: "🌌 Transcendence" },
{ id: "apotheosis", label: "✨ Apotheosis" },
{ id: "statistics", label: "📊 Statistics" },
{ id: "character", label: "📋 Character" },
{ id: "achievements", label: "🏆 Achievements" },
{ id: "codex", label: "📖 Codex" },
{ id: "about", label: "️ About" },
@@ -129,6 +131,7 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "crafting" && <CraftingPanel />}
{activeTab === "statistics" && <StatisticsPanel />}
{activeTab === "daily" && <DailyChallengePanel />}
{activeTab === "character" && <CharacterSheetPanel />}
{activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />}
</div>
+172
View File
@@ -3070,3 +3070,175 @@ body {
padding: 0.3rem 0.6rem;
}
}
/* ===================== CHARACTER SHEET ===================== */
.character-sheet-panel .panel-header {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.character-sheet-edit-btn {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
cursor: pointer;
font-size: 0.85rem;
padding: 0.3rem 0.75rem;
transition: border-color 0.15s, color 0.15s;
}
.character-sheet-edit-btn:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.character-sheet-view,
.character-sheet-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.character-sheet-section {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.character-sheet-section-title {
color: var(--colour-accent);
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.character-sheet-field {
align-items: baseline;
display: flex;
gap: 0.75rem;
}
.character-sheet-field-label {
color: var(--colour-text-muted);
font-size: 0.8rem;
font-weight: 600;
min-width: 5rem;
text-transform: uppercase;
}
.character-sheet-field-value {
color: var(--colour-text);
font-size: 0.95rem;
}
.character-sheet-bio {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.25rem;
}
.character-sheet-bio-text {
color: var(--colour-text);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
.character-sheet-empty {
color: var(--colour-text-muted);
font-size: 0.85rem;
font-style: italic;
}
.character-sheet-label {
color: var(--colour-text-muted);
display: block;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.character-sheet-input {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
width: 100%;
}
.character-sheet-input:focus {
border-color: var(--colour-accent);
outline: none;
}
.character-sheet-textarea {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
font-family: inherit;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
resize: vertical;
width: 100%;
}
.character-sheet-textarea:focus {
border-color: var(--colour-accent);
outline: none;
}
.character-sheet-hint {
color: var(--colour-text-muted);
font-size: 0.75rem;
text-align: right;
}
.character-sheet-error {
color: #e74c3c;
font-size: 0.85rem;
}
.character-sheet-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.character-sheet-cancel {
background: transparent;
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 0.5rem 1.25rem;
}
.character-sheet-save {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
padding: 0.5rem 1.25rem;
transition: opacity 0.15s;
}
.character-sheet-save:disabled {
cursor: not-allowed;
opacity: 0.5;
}
+9
View File
@@ -91,9 +91,12 @@ export interface BuyPrestigeUpgradeResponse {
export interface PublicProfileResponse {
characterName: string;
pronouns: string;
username: string;
avatar: string | null;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
createdAt: number;
/** All Time stats — cumulative across all runs, never reset */
@@ -117,13 +120,19 @@ export interface PublicProfileResponse {
export interface UpdateProfileRequest {
characterName: string;
pronouns: string;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
}
export interface UpdateProfileResponse {
characterName: string;
pronouns: string;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
}