feat: add main questline / overarching story system (#24)

- 22 chapters across the full game arc (18 zone bosses + 4 milestones)
- Choice-based narrative with {characterName} dynamic substitution
- Story progress (unlocked + completed chapters) is permanent across all resets
- Server-side anti-cheat: chapters/choices can only accumulate, never be removed
- Tab badge and lower-right toast notifications for newly unlocked chapters
- Story choices displayed on the Character Sheet and Character Page
- How to Play entry added to About panel
This commit is contained in:
2026-03-07 17:15:08 -08:00
committed by Naomi Carrigan
parent ad5f2ad226
commit 4ed3ccc69c
14 changed files with 1146 additions and 5 deletions
@@ -103,6 +103,10 @@ const HOW_TO_PLAY = [
title: "✨ Apotheosis",
body: "Apotheosis is the final act — a complete dissolution of everything you have built, including your prestige and transcendence progress. It is unlocked once you have purchased every Transcendence upgrade. In exchange for this total reset, you receive the Apotheosis badge: pure bragging rights, a mark of reaching the absolute pinnacle of the game. Apotheosis can be achieved multiple times; each cycle requires purchasing all Transcendence upgrades again. Your Codex entries and lifetime profile statistics are always preserved.",
},
{
title: "📖 Story",
body: "The Story tab contains 22 chapters that unlock as you progress. The first 18 unlock when you defeat the final boss of each zone. Chapters 19 and 20 unlock after your first and fifth prestige respectively. Chapter 21 unlocks on your first transcendence, and Chapter 22 on your first apotheosis. Each chapter presents a narrative moment and three choices — the choice you make is recorded on your Character Sheet and shapes your guild's story. Story progress is permanent and survives all resets.",
},
];
const formatDate = (dateStr: string): string =>
@@ -1,5 +1,5 @@
import type { EquipmentBonus, EquipmentRarity, EquipmentType, ProfileSettings } from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS, STORY_CHAPTERS } from "@elysium/types";
import { useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/GameContext.js";
@@ -405,6 +405,28 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
<p className="character-sheet-empty">No guild registered yet. Click Edit to add one!</p>
)}
</div>
{(() => {
const completedChapters = state?.story?.completedChapters ?? [];
if (completedChapters.length === 0) return null;
return (
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">📖 Story Choices</h3>
{completedChapters.map((completion) => {
const chapter = STORY_CHAPTERS.find((c) => c.id === completion.chapterId);
if (!chapter) return null;
const choice = chapter.choices.find((c) => c.id === completion.choiceId);
if (!choice) return null;
return (
<div className="character-sheet-story-entry" key={completion.chapterId}>
<span className="character-sheet-story-chapter">{chapter.title}</span>
<span className="character-sheet-story-choice">{choice.label}</span>
</div>
);
})}
</div>
);
})()}
</div>
</section>
);
+11 -3
View File
@@ -25,8 +25,10 @@ import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
import { CompanionPanel } from "./CompanionPanel.js";
import { CraftingPanel } from "./CraftingPanel.js";
import { LoginBonusModal } from "./LoginBonusModal.js";
import { StoryPanel } from "./StoryPanel.js";
import { StoryToast } from "./StoryToast.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions" | "story";
const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
@@ -44,12 +46,13 @@ const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "companions", label: "👥 Companions" },
{ id: "character", label: "📋 Character" },
{ id: "achievements", label: "🏆 Achievements" },
{ id: "codex", label: "📖 Codex" },
{ id: "story", label: "📖 Story" },
{ id: "codex", label: "🗺️ Codex" },
{ id: "about", label: "️ About" },
];
export const GameLayout = (): React.JSX.Element => {
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, loginBonus, dismissLoginBonus } = useGame();
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, newStoryChapterIds, loginBonus, dismissLoginBonus } = useGame();
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
const [editingProfile, setEditingProfile] = useState(false);
@@ -90,6 +93,7 @@ export const GameLayout = (): React.JSX.Element => {
<OfflineModal />
<AchievementToast />
<CodexToast />
<StoryToast />
{loginBonus && (
<LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
)}
@@ -119,6 +123,9 @@ export const GameLayout = (): React.JSX.Element => {
{tab.id === "codex" && newCodexEntryIds.length > 0 && (
<span className="tab-badge">{newCodexEntryIds.length}</span>
)}
{tab.id === "story" && newStoryChapterIds.length > 0 && (
<span className="tab-badge">{newStoryChapterIds.length}</span>
)}
</button>
))}
</nav>
@@ -139,6 +146,7 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "daily" && <DailyChallengePanel />}
{activeTab === "companions" && <CompanionPanel />}
{activeTab === "character" && <CharacterSheetPanel />}
{activeTab === "story" && <StoryPanel />}
{activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />}
</div>
+112
View File
@@ -0,0 +1,112 @@
import { useState } from "react";
import { STORY_CHAPTERS } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
const substituteCharacterName = (text: string, characterName: string): string =>
text.replaceAll("{characterName}", characterName || "the guild leader");
export const StoryPanel = (): React.JSX.Element => {
const { state, completeChapter } = useGame();
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
if (!state) return <div className="story-panel"><p>Loading</p></div>;
const unlockedIds = state.story?.unlockedChapterIds ?? [];
const completedChapters = state.story?.completedChapters ?? [];
const characterName = state.player.characterName ?? "";
const activeChapter = STORY_CHAPTERS[activeChapterIndex];
const isUnlocked = unlockedIds.includes(activeChapter?.id ?? "");
const completion = activeChapter
? completedChapters.find((c) => c.chapterId === activeChapter.id)
: null;
const isUnread = isUnlocked && !completion;
return (
<div className="story-panel">
<div className="story-chapter-tabs">
{STORY_CHAPTERS.map((chapter, index) => {
const unlocked = unlockedIds.includes(chapter.id);
const completed = completedChapters.some((c) => c.chapterId === chapter.id);
const unread = unlocked && !completed;
return (
<button
key={chapter.id}
className={[
"story-tab-btn",
activeChapterIndex === index ? "active" : "",
!unlocked ? "locked" : "",
].join(" ")}
onClick={() => {
setActiveChapterIndex(index);
}}
type="button"
aria-label={unlocked ? chapter.title : `Chapter ${index + 1} (locked)`}
>
{index + 1}
{unread && <span className="story-unread-dot" />}
</button>
);
})}
</div>
{activeChapter && (
<div className="story-chapter-view">
{isUnlocked ? (
<>
<h2 className="story-chapter-title">
Chapter {activeChapterIndex + 1}: {activeChapter.title}
</h2>
<div className="story-chapter-content">
{substituteCharacterName(activeChapter.content, characterName)
.split("\n\n")
.map((paragraph, i) => (
// eslint-disable-next-line react/no-array-index-key -- static paragraph splits
<p key={i}>{paragraph}</p>
))}
</div>
{completion ? (
<div className="story-choice-result">
<p className="story-choice-label">
<strong>Your choice:</strong>{" "}
{activeChapter.choices.find((c) => c.id === completion.choiceId)?.label}
</p>
<p className="story-choice-outcome">
{substituteCharacterName(
activeChapter.choices.find((c) => c.id === completion.choiceId)?.outcome ?? "",
characterName,
)}
</p>
</div>
) : (
isUnread && (
<div className="story-choices">
<p className="story-choices-prompt">What do you do?</p>
{activeChapter.choices.map((choice) => (
<button
key={choice.id}
className="story-choice-btn"
onClick={() => {
completeChapter(activeChapter.id, choice.id);
}}
type="button"
>
{choice.label}
</button>
))}
</div>
)
)}
</>
) : (
<div className="story-locked">
<p className="story-locked-title">Chapter {activeChapterIndex + 1}</p>
<p className="story-locked-hint">🔒 This chapter has not yet been unlocked.</p>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,51 @@
import { useEffect } from "react";
import { STORY_CHAPTERS } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
interface StoryToastItemProps {
chapterId: string;
}
const StoryToastItem = ({ chapterId }: StoryToastItemProps): React.JSX.Element | null => {
const { dismissStoryChapter } = useGame();
const chapter = STORY_CHAPTERS.find((c) => c.id === chapterId);
useEffect(() => {
const timer = setTimeout(() => {
dismissStoryChapter(chapterId);
}, 4000);
return () => {
clearTimeout(timer);
};
}, [chapterId, dismissStoryChapter]);
if (!chapter) return null;
return (
<button
className="achievement-toast"
onClick={() => {
dismissStoryChapter(chapterId);
}}
type="button"
>
<span className="achievement-toast-icon">📖</span>
<div className="achievement-toast-content">
<span className="achievement-toast-label"> New Chapter!</span>
<span className="achievement-toast-name">{chapter.title}</span>
</div>
</button>
);
};
export const StoryToast = (): React.JSX.Element | null => {
const { newStoryChapterIds } = useGame();
if (newStoryChapterIds.length === 0) return null;
return (
<div className="achievement-toast-container">
{newStoryChapterIds.map((id) => (
<StoryToastItem key={id} chapterId={id} />
))}
</div>
);
};